diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f4e7698..c860040 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 are SQLite files of page URLs, titles, and headings from synced projects, with no page bodies. `pull` needs an explicit HTTPS manifest URL. It verifies each shard's sha256 and enforces a total download size limit. Searches run offline. +- Share reproducible packs as recipes. `docpull registry export` writes a pack's + public sources, pinned page URLs, and per-page content hashes, with no fetched + content. `docpull add name@version` downloads a recipe from the registry, + checks its sha256, adds and syncs the sources, and reports which pages still + match. `docpull registry verify` repeats the check after a later sync. ### Removed diff --git a/docs/cli-recipes.md b/docs/cli-recipes.md index 742c822..5753872 100644 --- a/docs/cli-recipes.md +++ b/docs/cli-recipes.md @@ -113,6 +113,40 @@ Shards install to `DOCPULL_INDEX_DIR`, or `$XDG_DATA_HOME/docpull/index` when that is unset. Pass `--index-dir` to `index`, `search`, or `serve` to use another directory. +## Shared Pack Recipes + +A recipe lists a pack's public sources, its pinned page URLs, and the content hash +of every page the publisher synced. It contains no fetched content. When you add +a recipe, DocPull fetches every page locally and reports which pages still match +the publisher's hashes. + +```bash +# Consumer: add, sync, and verify a published pack +docpull add stripe-api@2026-10 +docpull registry verify stripe-api@2026-10 --json + +# Publisher: export a recipe from a synced project +docpull registry export --name stripe-api --version 2026-10 --description "Stripe API reference" +``` + +`docpull add name@version`: +1. Downloads the recipe through the registry's `index.json`, checking the + recipe's sha256 against its index entry. +2. Adds the recipe's sources with their pinned URLs. +3. Saves the recipe under `.docpull/registry/`. +4. Runs `docpull sync`. +5. Prints how many pages match, changed, went missing, or are new. + +Live pages change, so drift is reported, not treated as an error. Use +`name@latest` for the newest version. + +Recipes may only contain HTTPS crawl sources. Export refuses sources that use +`auth`, so private content never goes into a registry. + +The registry is a public GitHub repository, read over HTTPS. Publishing a recipe +means opening a pull request that adds the recipe file and its `index.json` +entry. Set `DOCPULL_REGISTRY_URL` to use another registry. + ## Local Extract API `docpull serve --compat` accepts the request shapes of common hosted extract diff --git a/docs/surface-contract.md b/docs/surface-contract.md index 6d0a988..af91c86 100644 --- a/docs/surface-contract.md +++ b/docs/surface-contract.md @@ -44,6 +44,7 @@ DocPull targets capability alignment, not 1:1 flag parity. MCP should not mirror | Project lifecycle | `docpull init`, `add`, `install`, `deps`, `sources`, `sync`, `diff`, `status`, `history`, `review`, `release context-pack`, `watch` with explicit crawl bounds | `docpull.project` helpers | Not exposed yet | Core-aligned | | Search the web | `docpull search QUERY --backend searxng\|marginalia\|index` with domain filters and `--include-content` | `search_web`, `SearchResponse`, `SearchHit` in `docpull.web_search` | Not exposed | CLI-first | | Offline discovery index | `docpull index build\|pull\|list` | `build_shard`, `pull`, `search_index` in `docpull.discovery_index` | Not exposed | CLI-first | +| Shared pack recipes | `docpull add name@version`, `docpull registry export\|verify` | `export_recipe`, `fetch_recipe`, `install_recipe`, `verify_recipe` in `docpull.registry` | Not exposed | CLI-first | | Search cached sources | SQLite/local search helpers where applicable | `search_sqlite_documents` | `grep_docs` | Adapted | | Query locked context with budgets, version filters, deduplication, and precise citations | `docpull query` | `query_context`, `ContextQueryError` | `context_query` | Core-aligned | | Read cached source by path/range | Filesystem responsibility | Filesystem responsibility | `read_doc` | MCP-specific | diff --git a/src/docpull/cli.py b/src/docpull/cli.py index e28e710..b56741c 100644 --- a/src/docpull/cli.py +++ b/src/docpull/cli.py @@ -1687,6 +1687,10 @@ def main(argv: list[str] | None = None) -> int: from .graph import run_graph_cli return run_graph_cli(raw_argv[1:]) + if raw_argv and raw_argv[0] == "registry": + from .registry import run_registry_cli + + return run_registry_cli(raw_argv[1:]) if raw_argv and raw_argv[0] == "index": from .discovery_index import run_index_cli diff --git a/src/docpull/project.py b/src/docpull/project.py index 67521b9..baa81cc 100644 --- a/src/docpull/project.py +++ b/src/docpull/project.py @@ -407,7 +407,11 @@ def run_init_cli(argv: list[str] | None = None) -> int: def run_add_cli(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="docpull add", description="Add a source to a DocPull project") - parser.add_argument("sources", nargs="+", help="HTTPS URL, bundled alias, or typed source spec") + parser.add_argument( + "sources", + nargs="+", + help="HTTPS URL, bundled alias, typed source spec, or registry pack name@version", + ) parser.add_argument("--name", help="Source name") parser.add_argument("--type", choices=SOURCE_TYPES, default="auto") parser.add_argument("--discover", action="store_true", help="Discover and store source URLs now") @@ -424,6 +428,16 @@ def run_add_cli(argv: list[str] | None = None) -> int: ) parser.add_argument("--auth-header-name", help="Header name for --auth header-env") args = parser.parse_args(argv) + if len(args.sources) == 1 and _infer_typed_source_type(args.sources[0]) is None: + from .registry import REF, run_add_recipe_cli + + if REF.fullmatch(args.sources[0].strip()): + if args.name or args.type != "auto" or args.discover or args.auth: + Console().print( + "[red]Project error:[/red] registry packs take no --name, --type, --discover, or --auth" + ) + return 1 + return run_add_recipe_cli(args.sources[0].strip()) console = Console() try: auth = _auth_ref_from_cli( diff --git a/src/docpull/registry.py b/src/docpull/registry.py new file mode 100644 index 0000000..b1c488e --- /dev/null +++ b/src/docpull/registry.py @@ -0,0 +1,326 @@ +"""Shared pack recipes: pinned public sources plus the content hashes a publisher saw. + +A recipe never carries fetched content. ``docpull add name@version`` downloads a +recipe from the registry, adds its sources, syncs them locally, and reports which +pages still match the published hashes. Pages change, so drift is reported, not +treated as an error. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import json +import os +import re +import sys +from pathlib import Path +from typing import Any +from urllib.parse import urljoin, urlsplit + +from pydantic import BaseModel, Field, ValidationError, field_validator + +from . import __version__ +from .discovery_index import _fetch_bytes +from .project import ( + CRAWL_SOURCE_TYPES, + ProjectError, + ProjectSource, + _read_context_lock, + _read_jsonl, + _run_dir_for_id, + find_project_root, + load_project_config, + project_paths, + save_project_config, + sync_project, +) + +SCHEMA_VERSION = 1 +REGISTRY_URL_ENV = "DOCPULL_REGISTRY_URL" +DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/raintree-technology/docpull-registry/main/" +MAX_INDEX_BYTES = 1_000_000 +# About 110 bytes per page, so 10 MB covers roughly 90,000 pages. +MAX_RECIPE_BYTES = 10_000_000 +PACK_NAME = r"[a-z0-9][a-z0-9-]{0,63}" +PACK_VERSION = r"[A-Za-z0-9._-]{1,32}" +REF = re.compile(rf"({PACK_NAME})@({PACK_VERSION})") + + +class RegistryError(ValueError): + """Invalid recipe, registry entry, or project state.""" + + +class RecipeSource(BaseModel): + """A public crawl source; typed specs and local paths are refused.""" + + name: str + url: str + type: str = "auto" + discovered_urls: list[str] = Field(default_factory=list) + + model_config = {"extra": "forbid"} + + @field_validator("url") + @classmethod + def _https(cls, value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: + raise ValueError("recipe source URLs must be absolute https URLs without credentials") + return value + + @field_validator("type") + @classmethod + def _crawl_type(cls, value: str) -> str: + if value not in CRAWL_SOURCE_TYPES: + raise ValueError(f"recipe sources must use a crawl type: {', '.join(CRAWL_SOURCE_TYPES)}") + return value + + def project_source(self) -> ProjectSource: + # Pinned URLs make the page set reproducible, so they replace live discovery. + return ProjectSource( + name=self.name, + url=self.url, + type=self.type, # type: ignore[arg-type] + discover=not self.discovered_urls, + discovered_urls=self.discovered_urls, + ) + + +class Recipe(BaseModel): + schema_version: int + name: str = Field(pattern=rf"^{PACK_NAME}$") + version: str = Field(pattern=rf"^{PACK_VERSION}$") + description: str = "" + docpull_version: str = "" + sources: list[RecipeSource] = Field(min_length=1) + expected: dict[str, dict[str, str]] + + model_config = {"extra": "forbid"} + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if value != SCHEMA_VERSION: + raise ValueError(f"unsupported recipe schema_version {value}") + return value + + def canonical(self) -> bytes: + return (json.dumps(self.model_dump(mode="json"), indent=2, sort_keys=True) + "\n").encode() + + +def _locked_pages(root: Path) -> dict[str, dict[str, str]]: + """Map each source to {url: content_hash} for the locked run.""" + paths = project_paths(root) + lock = _read_context_lock(paths.context_lock) + if not lock or not isinstance(lock.get("run_id"), str): + raise RegistryError(f"No locked run in {root}; run `docpull sync` first") + _, run_dir = _run_dir_for_id(paths, lock["run_id"]) + pages: dict[str, dict[str, str]] = {} + for record in _read_jsonl(run_dir / "documents.jsonl"): + metadata = record.get("metadata") + source = metadata.get("docpull_project_source") if isinstance(metadata, dict) else None + url, digest = record.get("url"), record.get("content_hash") + if isinstance(source, str) and isinstance(url, str) and isinstance(digest, str): + pages.setdefault(source, {})[url] = digest + return pages + + +def export_recipe(root: Path, name: str, version: str, description: str = "") -> Recipe: + """Build a recipe from a synced project's sources and locked page hashes.""" + try: + root = find_project_root(root) + config = load_project_config(root) + pages = _locked_pages(root) + except ProjectError as err: + raise RegistryError(str(err)) from err + private = [source.name for source in config.sources if source.auth] + if private: + raise RegistryError(f"Sources with auth cannot be shared: {', '.join(private)}") + try: + return Recipe( + schema_version=SCHEMA_VERSION, + name=name, + version=version, + description=description, + docpull_version=__version__, + sources=[ + RecipeSource(name=s.name, url=s.url, type=s.type, discovered_urls=s.discovered_urls) + for s in config.sources + ], + expected={ + source.name: dict(sorted(pages.get(source.name, {}).items())) for source in config.sources + }, + ) + except ValidationError as err: + raise RegistryError(f"Project cannot be exported as a recipe: {err}") from err + + +def registry_url() -> str: + url = os.environ.get(REGISTRY_URL_ENV) or DEFAULT_REGISTRY_URL + return url if url.endswith("/") else url + "/" + + +async def fetch_recipe(ref: str, base_url: str | None = None) -> Recipe: + """Resolve ``name@version`` through the registry index and verify the recipe's sha256.""" + match = REF.fullmatch(ref) + if not match: + raise RegistryError(f"Pack references look like name@version: {ref!r}") + name, version = match.groups() + base = base_url or registry_url() + try: + index = json.loads(await _fetch_bytes(urljoin(base, "index.json"), MAX_INDEX_BYTES)) + except json.JSONDecodeError as err: + raise RegistryError("Registry index is not valid JSON") from err + if not isinstance(index, dict) or index.get("schema_version") != SCHEMA_VERSION: + raise RegistryError(f"Unsupported registry index; expected schema_version {SCHEMA_VERSION}") + pack = (index.get("packs") or {}).get(name) + if not isinstance(pack, dict): + raise RegistryError(f"Registry has no pack named {name}") + if version == "latest": + version = str(pack.get("latest") or "") + entry = (pack.get("versions") or {}).get(version) + if not isinstance(entry, dict) or not isinstance(entry.get("path"), str): + raise RegistryError(f"Registry has no version {version or 'latest'} of {name}") + if not re.fullmatch(r"[0-9a-f]{64}", str(entry.get("sha256"))): + raise RegistryError(f"Registry entry for {name}@{version} has no valid sha256") + raw = await _fetch_bytes(urljoin(base, entry["path"]), MAX_RECIPE_BYTES) + if hashlib.sha256(raw).hexdigest() != entry["sha256"]: + raise RegistryError(f"sha256 mismatch for {name}@{version}; refusing the recipe") + try: + recipe = Recipe.model_validate_json(raw) + except ValidationError as err: + raise RegistryError(f"Invalid recipe {name}@{version}: {err}") from err + if (recipe.name, recipe.version) != (name, version): + raise RegistryError(f"Registry served {recipe.name}@{recipe.version} for {name}@{version}") + return recipe + + +def _recipe_path(root: Path, name: str, version: str) -> Path: + return project_paths(root).state / "registry" / f"{name}@{version}.json" + + +def install_recipe(recipe: Recipe, root: Path | None = None) -> dict[str, Any]: + """Add a recipe's sources, sync them, and return the verification report.""" + try: + project_root = find_project_root(root or Path.cwd()) + config = load_project_config(project_root) + sources = [source.project_source() for source in recipe.sources] + except (ProjectError, ValidationError) as err: + raise RegistryError(str(err)) from err + names = {source.name for source in config.sources} + urls = {source.url for source in config.sources} + clashes = [s.name for s in sources if s.name in names or s.url in urls] + if clashes: + raise RegistryError(f"Project already has sources named or located like: {', '.join(clashes)}") + save_project_config(project_root, config.model_copy(update={"sources": [*config.sources, *sources]})) + target = _recipe_path(project_root, recipe.name, recipe.version) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(recipe.canonical()) + try: + sync_project(root=project_root) + except ProjectError as err: + raise RegistryError(f"Sources added, but sync failed: {err}") from err + return verify_recipe(project_root, f"{recipe.name}@{recipe.version}") + + +def verify_recipe(root: Path, ref: str) -> dict[str, Any]: + """Compare the locked run's pages for each recipe source with the published hashes.""" + match = REF.fullmatch(ref) + if not match: + raise RegistryError(f"Pack references look like name@version: {ref!r}") + try: + project_root = find_project_root(root) + path = _recipe_path(project_root, *match.groups()) + recipe = Recipe.model_validate_json(path.read_bytes()) + pages = _locked_pages(project_root) + except FileNotFoundError as err: + raise RegistryError(f"{ref} is not installed in this project; run `docpull add {ref}`") from err + except (ProjectError, ValidationError) as err: + raise RegistryError(str(err)) from err + report: dict[str, Any] = {"pack": ref, "sources": {}} + totals = {"expected": 0, "matched": 0, "changed": 0, "missing": 0, "new": 0} + for name, expected in recipe.expected.items(): + got = pages.get(name, {}) + counts = { + "expected": len(expected), + "matched": sum(1 for url, digest in expected.items() if got.get(url) == digest), + "changed": sum(1 for url, digest in expected.items() if url in got and got[url] != digest), + "missing": sum(1 for url in expected if url not in got), + "new": sum(1 for url in got if url not in expected), + } + report["sources"][name] = counts + for key, value in counts.items(): + totals[key] += value + report.update(totals) + report["match_ratio"] = round(totals["matched"] / totals["expected"], 4) if totals["expected"] else 1.0 + return report + + +def _print_report(report: dict[str, Any]) -> None: + print( + f"{report['pack']}: {report['matched']}/{report['expected']} pages match the published hashes " + f"({report['changed']} changed, {report['missing']} missing, {report['new']} new)" + ) + + +def run_add_recipe_cli(ref: str) -> int: + try: + report = install_recipe(asyncio.run(fetch_recipe(ref))) + except (RegistryError, ValueError) as err: + print(f"docpull add: {err}", file=sys.stderr) + return 1 + _print_report(report) + return 0 + + +def run_registry_cli(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="docpull registry", description=__doc__.splitlines()[0]) + subparsers = parser.add_subparsers(dest="command", required=True) + export = subparsers.add_parser("export", help="Write a recipe from this synced project") + export.add_argument("--name", required=True) + export.add_argument("--version", required=True) + export.add_argument("--description", default="") + export.add_argument("--out", type=Path, help="Recipe file (default: @.json)") + verify = subparsers.add_parser("verify", help="Compare the latest sync with an installed recipe") + verify.add_argument("ref", help="name@version") + verify.add_argument("--json", action="store_true", dest="json_output") + args = parser.parse_args(argv) + try: + if args.command == "export": + recipe = export_recipe(Path.cwd(), args.name, args.version, args.description) + out = args.out or Path(f"{args.name}@{args.version}.json") + data = recipe.canonical() + out.write_bytes(data) + pages = sum(len(pages) for pages in recipe.expected.values()) + print(f"Wrote {out}: {len(recipe.sources)} source(s), {pages} pages") + print(f"sha256 {hashlib.sha256(data).hexdigest()}") + unpinned = [s.name for s in recipe.sources if not s.discovered_urls] + if unpinned: + print( + f"warning: {', '.join(unpinned)} has no pinned URLs; run `docpull add URL --discover` " + "or sync with discovery so consumers fetch the same pages", + file=sys.stderr, + ) + else: + report = verify_recipe(Path.cwd(), args.ref) + if args.json_output: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + _print_report(report) + except RegistryError as err: + print(f"docpull registry: {err}", file=sys.stderr) + return 1 + return 0 + + +__all__ = [ + "Recipe", + "RegistryError", + "export_recipe", + "fetch_recipe", + "install_recipe", + "run_registry_cli", + "verify_recipe", +] diff --git a/src/docpull/surface.py b/src/docpull/surface.py index 51db67d..c09bc3b 100644 --- a/src/docpull/surface.py +++ b/src/docpull/surface.py @@ -37,6 +37,7 @@ class CliCommand: CliCommand("render", "Render one public URL or check optional browser backends"), CliCommand("search", "Search the web through self-hosted SearXNG, Marginalia, or a discovery index"), CliCommand("index", "Build, download, and list offline discovery index shards"), + CliCommand("registry", "Export shareable pack recipes and verify installed ones"), CliCommand("openapi-pack", "Build a local v3 pack from an OpenAPI spec"), CliCommand("feed-pack", "Build a local v3 pack from an RSS, Atom, JSON Feed, or feed page"), CliCommand("paper-pack", "Build a local v3 pack from papers and scholarly metadata"), diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..8f25c1f --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,185 @@ +"""Pack recipes: export from a synced project, verified fetch, add, and drift reports.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path +from typing import Any + +import pytest + +from docpull import discovery_index, registry +from docpull import project as project_module +from docpull.cli import main +from docpull.project import init_project, load_project_config, sync_project +from docpull.registry import RegistryError, export_recipe, fetch_recipe, install_recipe, verify_recipe + +PAGES = { + "https://docs.alpha.example/retry": "# Retry\n\nWait 2 seconds.", + "https://docs.alpha.example/auth": "# Auth\n\nUse a token.", +} +BASE = "https://registry.example/" + + +def _fake_sync(monkeypatch: pytest.MonkeyPatch, pages: dict[str, str]) -> None: + async def fetch(**kwargs: Any) -> dict[str, Any]: + source = kwargs["source"] + records = [ + { + "document_id": f"doc_{i}", + "url": url, + "title": url, + "content": content, + "content_hash": hashlib.sha256(content.encode()).hexdigest(), + "metadata": {"docpull_project_source": source.name}, + } + for i, (url, content) in enumerate(pages.items()) + if url.startswith(source.url.rstrip("/")) + ] + return {"records": records, "errors": [], "skips": [], "stats": {}} + + monkeypatch.setattr(project_module, "_sync_source", fetch) + + +def _publisher(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "publisher" + init_project(name="publisher", source="https://docs.alpha.example/", root=root) + config = load_project_config(root) + pinned = config.sources[0].model_copy(update={"discovered_urls": sorted(PAGES)}) + project_module.save_project_config(root, config.model_copy(update={"sources": [pinned]})) + _fake_sync(monkeypatch, PAGES) + sync_project(root=root, run_id="publisher_run") + return root + + +def _serve(monkeypatch: pytest.MonkeyPatch, recipe_bytes: bytes, sha256: str | None = None) -> None: + index = { + "schema_version": 1, + "packs": { + "alpha-docs": { + "latest": "2026-10", + "versions": { + "2026-10": { + "path": "packs/alpha-docs/2026-10.json", + "sha256": sha256 or hashlib.sha256(recipe_bytes).hexdigest(), + } + }, + } + }, + } + files = { + BASE + "index.json": json.dumps(index).encode(), + BASE + "packs/alpha-docs/2026-10.json": recipe_bytes, + } + + async def fake_fetch(url: str, limit: int) -> bytes: + return files[url] + + monkeypatch.setattr(registry, "_fetch_bytes", fake_fetch) + monkeypatch.setenv(registry.REGISTRY_URL_ENV, BASE) + + +def test_export_records_pinned_sources_and_page_hashes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + recipe = export_recipe(_publisher(tmp_path, monkeypatch), "alpha-docs", "2026-10", "Alpha docs") + [source] = recipe.sources + assert source.discovered_urls == sorted(PAGES) + assert recipe.expected[source.name] == { + url: hashlib.sha256(content.encode()).hexdigest() for url, content in sorted(PAGES.items()) + } + assert b"Wait 2 seconds" not in recipe.canonical() + with pytest.raises(RegistryError, match="name"): + export_recipe(tmp_path / "publisher", "Bad Name", "1") + + +def test_export_refuses_private_sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = _publisher(tmp_path, monkeypatch) + config = load_project_config(root) + private = config.sources[0].model_copy( + update={"auth": project_module.ProjectSourceAuth(type="bearer_env", env="TOKEN")} + ) + project_module.save_project_config(root, config.model_copy(update={"sources": [private]})) + with pytest.raises(RegistryError, match="auth"): + export_recipe(root, "alpha-docs", "2026-10") + + +def test_fetch_verifies_sha256_and_resolves_latest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + data = export_recipe(_publisher(tmp_path, monkeypatch), "alpha-docs", "2026-10").canonical() + _serve(monkeypatch, data) + assert asyncio.run(fetch_recipe("alpha-docs@latest")).version == "2026-10" + with pytest.raises(RegistryError, match="no version 2027"): + asyncio.run(fetch_recipe("alpha-docs@2027")) + with pytest.raises(RegistryError, match="no pack named"): + asyncio.run(fetch_recipe("beta@1")) + _serve(monkeypatch, data, sha256="0" * 64) + with pytest.raises(RegistryError, match="sha256 mismatch"): + asyncio.run(fetch_recipe("alpha-docs@2026-10")) + + +@pytest.mark.parametrize( + "source", + [ + {"name": "local", "url": "./secrets.sqlite", "type": "auto"}, + {"name": "typed", "url": "https://github.com/a/b", "type": "repo"}, + {"name": "plain", "url": "http://docs.example/", "type": "auto"}, + ], +) +def test_registry_recipes_only_allow_public_crawl_sources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, source: dict[str, str] +) -> None: + payload = { + "schema_version": 1, + "name": "alpha-docs", + "version": "2026-10", + "sources": [source], + "expected": {}, + } + _serve(monkeypatch, json.dumps(payload).encode()) + with pytest.raises(RegistryError, match="Invalid recipe"): + asyncio.run(fetch_recipe("alpha-docs@2026-10")) + + +def test_add_installs_syncs_and_reports_drift( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + data = export_recipe(_publisher(tmp_path, monkeypatch), "alpha-docs", "2026-10").canonical() + _serve(monkeypatch, data) + consumer = tmp_path / "consumer" + init_project(name="consumer", root=consumer) + monkeypatch.chdir(consumer) + changed = {**PAGES, "https://docs.alpha.example/auth": "# Auth\n\nUse an API key."} + _fake_sync(monkeypatch, changed) + + assert main(["add", "alpha-docs@2026-10"]) == 0 + assert "1/2 pages match" in capsys.readouterr().out + [source] = load_project_config(consumer).sources + assert source.discover is False and source.discovered_urls == sorted(PAGES) + report = verify_recipe(consumer, "alpha-docs@2026-10") + assert (report["matched"], report["changed"], report["missing"], report["new"]) == (1, 1, 0, 0) + assert report["match_ratio"] == 0.5 + + with pytest.raises(RegistryError, match="already has sources"): + install_recipe(asyncio.run(fetch_recipe("alpha-docs@2026-10")), consumer) + assert main(["registry", "verify", "alpha-docs@2026-10", "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["matched"] == 1 + + +def test_add_still_resolves_aliases_urls_and_typed_specs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def no_registry(*args: Any, **kwargs: Any) -> None: + pytest.fail("non-registry source reached the registry") + + monkeypatch.setattr(discovery_index, "_fetch_bytes", no_registry) + monkeypatch.setattr(registry, "_fetch_bytes", no_registry) + root = tmp_path / "plain" + init_project(name="plain", root=root) + monkeypatch.chdir(root) + assert main(["add", "react"]) == 0 + assert main(["add", "https://docs.gamma.example/"]) == 0 + assert main(["add", "npm:react@18.3.1"]) == 0 + assert main(["add", "owner/repo@v1"]) == 0 + assert len(load_project_config(root).sources) == 4