diff --git a/src/oold/validation/cli.py b/src/oold/validation/cli.py index dfa7600..4e2cf03 100644 --- a/src/oold/validation/cli.py +++ b/src/oold/validation/cli.py @@ -91,9 +91,10 @@ def _classify(target: Path) -> str: "--meta", "meta", multiple=True, - metavar="VERSION", + metavar="VERSION|PATH", help="Meta-schema version: latest (default), a version such as 0.7.0, remote, or all. " - "Repeat to validate against several at once.", + "A path to an oold-schema checkout validates against its working tree, including rules it " + "has not released yet. Repeat to validate against several at once.", ) _offline_option = click.option( "--offline", is_flag=True, help="Never fetch over the network; use local files and the cache." diff --git a/src/oold/validation/meta_store.py b/src/oold/validation/meta_store.py index 9bfc24b..30920b8 100644 --- a/src/oold/validation/meta_store.py +++ b/src/oold/validation/meta_store.py @@ -37,6 +37,9 @@ from .resolve import SchemaResolutionError, default_cache_dir, http_get_json META_SCHEMA_FILE = "oold-meta-schema.json" +#: The dialect body the wrapper `$ref`s, present from 1.0.0-rc.2 onward. Named here because a +#: local checkout has no index entry to declare its file set. +META_SCHEMA_BASE_FILE = "oold-meta-schema-base.json" UI_META_SCHEMA_FILE = "oold-ui-meta-schema.json" PATTERN_LINT_FILE = "oold-pattern-lint.schema.json" @@ -428,6 +431,47 @@ def load_tracked(version: str) -> MetaBundle: ) +LOCAL = "local" + + +def load_local(directory: Path | str) -> MetaBundle: + """Load a meta-schema set from a checkout rather than from a tracked release. + + Exists for the one case the tracked versions cannot serve: validating a change to the + specification *before* it is released. A tracked version is a tag and `--meta remote` is + `refs/heads/main`, so a rule added on a branch is invisible to both - the checks bound to it + skip, reporting that the version never stated it, and the pull request that introduces a rule + is the one run that cannot enforce it. + + `directory` is a checkout of oold-schema, or its `meta/` directly. Unlike a tracked version + nothing here is checksummed: the files are working state and expected to change, which is the + point. So this must not be the default, and a released version must never be read this way - + `load_tracked` stays the only path to those, and its checksums stay meaningful. + """ + root = Path(directory) + candidate = root / "meta" if (root / "meta" / META_SCHEMA_FILE).is_file() else root + if not (candidate / META_SCHEMA_FILE).is_file(): + raise MetaSchemaError( + f"{root} holds no meta-schemas: expected {META_SCHEMA_FILE} in it or in its meta/ subdirectory" + ) + present = [name for name in [*meta_files(), META_SCHEMA_BASE_FILE] if (candidate / name).is_file()] + documents = _read_documents(candidate, f"meta-schemas in {candidate}", present) + catalog, catalog_error = _read_rules(candidate) + rules, rules_error = _parse_rules(catalog, catalog_error) + rules_schema, rules_schema_error = _read_rules_schema(candidate) + return MetaBundle( + version=LOCAL, + origin=str(candidate), + documents=documents, + registry=_build_registry(documents), + rules=rules, + rules_document=catalog, + rules_schema=rules_schema, + rules_schema_error=rules_schema_error, + rules_error=rules_error, + ) + + # ---------------------------------------------------------------------------- remote @@ -518,9 +562,17 @@ def resolve_selection( requested = list(selectors) or [LATEST] wanted: list[str] = [] + local_paths: dict[str, str] = {} for selector in requested: name = selector.strip() - if name == ALL: + # A selector naming a directory is a checkout, not a version. Recognised by being one, + # rather than by a prefix, so `--meta ../oold-schema` reads the way a path should; a + # tracked version name can never collide, since none of them is a directory here. + if name not in (ALL, LATEST, REMOTE) and Path(name).is_dir(): + key = f"{LOCAL}:{Path(name).resolve()}" + local_paths[key] = name + resolved = [key] + elif name == ALL: resolved = tracked_versions() if not resolved: raise MetaSchemaError(f"no meta-schema versions are tracked in {meta_dir()}") @@ -534,7 +586,9 @@ def resolve_selection( bundles: list[MetaBundle] = [] for version in wanted: - if version == REMOTE: + if version in local_paths: + bundles.append(load_local(local_paths[version])) + elif version == REMOTE: bundles.append(load_remote(offline=offline, timeout=timeout)) else: bundles.append(load_tracked(version)) diff --git a/tests/test_validation/test_meta_store.py b/tests/test_validation/test_meta_store.py index 173771f..3eafe8c 100644 --- a/tests/test_validation/test_meta_store.py +++ b/tests/test_validation/test_meta_store.py @@ -446,3 +446,40 @@ def fake_get(uri, timeout=10.0): lambda *a, **k: pytest.fail("offline mode fetched over the network"), ) assert meta_store.load_remote(offline=True).version == "remote" + + +def test_a_directory_selector_loads_the_meta_schemas_in_it(tmp_path): + """`--meta ` reads a checkout, which is the only way to see an unreleased rule. + + A tracked version is a tag and `remote` is `refs/heads/main`, so a rule added on a branch is + invisible to both: the checks bound to it skip, saying the version never stated it, and the + pull request introducing a rule becomes the one run that cannot enforce it. + """ + latest = meta_store.meta_dir() / meta_store.latest_version() + checkout = tmp_path / "oold-schema" / "meta" + checkout.mkdir(parents=True) + for src in latest.glob("*.json"): + (checkout / src.name).write_bytes(src.read_bytes()) + + # A rule that exists only here, the way a branch would carry one. + catalog = json.loads((checkout / meta_store.RULES_FILE).read_text(encoding="utf-8")) + invented = dict(catalog["rules"][0], id="OOLD-XXX-beef", summary="only in the checkout") + catalog["rules"].append(invented) + (checkout / meta_store.RULES_FILE).write_text(json.dumps(catalog), encoding="utf-8") + + tracked = meta_store.load_tracked(meta_store.latest_version()) + # Both the repository root and its meta/ directory resolve to the same thing. + for selector in (tmp_path / "oold-schema", checkout): + bundle = meta_store.resolve_selection([str(selector)])[0] + assert bundle.version == meta_store.LOCAL + ids = {r.id for r in bundle.rules} + assert "OOLD-XXX-beef" in ids, "a local checkout must surface a rule no release carries" + assert ids - {"OOLD-XXX-beef"} == {r.id for r in tracked.rules} + + assert "OOLD-XXX-beef" not in {r.id for r in tracked.rules}, "the tracked copy must be untouched" + + +def test_a_directory_without_meta_schemas_is_rejected_by_name(tmp_path): + """The likely mistake is pointing at the wrong directory, so say what was expected.""" + with pytest.raises(MetaSchemaError, match=meta_store.META_SCHEMA_FILE): + meta_store.load_local(tmp_path)