diff --git a/SPEC.md b/SPEC.md index 19f6db1..d59ff98 100644 --- a/SPEC.md +++ b/SPEC.md @@ -436,7 +436,8 @@ repos: rdf: https://acme.example/knowledge/graph.nt # SPARQL harvest source concepts: https://acme.example/knowledge/concepts.jsonld # offline document access void: { triples: 86, class_partition: { Metric: 1, Dataset: 1, GlossaryTerm: 1 } } - id_index: [ ] # explicit `id:` IRIs outside base_iri + id_index: # explicit `id:` IRIs outside base_iri → Concept ID + https://acme.example/legacy/wau: metrics/weekly-active-users status: ok ``` @@ -452,12 +453,15 @@ target is *already* a correct triple. Three rules keep it trustworthy: 1. **Explicit-id index.** A concept's frontmatter `id:` may diverge from `base_iri + concept_id` (§5). Such IRIs are harvested into the entry's - `id_index` and checked as an exact-match fallback before an IRI is declared - external, so they still route. -2. **Namespace precedence & non-nesting.** The packaged vocabulary namespace - (`https://w3id.org/lokf/`) always resolves to the built-in schema; registered - `base_iri`s must be strictly longer and may not nest inside one another, so - routing is unambiguous. + `id_index` — a map from the explicit IRI to its Concept ID — and checked as + an exact-match fallback before an IRI is declared external, so they still + route (to the right source document). +2. **Boundaries, namespace precedence & non-nesting.** A member `base_iri` must + end in a path separator (`/` or `#`) so prefix routing respects segment + boundaries — `…/team/` never captures `…/team-archive/`. It must be strictly + longer than, and may not capture, the packaged vocabulary namespace + (`https://w3id.org/lokf/`), which is reserved for the built-in schema; and no + two `base_iri`s may nest, so at most one prefixes any IRI. 3. **Ownership validation.** At registration a member's sampled concept IRIs must actually start with its declared `base_iri`, so a bundle cannot claim a namespace it does not own. diff --git a/src/lokf/cli.py b/src/lokf/cli.py index 16206fa..2a2280a 100644 --- a/src/lokf/cli.py +++ b/src/lokf/cli.py @@ -392,6 +392,120 @@ def mcp() -> None: run_mcp() +# --------------------------------------------------------------------------- +# registry — federate multiple bundles (meta-lokf); see SPEC §11 +# --------------------------------------------------------------------------- +registry_app = typer.Typer( + help="Federate bundles: a registry of LOKF bundles (meta-lokf).", + no_args_is_help=True, +) +app.add_typer(registry_app, name="registry") + + +@registry_app.command("init") +def registry_init( + registry: Path = typer.Option( + Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest path to create." + ), + catalog_id: str = typer.Option("", "--id", help="Registry IRI (dcat:Catalog @id)."), + title: str = typer.Option("", "--title", help="Registry title."), +) -> None: + """Scaffold an empty registry manifest.""" + from lokf.registry import Registry + + if registry.exists(): + _err(f"{registry} already exists") + raise typer.Exit(1) + Registry(path=registry, id=catalog_id, title=title).save() + typer.echo(f"wrote {registry}") + + +@registry_app.command("add") +def registry_add( + bundle_dir: Path = typer.Argument( + ..., exists=True, file_okay=False, help="A local LOKF bundle directory." + ), + registry: Path = typer.Option( + Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to append to." + ), + source_base: Optional[str] = typer.Option( + None, + "--source-base", + help="URL prefix for source .md files (defaults to the bundle's file:// path).", + ), +) -> None: + """Register a local bundle: derive its base_iri, VoID index, and id_index.""" + from lokf.registry import entry_for_bundle, load_registry + + if not registry.exists(): + _err(f"{registry} not found — run `lokf registry init` first") + raise typer.Exit(1) + reg = load_registry(registry) + try: + entry = entry_for_bundle(bundle_dir, source_base=source_base) + reg.add(entry) + except ValueError as exc: + _err(f"cannot register {bundle_dir}: {exc}") + raise typer.Exit(1) + reg.save() + typer.echo( + f"registered {entry.base_iri} " + f"({entry.void.get('triples', 0)} triples) in {registry}" + ) + + +@registry_app.command("list") +def registry_list( + registry: Path = typer.Option( + Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to read." + ), +) -> None: + """Print the routing table: base_iri, title, triples, status.""" + from lokf.registry import load_registry + + if not registry.exists(): + _err(f"{registry} not found — run `lokf registry init` first") + raise typer.Exit(1) + reg = load_registry(registry) + if not reg.repos: + typer.echo("(no members registered)") + return + for e in sorted(reg.repos, key=lambda e: e.base_iri): + triples = e.void.get("triples", "?") + typer.echo(f"{e.base_iri}\t{e.title}\t{triples} triples\t{e.status}") + + +@registry_app.command("resolve") +def registry_resolve( + iri: str = typer.Argument(..., help="An absolute concept IRI to resolve."), + registry: Path = typer.Option( + Path("lokf-registry.yaml"), "--registry", "-r", help="Manifest to read." + ), +) -> None: + """Resolve an IRI to its owning bundle, Concept ID, and source URL (offline). + + Exits non-zero for an IRI no member owns — a tolerated dangling cross-link, + not owned here. + """ + from lokf.registry import load_registry + + if not registry.exists(): + _err(f"{registry} not found — run `lokf registry init` first") + raise typer.Exit(1) + res = load_registry(registry).resolve(iri) + if res.external: + typer.echo(f"{iri}\n external: not owned by any registered bundle") + raise typer.Exit(1) + typer.echo(iri) + typer.echo(f" owner: {res.entry.base_iri} ({res.entry.title})") + if res.concept_id: + typer.echo(f" concept_id: {res.concept_id}") + typer.echo(f" source_url: {res.source_url or '(none)'}") + else: + typer.echo(" concept_id: (namespace root — not a concept)") + typer.echo(f" via: {res.via}") + + def main(argv: list[str] | None = None) -> int: """Programmatic entry point (tests use ``typer.testing.CliRunner``). diff --git a/src/lokf/registry.py b/src/lokf/registry.py new file mode 100644 index 0000000..6364592 --- /dev/null +++ b/src/lokf/registry.py @@ -0,0 +1,260 @@ +"""Cross-bundle federation: a registry of LOKF bundles (*meta-lokf*). + +A registry (``lokf-registry.yaml``) maps each member bundle's ``base_iri`` to +where its exported artifacts and source documents live. The one load-bearing +operation is :meth:`Registry.owner` — longest-``base_iri``-prefix routing, the +exact inverse of :meth:`lokf.model.Bundle.iri` minting — so following an IRI +from one bundle into another is pure string math: no network, no shared +database. See SPEC §11. + +This module is the offline core (Phase 1): loading/saving the manifest, +building an entry from a local bundle, and resolving an IRI to its owning +bundle + Concept ID + source URL. Harvesting remote artifacts and traversing a +federated store land in later phases. +""" +from __future__ import annotations + +import dataclasses +import pathlib +from collections import Counter +from dataclasses import dataclass, field + +import yaml + +#: The ontology ``@vocab``. Never a repo ``base_iri`` — an IRI under it that no +#: (strictly longer) member owns is a vocabulary term, not a foreign concept. +VOCAB_NS = "https://w3id.org/lokf/" +REGISTRY_VERSION = "0.1" +DEFAULT_REGISTRY = "lokf-registry.yaml" + + +@dataclass +class RepoEntry: + """One member bundle. ``base_iri`` is its identity and routing key.""" + + base_iri: str + title: str = "" + repo: str | None = None + source_base: str = "" + path: str | None = None + distribution: dict = field(default_factory=dict) + void: dict = field(default_factory=dict) + id_index: dict = field(default_factory=dict) # explicit-id IRI -> Concept ID + sensitivity: str | None = None + status: str = "ok" + + +@dataclass +class Resolution: + """The result of resolving an IRI against a registry.""" + + iri: str + entry: RepoEntry | None + concept_id: str | None + source_url: str | None + via: str | None # "prefix" | "id_index" | None + + @property + def external(self) -> bool: + """True when no member owns the IRI (a tolerated dangling link).""" + return self.entry is None + + +def _source_url(entry: RepoEntry, concept_id: str | None) -> str | None: + """A concept's source-markdown URL: ``source_base``/``concept_id``.md. + + Returns ``None`` when the entry has no ``source_base`` or the IRI is the + bundle's namespace root (empty Concept ID) — neither names a document. + """ + if not entry.source_base or not concept_id: + return None + return entry.source_base.rstrip("/") + "/" + concept_id + ".md" + + +@dataclass +class Registry: + """A registry of LOKF bundles, loaded from ``lokf-registry.yaml``.""" + + path: pathlib.Path + id: str = "" + title: str = "" + publisher: dict = field(default_factory=dict) + version: str = REGISTRY_VERSION + repos: list[RepoEntry] = field(default_factory=list) + + # -- resolution --------------------------------------------------------- + def _match(self, iri: str) -> tuple[RepoEntry | None, str | None, str | None]: + """(entry, via, concept_id) for *iri*, or (None, None, None). + + Longest-``base_iri``-prefix wins; an explicit-``id`` in an ``id_index`` + is only a *fallback* for an IRI that no base_iri prefixes (SPEC §11 + rule 1), so a stale index entry can never hijack an IRI a member owns by + prefix. + """ + best: RepoEntry | None = None + for e in self.repos: + if e.base_iri and iri.startswith(e.base_iri): + if best is None or len(e.base_iri) > len(best.base_iri): + best = e + if best is not None: + return best, "prefix", iri[len(best.base_iri):].lstrip("/") + for e in self.repos: + if iri in e.id_index: + return e, "id_index", e.id_index[iri] + return None, None, None + + def owner(self, iri: str) -> RepoEntry | None: + """The member bundle that owns *iri*, or ``None`` if external.""" + return self._match(iri)[0] + + def resolve(self, iri: str) -> Resolution: + """Resolve *iri* to its owning bundle, Concept ID, and source URL.""" + entry, via, concept_id = self._match(iri) + return Resolution( + iri=iri, + entry=entry, + concept_id=concept_id, + source_url=_source_url(entry, concept_id) if entry else None, + via=via, + ) + + # -- mutation ----------------------------------------------------------- + def add(self, entry: RepoEntry) -> None: + """Validate and append *entry*. Raises ``ValueError`` on conflict. + + A base_iri must be non-empty, end in a path separator (``/`` or ``#``) + so prefix routing respects segment boundaries (``…/team/`` never + captures ``…/team-archive/``), stay clear of the vocabulary namespace, + and neither duplicate nor nest with an existing member (either would + make routing ambiguous). + """ + b = entry.base_iri + if not b: + raise ValueError("entry has no base_iri") + if not b.endswith(("/", "#")): + raise ValueError(f"base_iri must end with '/' or '#': {b!r}") + if VOCAB_NS.startswith(b): + raise ValueError( + f"base_iri {b!r} captures the vocabulary namespace {VOCAB_NS}; " + "a member base_iri must be strictly longer" + ) + for e in self.repos: + if e.base_iri == b: + raise ValueError(f"base_iri already registered: {b}") + if b.startswith(e.base_iri) or e.base_iri.startswith(b): + raise ValueError( + f"base_iri {b!r} nests with registered {e.base_iri!r}; " + "base_iris must not be prefixes of one another" + ) + self.repos.append(entry) + + def to_dict(self) -> dict: + """The manifest as a plain dict for YAML serialization.""" + d: dict = {"lokf_registry_version": self.version, "type": "dcat:Catalog"} + if self.id: + d["id"] = self.id + if self.title: + d["title"] = self.title + if self.publisher: + d["publisher"] = self.publisher + d["repos"] = [_entry_dict(e) for e in self.repos] + return d + + def save(self) -> None: + """Write the manifest back to :attr:`path` (block-style YAML).""" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text( + yaml.safe_dump(self.to_dict(), sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + +_FIELDS = dataclasses.fields(RepoEntry) +_ENTRY_FIELDS = {f.name for f in _FIELDS} +_ENTRY_DEFAULTS = { + f.name: (f.default_factory() if f.default_factory is not dataclasses.MISSING else f.default) + for f in _FIELDS +} + + +def _entry_dict(entry: RepoEntry) -> dict: + """A RepoEntry as a dict, dropping fields left at their default value.""" + out = {} + for k, v in dataclasses.asdict(entry).items(): + if k != "base_iri" and v == _ENTRY_DEFAULTS.get(k): + continue + out[k] = v + return out + + +def load_registry(path: str | pathlib.Path) -> Registry: + """Load ``lokf-registry.yaml`` with a plain YAML reader (no LinkML). + + Tolerant of a hand-edited manifest: a null ``repos:`` or null field values + fall back to defaults rather than crashing a later lookup. + """ + p = pathlib.Path(path) + data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError(f"{p}: registry must be a YAML mapping, got {type(data).__name__}") + entries = data.get("repos") or [] + if not isinstance(entries, list): + raise ValueError(f"{p}: 'repos' must be a list") + repos = [ + RepoEntry(**{k: v for k, v in (r or {}).items() if k in _ENTRY_FIELDS and v is not None}) + for r in entries + ] + return Registry( + path=p, + id=data.get("id") or "", + title=data.get("title") or "", + publisher=data.get("publisher") or {}, + version=str(data.get("lokf_registry_version") or REGISTRY_VERSION), + repos=repos, + ) + + +def entry_for_bundle( + bundle_dir: str | pathlib.Path, + source_base: str | None = None, +) -> RepoEntry: + """Build a :class:`RepoEntry` from a local bundle directory. + + Derives ``base_iri``/``title``/``publisher`` from the bundle's ``index.md``, + computes a VoID planning index (triple + per-type counts), and harvests an + ``id_index`` of explicit-``id`` IRIs that diverge from ``base_iri`` + + Concept ID (so they still route). Raises ``ValueError`` if the bundle has + no ``base_iri`` or a concept IRI falls outside it and is not indexable. + """ + from lokf.model import load_bundle + + bundle = load_bundle(bundle_dir) + base_iri = bundle.base_iri + if not base_iri: + raise ValueError(f"{bundle_dir}: bundle index.md has no base_iri; cannot federate") + + id_index: dict[str, str] = {} + for c in bundle.concepts: + actual = bundle.iri(c) + if actual != base_iri + c.concept_id: # explicit id diverges from the mint rule + id_index[actual] = c.concept_id + + # Ownership: every concept IRI must be routable (under base_iri or indexed). + for iri in bundle.by_iri(): + if not iri.startswith(base_iri) and iri not in id_index: + raise ValueError( + f"concept IRI {iri} is outside base_iri {base_iri} and not indexable" + ) + + part = Counter(c.type for c in bundle.concepts) + void = {"triples": len(bundle.graph()), "class_partition": dict(part)} + + root = pathlib.Path(bundle_dir).resolve() + return RepoEntry( + base_iri=base_iri, + title=bundle.meta.get("title") or base_iri, + source_base=source_base or root.as_uri(), + path=str(root), + void=void, + id_index=id_index, + ) diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..52a6106 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,250 @@ +"""Tests for the cross-bundle registry (meta-lokf) — SPEC §11. + +Covers the offline Phase 1 engine: longest-``base_iri``-prefix routing, the +explicit-``id`` fallback, the registration guards, entry building from a local +bundle, and the ``lokf registry`` CLI round-trip. +""" +from __future__ import annotations + +import pathlib + +import pytest +import yaml +from typer.testing import CliRunner + +from lokf.cli import app +from lokf.registry import ( + VOCAB_NS, + RepoEntry, + Registry, + entry_for_bundle, + load_registry, +) + +runner = CliRunner() +BUNDLE = pathlib.Path(__file__).resolve().parents[1] / "examples" / "acme-knowledge" + + +def _reg(*bases: str) -> Registry: + """A registry built directly from base_iris (bypassing add() guards).""" + return Registry( + path=pathlib.Path("x"), + repos=[RepoEntry(base_iri=b, title=b, source_base=b) for b in bases], + ) + + +# -- routing ---------------------------------------------------------------- +def test_owner_longest_prefix_wins(): + """The owner is the longest base_iri that prefixes the IRI, even when nested.""" + reg = _reg("https://ex.org/", "https://ex.org/sub/") + assert reg.owner("https://ex.org/sub/x").base_iri == "https://ex.org/sub/" + assert reg.owner("https://ex.org/top").base_iri == "https://ex.org/" + + +def test_resolve_prefix_gives_concept_id_and_source_url(): + reg = _reg("https://acme.example/knowledge/") + res = reg.resolve("https://acme.example/knowledge/tables/user-events") + assert res.via == "prefix" and not res.external + assert res.concept_id == "tables/user-events" + assert res.source_url == "https://acme.example/knowledge/tables/user-events.md" + + +def test_resolve_external_is_tolerated_not_error(): + res = _reg("https://acme.example/knowledge/").resolve("https://other.example/x") + assert res.external and res.entry is None and res.concept_id is None + + +def test_id_index_fallback_routes_diverging_ids(): + """An explicit id outside base_iri still routes via the id_index.""" + reg = Registry( + path=pathlib.Path("x"), + repos=[ + RepoEntry( + base_iri="https://ex.org/kb/", + source_base="https://ex.org/kb/", + id_index={"https://ex.org/legacy/wau": "metrics/wau"}, + ) + ], + ) + res = reg.resolve("https://ex.org/legacy/wau") + assert res.via == "id_index" and res.concept_id == "metrics/wau" + assert res.source_url == "https://ex.org/kb/metrics/wau.md" + + +# -- registration guards ---------------------------------------------------- +def test_add_rejects_vocab_namespace(): + reg = Registry(path=pathlib.Path("x")) + with pytest.raises(ValueError, match="vocabulary namespace"): + reg.add(RepoEntry(base_iri=VOCAB_NS)) + + +def test_add_rejects_duplicate_and_nesting(): + reg = Registry(path=pathlib.Path("x")) + reg.add(RepoEntry(base_iri="https://ex.org/a/")) + with pytest.raises(ValueError, match="already registered"): + reg.add(RepoEntry(base_iri="https://ex.org/a/")) + with pytest.raises(ValueError, match="nest"): + reg.add(RepoEntry(base_iri="https://ex.org/a/sub/")) + with pytest.raises(ValueError, match="nest"): + reg.add(RepoEntry(base_iri="https://ex.org/")) + + +def test_add_rejects_empty_base_iri(): + with pytest.raises(ValueError, match="no base_iri"): + Registry(path=pathlib.Path("x")).add(RepoEntry(base_iri="")) + + +# -- entry building from a bundle ------------------------------------------- +def test_entry_for_bundle_indexes_the_example(): + entry = entry_for_bundle(BUNDLE, source_base="https://x/") + assert entry.base_iri == "https://acme.example/knowledge/" + assert entry.void["triples"] == 86 + # Six concepts, each a distinct type; all route by prefix, so id_index empty. + assert sum(entry.void["class_partition"].values()) == 6 + assert entry.id_index == {} + + +def _write_bundle(root: pathlib.Path, base_iri: str, concept: str, explicit_id: str | None): + root.mkdir(parents=True, exist_ok=True) + (root / "index.md").write_text(f"---\nbase_iri: {base_iri}\ntitle: T\n---\n", encoding="utf-8") + fm = f"id: {explicit_id}\n" if explicit_id else "" + (root / f"{concept}.md").write_text( + f"---\n{fm}type: Concept\ntitle: Thing\n---\nbody\n", encoding="utf-8" + ) + + +def test_entry_for_bundle_harvests_diverging_explicit_id(tmp_path): + """A concept whose explicit id escapes base_iri lands in id_index.""" + root = tmp_path / "kb" + _write_bundle(root, "https://ex.org/kb/", "legacy", "https://ex.org/legacy/thing") + entry = entry_for_bundle(root) + assert entry.id_index == {"https://ex.org/legacy/thing": "legacy"} + + +# -- CLI round-trip --------------------------------------------------------- +def test_cli_init_add_list_resolve(tmp_path): + manifest = tmp_path / "lokf-registry.yaml" + assert runner.invoke(app, ["registry", "init", "-r", str(manifest)]).exit_code == 0 + assert manifest.exists() + + add = runner.invoke( + app, + ["registry", "add", str(BUNDLE), "-r", str(manifest), "--source-base", "https://x/"], + ) + assert add.exit_code == 0 and "86 triples" in add.output + + listed = runner.invoke(app, ["registry", "list", "-r", str(manifest)]) + assert "https://acme.example/knowledge/" in listed.output + + ok = runner.invoke( + app, + ["registry", "resolve", "https://acme.example/knowledge/glossary/active-user", + "-r", str(manifest)], + ) + assert ok.exit_code == 0 + assert "concept_id: glossary/active-user" in ok.output + assert "source_url: https://x/glossary/active-user.md" in ok.output + + external = runner.invoke( + app, ["registry", "resolve", "https://nope.example/x", "-r", str(manifest)] + ) + assert external.exit_code == 1 and "external" in external.output + + +def test_cli_add_rejects_second_copy_of_same_bundle(tmp_path): + """Registering the same base_iri twice is a clean error, not a traceback.""" + manifest = tmp_path / "lokf-registry.yaml" + runner.invoke(app, ["registry", "init", "-r", str(manifest)]) + runner.invoke(app, ["registry", "add", str(BUNDLE), "-r", str(manifest)]) + again = runner.invoke(app, ["registry", "add", str(BUNDLE), "-r", str(manifest)]) + assert again.exit_code == 1 and "already registered" in again.output + + +def test_cli_list_missing_manifest_is_clean_error(tmp_path): + r = runner.invoke(app, ["registry", "list", "-r", str(tmp_path / "nope.yaml")]) + assert r.exit_code == 1 and "not found" in r.output + + +def test_cli_init_creates_parent_dirs(tmp_path): + manifest = tmp_path / "nested" / "dir" / "lokf-registry.yaml" + assert runner.invoke(app, ["registry", "init", "-r", str(manifest)]).exit_code == 0 + assert manifest.exists() + + +# -- robustness / boundary regressions (from the Phase 1 review) ------------- +def test_add_requires_trailing_separator(): + reg = Registry(path=pathlib.Path("x")) + with pytest.raises(ValueError, match="end with"): + reg.add(RepoEntry(base_iri="https://ex.org/team")) + reg.add(RepoEntry(base_iri="https://ex.org/team/")) # slash ok + reg.add(RepoEntry(base_iri="https://ex.org/onto#")) # hash ok + + +def test_sibling_namespaces_register_and_route(): + """`…/team/` and `…/team-archive/` don't nest and never cross-route.""" + reg = Registry(path=pathlib.Path("x")) + reg.add(RepoEntry(base_iri="https://ex.org/team/")) + reg.add(RepoEntry(base_iri="https://ex.org/team-archive/")) # not a nesting conflict + assert reg.owner("https://ex.org/team-archive/r").base_iri == "https://ex.org/team-archive/" + assert reg.owner("https://ex.org/team/r").base_iri == "https://ex.org/team/" + + +def test_add_rejects_vocab_ancestor(): + reg = Registry(path=pathlib.Path("x")) + with pytest.raises(ValueError, match="vocabulary namespace"): + reg.add(RepoEntry(base_iri="https://w3id.org/")) + + +def test_resolve_bare_base_iri_has_no_document_url(): + """The namespace root is owned but is not a concept — no bogus .md URL.""" + res = _reg("https://ex.org/kb/").resolve("https://ex.org/kb/") + assert not res.external and res.concept_id == "" and res.source_url is None + + +def test_prefix_beats_stale_id_index(): + """A more-specific prefix owner wins over another repo's id_index entry.""" + reg = Registry( + path=pathlib.Path("x"), + repos=[ + RepoEntry(base_iri="https://ex.org/kb/", source_base="https://ex.org/kb/"), + RepoEntry(base_iri="https://other.org/", id_index={"https://ex.org/kb/x": "hijack"}), + ], + ) + res = reg.resolve("https://ex.org/kb/x") + assert res.via == "prefix" and res.concept_id == "x" + assert res.entry.base_iri == "https://ex.org/kb/" + + +def test_load_registry_tolerates_null_fields(tmp_path): + """A hand-edited manifest with null id_index/void/repos doesn't crash lookups.""" + manifest = tmp_path / "r.yaml" + manifest.write_text( + "lokf_registry_version: '0.1'\nrepos:\n" + " - base_iri: https://ex.org/kb/\n id_index:\n void:\n", + encoding="utf-8", + ) + reg = load_registry(manifest) + assert reg.repos[0].id_index == {} and reg.repos[0].void == {} + assert reg.owner("https://ex.org/kb/thing").base_iri == "https://ex.org/kb/" + assert reg.resolve("https://nope.org/x").external + + +def test_load_registry_tolerates_null_repos(tmp_path): + manifest = tmp_path / "r.yaml" + manifest.write_text("lokf_registry_version: '0.1'\nrepos:\n", encoding="utf-8") + assert load_registry(manifest).repos == [] + + +def test_load_registry_round_trips(tmp_path): + manifest = tmp_path / "lokf-registry.yaml" + reg = Registry(path=manifest, id="https://w3id.org/lokf/registry/x", title="X") + reg.add(entry_for_bundle(BUNDLE, source_base="https://x/")) + reg.save() + + reloaded = load_registry(manifest) + assert reloaded.id == "https://w3id.org/lokf/registry/x" + assert len(reloaded.repos) == 1 + assert reloaded.repos[0].base_iri == "https://acme.example/knowledge/" + # the on-disk form drops empty fields + raw = yaml.safe_load(manifest.read_text()) + assert "id_index" not in raw["repos"][0] diff --git a/web/scripts/gen_api_docs.py b/web/scripts/gen_api_docs.py index 70c8779..28df123 100644 --- a/web/scripts/gen_api_docs.py +++ b/web/scripts/gen_api_docs.py @@ -23,6 +23,7 @@ "lokf.server", "lokf.propose", "lokf.export", + "lokf.registry", "lokf.agentskills", "lokf.mcp_server", "lokf.build",