From 8663489d7b587e86bb04c0140b033eaf7e792de4 Mon Sep 17 00:00:00 2001 From: Lukas Gold Date: Fri, 28 Aug 2026 17:52:59 +0200 Subject: [PATCH] feat(validation): add oold meta vendor command - reads the file set a release tag actually ships instead of a hand-run bash loop, so the wrong-file-set trap cannot recur - extracts with git cat-file blob and writes bytes verbatim, closing the CRLF-on-Windows trap by construction - records tag, commit, commit date, id_base and sha256 per file in meta/index.json, refusing to overwrite a tracked version without --force - refreshes tests/data/oold/ from the same tag and sets fixtures.tag in one call, so the two can no longer drift apart - points docs/maintaining-meta-schemas.md at the command in place of the bash procedure it replaces --- docs/maintaining-meta-schemas.md | 96 +++---- src/oold/validation/cli.py | 28 ++ src/oold/validation/meta_vendor.py | 219 +++++++++++++++ tests/test_validation/test_cli.py | 50 ++++ tests/test_validation/test_meta_vendor.py | 317 ++++++++++++++++++++++ 5 files changed, 652 insertions(+), 58 deletions(-) create mode 100644 src/oold/validation/meta_vendor.py create mode 100644 tests/test_validation/test_meta_vendor.py diff --git a/docs/maintaining-meta-schemas.md b/docs/maintaining-meta-schemas.md index 4c2f69c..8a5f93f 100644 --- a/docs/maintaining-meta-schemas.md +++ b/docs/maintaining-meta-schemas.md @@ -48,7 +48,7 @@ meta-schema file, so they are not ordinary source files: - they must be LF. A CRLF copy hashes differently, which passes on Windows and fails on Linux. This has happened; `test_the_vendored_files_are_stored_with_unix_line_endings` now guards it. -That is why every extraction command below uses `git cat-file blob`, never `git show`: `show` +That is why `oold meta vendor` (below) extracts with `git cat-file blob`, never `git show`: `show` applies the checkout's end-of-line conversion, so on Windows it writes CRLF, which changes every digest and fails only once it reaches Linux CI. @@ -72,55 +72,43 @@ skip - drift here is exactly what this is meant to catch. When oold-schema cuts a release, from a checkout of it: -### 1. Vendor the meta-schema files - ```bash -V=1.0.0 -mkdir -p src/oold/validation/meta/$V -for f in oold-meta-schema oold-meta-schema-base oold-pattern-lint.schema oold-ui-meta-schema oold-rules oold-rules.schema; do - git -C ../oold-schema cat-file blob v$V:meta/$f.json > src/oold/validation/meta/$V/$f.json -done -sha256sum src/oold/validation/meta/$V/*.json -git -C ../oold-schema rev-parse v$V -git -C ../oold-schema log -1 --format=%cI v$V +uv run oold meta vendor 1.0.0 --from ../oold-schema ``` -**Check what the release actually ships before running the loop.** The set has grown twice. -`oold-rules.json`, the catalogue of normative statements, and `oold-rules.schema.json`, which -describes it, arrived in 1.0.0-rc.1; `oold-meta-schema-base.json` arrived in 1.0.0-rc.2, when the -dialect split into a wrapper and the body it `$ref`s. Drop from the loop whatever a given version -predates, and name the set in that version's own `files` entry when it differs from the shared -default. Listing only the three meta-schemas here once cost a vendoring the catalogue entirely, -which is silent: findings simply stop citing rules and every `rule.*` check skips as though the -version had stated nothing. Omitting the base is not silent, but it fails obscurely, as an -unresolvable `$ref` rather than a missing file. - -Extract from the **tag**, not from the working tree. The two diverge: at the time 0.7.0 was added, -`main` had already changed all three files, including the canonical `$id` domain. - -The catalogue is the one exception, and only while it is unreleased. `1.0.0-rc.1`'s copy comes from -an oold-schema branch because no tag carries one yet; when that happens, record the branch and -commit under `rules_source` so the provenance is still exact. Never do this for a meta-schema. - -Then add an entry to `index.json` with the tag, commit, commit date, the `$id` base in use for that -release (see "Why `id_base` is recorded and not assumed" below), and the checksums. - -### 2. Refresh the fixture slice - -Refresh `tests/data/oold/` from the **same tag**, so fixtures and meta-schemas always come from -one release, then record that tag as `fixtures.tag` in `index.json`: - -```bash -V=$(uv run python -c "from oold.validation.meta_store import latest_version; print(latest_version())") -DEST=tests/data/oold -for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/ | grep '\.json$'); do - git -C ../oold-schema cat-file blob "v$V:$f" > "$DEST/$(basename $f)" -done -for f in $(git -C ../oold-schema ls-tree --name-only v$V examples/compliance/); do - git -C ../oold-schema cat-file blob "v$V:$f" > "$DEST/compliance/$(basename $f)" -done -make validate -``` +This resolves the tag `v1.0.0` in that checkout and does, in one call, what used to be two +hand-run procedures: + +- reads what `meta/` actually contains **at that tag**, rather than a fixed list someone has to + remember to edit. The set has grown twice already - `oold-rules.json`, the catalogue of + normative statements, and `oold-rules.schema.json`, which describes it, arrived in 1.0.0-rc.1; + `oold-meta-schema-base.json` arrived in 1.0.0-rc.2, when the dialect split into a wrapper and the + body it `$ref`s - and a version that predates one of these is simply not made to load a file it + does not ship. Listing only the three meta-schemas here once cost a vendoring the catalogue + entirely, which is silent: findings stop citing rules and every `rule.*` check skips as though + the version had stated nothing. Omitting the base is not silent, but it fails obscurely, as an + unresolvable `$ref` rather than a missing file; +- writes every file with `git cat-file blob`, never `git show`, so nothing here can pick up the + checkout's line-ending conversion (see "Byte-exactness" above); +- records the tag, commit, commit date, the `$id` base declared in the vendored wrapper (see "Why + `id_base` is recorded and not assumed" below), and a sha256 of each file, in `index.json`; +- refreshes `tests/data/oold/` from the **same tag** and sets `fixtures.tag` to it, so fixtures and + meta-schemas can never drift apart the way a separate, easy-to-skip second step once let them. + Keeping the two in step is not cosmetic: a compliance fixture asserts the lint rules of the + release that introduced them, so a newer fixture set combined with an older meta-schema fails in + ways that say nothing about the code. `test_the_fixture_slice_records_the_release_it_came_from` + is what would have caught the earlier miss - a README claiming a release the fixture slice had + already moved past. + +It refuses to overwrite a version already tracked; pass `--force` to replace one deliberately. +Optional narrative fields on an entry - `notes`, `prerelease` - are not generated and can be added +by hand afterward. + +The rule catalogue is the one thing this command does not vendor from an unreleased source. +`1.0.0-rc.1`'s copy comes from an oold-schema branch because no tag carried one yet; when that +happens, add the catalogue and a `rules_source` entry by hand, recording the branch and commit so +the provenance stays exact. Never do this for a meta-schema - a document that has not reached a tag +has not been released. Then confirm both refreshes still pass: @@ -129,21 +117,13 @@ uv run oold validate tests/data/oold --offline --meta all make validate && uv run pytest tests/test_validation -q ``` -Keeping the two in step is not cosmetic. A compliance fixture asserts the lint rules of the release -that introduced them, so a newer fixture set combined with an older meta-schema fails in ways that -say nothing about the code. `fixtures.tag` is what makes the pairing checkable rather than a habit: -`test_the_fixture_slice_records_the_release_it_came_from` compares it against the newest tracked -version, because this step has been skipped before and prose describing the tag in a README did -not notice - the sentence kept naming an old release after a vendoring had already moved the -fixture files on. The tag is recorded only in `index.json` now, for exactly that reason. - ## The fixture slice Only the top level and `compliance/` are the upstream snapshot; both come from `examples/` at the recorded tag. `broken/`, `remote_context/` and `x_oold_context/` are written here by hand, exist -in no oold-schema release, and the refresh loop above never touches them. Upstream's `examples/` -also has a `spec/` subdirectory, which is deliberately outside the slice - the loops above do not -descend into it. +in no oold-schema release, and `oold meta vendor` never touches them. Upstream's `examples/` also +has a `spec/` subdirectory, which is deliberately outside the slice - the command does not descend +into it. Upstream's current `main` is covered instead by the opt-in parity tests (`tests/test_validation/test_parity_live.py`), which validate against `--meta remote`. diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index 4e2cf03..8f886ef 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -19,6 +19,7 @@ import click from .meta_store import MetaSchemaError, describe_store, fetch_remote, load_index, resolve_selection +from .meta_vendor import vendor_version from .pipeline import Options, run_compliance, validate_directory, validate_instance, validate_schema from .report import FAIL, OK, SKIP, WARN, Report @@ -319,6 +320,33 @@ def meta_fetch(force: bool) -> None: click.echo(f"fetched into {target}") +@meta_group.command("vendor") +@click.argument("version") +@click.option( + "--from", + "source", + required=True, + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="A checkout of oold-schema to vendor the release from.", +) +@click.option("--force", is_flag=True, help="Overwrite an already-tracked version.") +def meta_vendor(version: str, source: Path, force: bool) -> None: + """Vendor one released meta-schema version, and refresh the fixture slice from the same tag. + + Reads the file set the tag actually ships rather than a fixed list, writes every file + byte-for-byte with no line-ending conversion, and records its sha256, tag, commit and commit + date in meta/index.json. See docs/maintaining-meta-schemas.md for what this replaces. + """ + try: + result = vendor_version(version, source, force=force) + except MetaSchemaError as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"vendored {result.version} from {result.tag} ({result.commit})") + for name in result.files: + click.echo(f" {name}") + click.echo(f"fixtures refreshed from {result.tag}: {len(result.fixture_files)} files") + + @click.group("rules") def rules_group() -> None: """Look up the normative rules the validator cites.""" diff --git a/src/oold/validation/meta_vendor.py b/src/oold/validation/meta_vendor.py new file mode 100644 index 0000000..a2edb67 --- /dev/null +++ b/src/oold/validation/meta_vendor.py @@ -0,0 +1,219 @@ +"""Vendor a released meta-schema version from an oold-schema checkout. + +``meta_store.py`` only ever reads the tracked tree; this module is the one place that writes it, +and only in response to an explicit ``oold meta vendor`` invocation - never at validation time. + +It exists to remove, by construction, three mistakes the hand-run procedure in +``docs/maintaining-meta-schemas.md`` could only warn about: + +- ``git show`` instead of ``git cat-file blob``, which applies the checkout's autocrlf filter and + writes CRLF on Windows, changing every recorded digest and failing only once it reaches Linux + CI. Every file below is read with ``git cat-file blob`` and written with :meth:`Path.write_bytes`, + so nothing in the path ever re-encodes a line ending. +- copying the wrong file set. The set has grown twice already (the rule catalogue in 1.0.0-rc.1, + the meta-schema base in 1.0.0-rc.2), so this reads what the tag's ``meta/`` directory actually + contains rather than a fixed list a human has to remember to edit. +- updating the vendored files but not ``fixtures.tag``. The two writes happen in one call, from + one resolved tag, so they cannot drift apart the way a two-step manual procedure did. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from . import meta_store + +#: Meta-schema documents, in the order they get committed with. Not every version ships every one +#: - the base split off in 1.0.0-rc.2 - so which of these a version records is decided by what its +#: tag actually contains, never assumed. +_CANDIDATE_DOCUMENTS = ( + meta_store.META_SCHEMA_FILE, + meta_store.META_SCHEMA_BASE_FILE, + meta_store.PATTERN_LINT_FILE, + meta_store.UI_META_SCHEMA_FILE, +) + +#: The rule catalogue and its schema, optional and never part of a version's ``files`` override: +#: unlike the documents above they are not loaded through the registry, so meta_files() has no +#: reason to know about them. +_CANDIDATE_RULE_FILES = (meta_store.RULES_FILE, meta_store.RULES_SCHEMA_FILE) + + +@dataclass +class VendorResult: + """What one ``vendor_version`` call did, for the CLI to report.""" + + version: str + tag: str + commit: str + committed: str + files: list[str] + fixture_files: list[str] + + +def _fixtures_dir() -> Path: + """``tests/data/oold/`` in this checkout, located from this file rather than the CWD. + + A separate lookup from :func:`meta_store.meta_dir`, because this directory ships only in a + source checkout, never in the installed package - the same reason the fixture refresh half of + this module is a repository-maintenance operation rather than something ``oold`` needs at + runtime. + """ + # src/oold/validation/meta_vendor.py -> repository root is three parents up. + return Path(__file__).resolve().parents[3] / "tests" / "data" / "oold" + + +def _git(source: Path, *args: str) -> str: + """Run a git command in ``source`` and return its stdout as text, stripped.""" + # S603/S607: "git" is not resolved from an untrusted PATH here - it is the same interpreter + # this whole toolchain already depends on, and args are fixed revision/path literals, never + # user-supplied shell text. + result = subprocess.run(["git", "-C", str(source), *args], capture_output=True) # noqa: S603, S607 + if result.returncode != 0: + raise meta_store.MetaSchemaError( + f"git {' '.join(args)} failed in {source}: {result.stderr.decode(errors='replace').strip()}" + ) + return result.stdout.decode(errors="replace").strip() + + +def _git_blob(source: Path, rev: str) -> bytes: + """The verbatim bytes of one blob, bypassing any working-tree line-ending conversion. + + ``git cat-file blob``, never ``git show``: ``show`` applies the checkout's autocrlf filter, + which on Windows turns LF into CRLF and changes every digest computed from the result. The + bytes are captured directly from the subprocess pipe and written with :meth:`Path.write_bytes` + - no text-mode decoding happens anywhere between the object database and the file on disk. + """ + # S603/S607: see _git above. + result = subprocess.run(["git", "-C", str(source), "cat-file", "blob", rev], capture_output=True) # noqa: S603, S607 + if result.returncode != 0: + raise meta_store.MetaSchemaError( + f"git cat-file blob {rev} failed in {source}: {result.stderr.decode(errors='replace').strip()}" + ) + return result.stdout + + +def _ls_tree(source: Path, rev: str, directory: str) -> list[str]: + """File names (not full paths) one level under ``directory`` at ``rev``.""" + listing = _git(source, "ls-tree", "--name-only", rev, directory) + return [Path(line).name for line in listing.splitlines() if line] + + +def _id_base(document: dict[str, Any], filename: str) -> str: + """The ``$id`` base this release publishes under, derived from its own wrapper document. + + Recorded per version rather than assumed, because the canonical domain has already moved once + (see :func:`meta_store._build_registry`); reading it from the file itself means a future move + needs only a new vendored entry, never a code change here. + """ + declared = document.get("$id") + if not isinstance(declared, str) or not declared.endswith(filename): + raise meta_store.MetaSchemaError(f"{filename}'s $id does not end with its own file name: {declared!r}") + return declared[: -len(filename)] + + +def _refresh_fixtures(source: Path, tag: str) -> list[str]: + """Copy the fixture slice from ``examples/`` at ``tag``, mirroring the documented procedure. + + Only the top level and ``compliance/`` are upstream; ``broken/``, ``remote_context/`` and + ``x_oold_context/`` are written here by hand and this never touches them, because it never + looks anywhere but those two source directories. + """ + dest = _fixtures_dir() + written: list[str] = [] + + for name in _ls_tree(source, tag, "examples/"): + if not name.endswith(".json"): + continue + (dest / name).write_bytes(_git_blob(source, f"{tag}:examples/{name}")) + written.append(name) + + dest_compliance = dest / "compliance" + for name in _ls_tree(source, tag, "examples/compliance/"): + (dest_compliance / name).write_bytes(_git_blob(source, f"{tag}:examples/compliance/{name}")) + written.append(f"compliance/{name}") + + return written + + +def vendor_version(version: str, source: Path, *, force: bool = False) -> VendorResult: + """Vendor one released meta-schema version from ``source``, and refresh the fixture slice. + + ``source`` is a checkout of oold-schema; ``version`` names the release, whose tag is assumed + to be ``v`` - the convention every tracked entry in ``index.json`` already follows. + Refuses to overwrite a version already present in the tracked folder or the index unless + ``force`` is set, so a typo'd version cannot silently discard a curated entry. + """ + index = meta_store.load_index() + target_dir = meta_store.meta_dir() / version + already_tracked = version in index.get("versions", {}) or target_dir.is_dir() + if already_tracked and not force: + raise meta_store.MetaSchemaError( + f"meta-schema version {version!r} is already tracked; pass --force to overwrite it" + ) + + tag = f"v{version}" + commit = _git(source, "rev-parse", f"{tag}^{{commit}}") + committed = _git(source, "log", "-1", "--format=%cI", commit) + + present = set(_ls_tree(source, tag, "meta/")) + documents = [name for name in _CANDIDATE_DOCUMENTS if name in present] + if meta_store.META_SCHEMA_FILE not in documents: + raise meta_store.MetaSchemaError(f"{tag} carries no {meta_store.META_SCHEMA_FILE} under meta/ in {source}") + rule_files = [name for name in _CANDIDATE_RULE_FILES if name in present] + all_files = documents + rule_files + + target_dir.mkdir(parents=True, exist_ok=True) + sha256: dict[str, str] = {} + for name in all_files: + content = _git_blob(source, f"{tag}:meta/{name}") + (target_dir / name).write_bytes(content) + sha256[name] = hashlib.sha256(content).hexdigest() + + wrapper = json.loads((target_dir / meta_store.META_SCHEMA_FILE).read_bytes()) + id_base = _id_base(wrapper, meta_store.META_SCHEMA_FILE) + + entry: dict[str, Any] = { + "tag": tag, + "commit": commit, + "committed": committed, + "added": datetime.now(timezone.utc).date().isoformat(), + "id_base": id_base, + "sha256": sha256, + } + # Mirrors the fallback in meta_store.meta_files(): the three files every version predating the + # 1.0.0-rc.2 split ships, and the default this version's own set is compared against below. + default_documents = index.get("files") or [ + meta_store.META_SCHEMA_FILE, + meta_store.PATTERN_LINT_FILE, + meta_store.UI_META_SCHEMA_FILE, + ] + if documents != default_documents: + entry["files"] = documents + + index.setdefault("versions", {})[version] = entry + + fixture_files = _refresh_fixtures(source, tag) + index.setdefault("fixtures", {})["tag"] = tag + + index_path = meta_store.meta_dir() / "index.json" + # newline="\n": write_text defaults to translating "\n" to os.linesep, which on Windows would + # author this file with CRLF - the same trap the vendored files avoid by being written with + # write_bytes, just reappearing in the one file this command writes as text. + index_path.write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8", newline="\n") + meta_store.load_index.cache_clear() + + return VendorResult( + version=version, + tag=tag, + commit=commit, + committed=committed, + files=all_files, + fixture_files=fixture_files, + ) diff --git a/tests/test_validation/test_cli.py b/tests/test_validation/test_cli.py index 367e19b..78f6e2e 100644 --- a/tests/test_validation/test_cli.py +++ b/tests/test_validation/test_cli.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +import subprocess import pytest from click.testing import CliRunner +from oold.validation import meta_store, meta_vendor from oold.validation.cli import main @@ -110,6 +112,54 @@ def test_meta_list_json(run): assert payload["versions"] +def test_meta_vendor_requires_from(run): + result = run("meta", "vendor", "1.2.3") + assert result.exit_code != 0 + assert "--from" in result.output + + +def _run_git(cwd, *args: str) -> None: + # S603/S607: a fixed executable ("git") with literal, test-authored arguments, never + # untrusted input. + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) # noqa: S603, S607 + + +def test_meta_vendor_reaches_the_store_and_reports_what_it_did(run, tmp_path, monkeypatch): + """A wiring test: the CLI option maps through to `vendor_version` and its result is echoed.""" + source = tmp_path / "source" + (source / "meta").mkdir(parents=True) + _run_git(source, "init", "-q") + _run_git(source, "config", "user.email", "test@example.com") + _run_git(source, "config", "user.name", "Test") + documents = (meta_store.META_SCHEMA_FILE, meta_store.PATTERN_LINT_FILE, meta_store.UI_META_SCHEMA_FILE) + for name in documents: + (source / "meta" / name).write_text( + json.dumps({"$id": f"https://example.org/1.2.3/meta/{name}"}), encoding="utf-8" + ) + _run_git(source, "add", "-A") + _run_git(source, "commit", "-q", "-m", "release 1.2.3") + _run_git(source, "tag", "v1.2.3") + + meta_root = tmp_path / "tracked-meta" + meta_root.mkdir() + (meta_root / "index.json").write_text( + json.dumps({"files": list(documents), "versions": {}, "fixtures": {"tag": "v0.0.0"}}), encoding="utf-8" + ) + monkeypatch.setattr(meta_store, "meta_dir", lambda: meta_root) + meta_store.load_index.cache_clear() + fixtures_root = tmp_path / "fixtures" + (fixtures_root / "compliance").mkdir(parents=True) + monkeypatch.setattr(meta_vendor, "_fixtures_dir", lambda: fixtures_root) + + try: + result = run("meta", "vendor", "1.2.3", "--from", str(source)) + assert result.exit_code == 0, result.output + assert "vendored 1.2.3 from v1.2.3" in result.output + assert (meta_root / "1.2.3" / meta_store.META_SCHEMA_FILE).is_file() + finally: + meta_store.load_index.cache_clear() + + def test_unknown_meta_version_reports_cleanly(run, data_dir): result = run("validate", str(data_dir), "--meta", "9.9.9", "--offline") assert result.exit_code == 1 diff --git a/tests/test_validation/test_meta_vendor.py b/tests/test_validation/test_meta_vendor.py new file mode 100644 index 0000000..523658e --- /dev/null +++ b/tests/test_validation/test_meta_vendor.py @@ -0,0 +1,317 @@ +"""`oold meta vendor`: the command that writes the tracked meta-schema store. + +Everything else in this package only reads ``meta/``; this is the one path that writes it, so +these tests build a small real git repository per test rather than faking git's behaviour, since +the property under test - byte-exact extraction, immune to the working tree's line endings - is +about what git plumbing actually returns. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +from pathlib import Path + +import pytest + +from oold.validation import meta_store, meta_vendor +from oold.validation.meta_store import MetaSchemaError + +# The three files every version predating the 1.0.0-rc.2 split ships - the shared default. +_CORE_DOCUMENTS = (meta_store.META_SCHEMA_FILE, meta_store.PATTERN_LINT_FILE, meta_store.UI_META_SCHEMA_FILE) + + +def _run_git(cwd: Path, *args: str) -> None: + # S603/S607: a fixed executable ("git") with literal, test-authored arguments, never + # untrusted input. + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) # noqa: S603, S607 + + +def _git_output(cwd: Path, *args: str) -> str: + """Run a git command and return its stdout, stripped - for assertions, not fixture setup.""" + # S603/S607: see _run_git above. + result = subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) # noqa: S603, S607 + return result.stdout.strip() + + +def _commit_and_tag(repo: Path, version: str) -> None: + _run_git(repo, "add", "-A") + _run_git(repo, "commit", "-q", "-m", f"release {version}") + _run_git(repo, "tag", f"v{version}") + + +@pytest.fixture +def source_repo(tmp_path: Path) -> Path: + """A minimal git-initialised checkout, standing in for an oold-schema clone.""" + repo = tmp_path / "source" + (repo / "meta").mkdir(parents=True) + (repo / "examples" / "compliance").mkdir(parents=True) + _run_git(repo, "init", "-q") + _run_git(repo, "config", "user.email", "test@example.com") + _run_git(repo, "config", "user.name", "Test") + # The point of this suite is what git plumbing returns, not what a Windows default would do + # to it on the way in; each test controls line endings explicitly instead. + _run_git(repo, "config", "core.autocrlf", "false") + return repo + + +@pytest.fixture +def isolated_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """An isolated tracked folder and fixture destination, so a test never touches the real ones.""" + meta_root = tmp_path / "tracked-meta" + meta_root.mkdir() + index = { + "files": list(_CORE_DOCUMENTS), + "versions": {}, + "fixtures": {"tag": "v0.0.0"}, + } + (meta_root / "index.json").write_text(json.dumps(index), encoding="utf-8") + monkeypatch.setattr(meta_store, "meta_dir", lambda: meta_root) + meta_store.load_index.cache_clear() + + fixtures_root = tmp_path / "fixtures" + (fixtures_root / "compliance").mkdir(parents=True) + monkeypatch.setattr(meta_vendor, "_fixtures_dir", lambda: fixtures_root) + + try: + yield meta_root, fixtures_root + finally: + meta_store.load_index.cache_clear() + + +def _write_core_documents(repo: Path, *, id_base: str = "https://example.org/1.2.3/meta/") -> None: + for name in _CORE_DOCUMENTS: + (repo / "meta" / name).write_text(json.dumps({"$id": id_base + name}), encoding="utf-8") + + +def test_vendor_writes_the_shipped_files_with_matching_checksums(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + assert set(result.files) == set(_CORE_DOCUMENTS) + index = json.loads((meta_root / "index.json").read_text(encoding="utf-8")) + recorded = index["versions"]["1.2.3"]["sha256"] + for name in _CORE_DOCUMENTS: + content = (meta_root / "1.2.3" / name).read_bytes() + assert hashlib.sha256(content).hexdigest() == recorded[name] + + +def test_the_written_index_json_has_no_crlf(source_repo, isolated_store): + """`Path.write_text`'s default `newline` translates "\\n" to `os.linesep`, which on Windows + is CRLF - the same trap the vendored files avoid by being written with `write_bytes`, just + reappearing in the one file this command writes as text. Asserted on the bytes, the way + `test_the_vendored_files_are_stored_with_unix_line_endings` checks the vendored files, since + that is the form of the check that actually travels across platforms. + """ + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + assert b"\r\n" not in (meta_root / "index.json").read_bytes() + + +def test_vendor_records_tag_commit_and_commit_date(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + commit = _git_output(source_repo, "rev-parse", "v1.2.3^{commit}") + committed = _git_output(source_repo, "log", "-1", "--format=%cI", commit) + assert result.tag == "v1.2.3" + assert result.commit == commit + assert len(result.commit) == 40 + assert result.committed == committed + + +def test_vendor_omits_files_override_when_the_set_matches_the_default(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + entry = json.loads((meta_root / "index.json").read_text(encoding="utf-8"))["versions"]["1.2.3"] + assert "files" not in entry + + +def test_vendor_records_a_files_override_when_the_base_is_present(source_repo, isolated_store): + _write_core_documents(source_repo) + (source_repo / "meta" / meta_store.META_SCHEMA_BASE_FILE).write_text( + json.dumps({"$id": "https://example.org/1.2.3/meta/" + meta_store.META_SCHEMA_BASE_FILE}), + encoding="utf-8", + ) + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + entry = json.loads((meta_root / "index.json").read_text(encoding="utf-8"))["versions"]["1.2.3"] + assert entry["files"] == [ + meta_store.META_SCHEMA_FILE, + meta_store.META_SCHEMA_BASE_FILE, + meta_store.PATTERN_LINT_FILE, + meta_store.UI_META_SCHEMA_FILE, + ] + + +def test_vendor_includes_the_optional_rule_files_without_a_files_override(source_repo, isolated_store): + _write_core_documents(source_repo) + (source_repo / "meta" / meta_store.RULES_FILE).write_text(json.dumps({"rules": []}), encoding="utf-8") + (source_repo / "meta" / meta_store.RULES_SCHEMA_FILE).write_text(json.dumps({"type": "object"}), encoding="utf-8") + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + entry = json.loads((meta_root / "index.json").read_text(encoding="utf-8"))["versions"]["1.2.3"] + assert "files" not in entry, "the rule catalogue is not part of the registry's document set" + assert meta_store.RULES_FILE in entry["sha256"] + assert meta_store.RULES_SCHEMA_FILE in entry["sha256"] + assert meta_store.RULES_FILE in result.files + assert (meta_root / "1.2.3" / meta_store.RULES_FILE).is_file() + + +def test_vendor_ignores_files_under_meta_that_are_not_part_of_the_bundle(source_repo, isolated_store): + """oold-schema's meta/ also carries RULES.md and rules-baseline.json - authoring tooling, not + part of what any tracked version loads. Copying them would be exactly the "wrong file set" + mistake this command exists to remove. + """ + _write_core_documents(source_repo) + (source_repo / "meta" / "RULES.md").write_text("not a schema", encoding="utf-8") + (source_repo / "meta" / "rules-baseline.json").write_text("{}", encoding="utf-8") + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + assert "RULES.md" not in result.files + assert "rules-baseline.json" not in result.files + assert not (meta_root / "1.2.3" / "RULES.md").exists() + assert not (meta_root / "1.2.3" / "rules-baseline.json").exists() + + +def test_vendor_records_id_base_from_the_wrapper_document(source_repo, isolated_store): + _write_core_documents(source_repo, id_base="https://oo-ld.org/1.2.3/meta/") + _commit_and_tag(source_repo, "1.2.3") + meta_root, _ = isolated_store + + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + entry = json.loads((meta_root / "index.json").read_text(encoding="utf-8"))["versions"]["1.2.3"] + assert entry["id_base"] == "https://oo-ld.org/1.2.3/meta/" + + +def test_vendored_files_are_written_with_lf_even_when_the_working_tree_has_crlf(source_repo, isolated_store): + """The CRLF trap: a checkout's working tree can hold CRLF - from `core.autocrlf=true` on + Windows, or, as here, from something dirtying the file after the commit - while the committed + blob stays LF. The command must read the blob, never the working tree file, so the two can + disagree and the output still matches the blob. + """ + _write_core_documents(source_repo) + committed_bytes = (source_repo / "meta" / meta_store.META_SCHEMA_FILE).read_bytes() + assert b"\r\n" not in committed_bytes + _commit_and_tag(source_repo, "1.2.3") + + # Dirty the working tree after the commit: same content, CRLF line endings. The blob in the + # object database is untouched. + (source_repo / "meta" / meta_store.META_SCHEMA_FILE).write_bytes(committed_bytes.replace(b"\n", b"\r\n")) + + meta_root, _ = isolated_store + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + written = (meta_root / "1.2.3" / meta_store.META_SCHEMA_FILE).read_bytes() + assert b"\r\n" not in written + assert written == committed_bytes + entry = json.loads((meta_root / "index.json").read_text(encoding="utf-8"))["versions"]["1.2.3"] + assert entry["sha256"][meta_store.META_SCHEMA_FILE] == hashlib.sha256(committed_bytes).hexdigest() + + +def test_vendor_refuses_to_overwrite_an_existing_version_without_force(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + with pytest.raises(MetaSchemaError, match="--force"): + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + +def test_vendor_with_force_overwrites_an_existing_version(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + (source_repo / "meta" / meta_store.META_SCHEMA_FILE).write_text( + json.dumps({"$id": "https://example.org/1.2.3/meta/" + meta_store.META_SCHEMA_FILE, "changed": True}), + encoding="utf-8", + ) + _run_git(source_repo, "add", "-A") + _run_git(source_repo, "commit", "-q", "-m", "amend release 1.2.3") + _run_git(source_repo, "tag", "-f", "v1.2.3") + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=True) + meta_root, _ = isolated_store + written = json.loads((meta_root / "1.2.3" / meta_store.META_SCHEMA_FILE).read_text(encoding="utf-8")) + assert written["changed"] is True + assert result.commit + + +def test_vendor_reports_a_missing_tag_clearly(source_repo, isolated_store): + _write_core_documents(source_repo) + _commit_and_tag(source_repo, "1.2.3") + + with pytest.raises(MetaSchemaError, match=re.escape("9.9.9")): + meta_vendor.vendor_version("9.9.9", source_repo, force=False) + + +def test_vendor_refuses_a_tag_with_no_meta_schema_wrapper(source_repo, isolated_store): + (source_repo / "meta" / meta_store.PATTERN_LINT_FILE).write_text( + json.dumps({"$id": "https://example.org/1.2.3/meta/" + meta_store.PATTERN_LINT_FILE}), encoding="utf-8" + ) + _commit_and_tag(source_repo, "1.2.3") + + with pytest.raises(MetaSchemaError, match=meta_store.META_SCHEMA_FILE): + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + +def test_vendor_refreshes_the_fixture_slice_and_records_its_tag(source_repo, isolated_store): + _write_core_documents(source_repo) + (source_repo / "examples" / "Thing.schema.json").write_text(json.dumps({"name": "Thing"}), encoding="utf-8") + (source_repo / "examples" / "compliance" / "rule.json").write_text(json.dumps({"ok": True}), encoding="utf-8") + _commit_and_tag(source_repo, "1.2.3") + meta_root, fixtures_root = isolated_store + + result = meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + assert (fixtures_root / "Thing.schema.json").read_text(encoding="utf-8") == json.dumps({"name": "Thing"}) + assert (fixtures_root / "compliance" / "rule.json").read_text(encoding="utf-8") == json.dumps({"ok": True}) + assert "Thing.schema.json" in result.fixture_files + assert "compliance/rule.json" in result.fixture_files + index = json.loads((meta_root / "index.json").read_text(encoding="utf-8")) + assert index["fixtures"]["tag"] == "v1.2.3" + + +def test_fixture_refresh_never_touches_locally_authored_directories(source_repo, isolated_store): + """`broken/`, `remote_context/` and `x_oold_context/` are hand-written and not part of any + upstream tag; the refresh must never see them, since it only ever descends into `examples/` + and `examples/compliance/` on the source side. + """ + _write_core_documents(source_repo) + (source_repo / "examples" / "Thing.schema.json").write_text(json.dumps({"name": "Thing"}), encoding="utf-8") + _commit_and_tag(source_repo, "1.2.3") + _, fixtures_root = isolated_store + broken_dir = fixtures_root / "broken" + broken_dir.mkdir() + (broken_dir / "invalid_meta.schema.json").write_text("{}", encoding="utf-8") + before = (broken_dir / "invalid_meta.schema.json").read_bytes() + + meta_vendor.vendor_version("1.2.3", source_repo, force=False) + + assert (broken_dir / "invalid_meta.schema.json").read_bytes() == before