diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 7d11a970..8bd0fe08 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -365,12 +365,37 @@ def _warn_on_empty_projection(stats: dict[str, int], repo_root: Path) -> None: "dashboard's empty funnel reads the same either way", file=sys.stderr) +class _RepeatedKey(ValueError): + """A JSON object in the written snapshot gives one key twice.""" + + +def _refuse_repeated_keys(pairs: list[tuple[str, object]]) -> dict: + document: dict = {} + for key, value in pairs: + if key in document: + raise _RepeatedKey(f"the key {key!r} twice in one object") + document[key] = value + return document + + def _written_kind(written: Path) -> str | None: """The `kind` the written snapshot declares, which chooses its validator, - or None where the file declares none it can be read by.""" + or None where the file declares none it can be read by. + + A KEY GIVEN TWICE IS REFUSED, `_RepeatedKey` (plan 034 T058; Copilot at + openDox-code#68 09cd1e8a, r4139769819). Python's `json` keeps the last + of two, so `"kind": "opendox-snapshot", "kind": "unknown"` chose no + registered validator, and an ordinary run then warned and exited 0, + though no reader could say which contract the file meant. Such a + document has no one meaning, whatever its kind, so no validator is chosen + for it. Which constants or numbers a contract admits is the chosen + validator's to judge, since none of them makes the kind ambiguous.""" try: - document = json.loads(written.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, ValueError): + document = json.loads(written.read_text(encoding="utf-8"), + object_pairs_hook=_refuse_repeated_keys) + except _RepeatedKey: + raise + except (OSError, UnicodeDecodeError, ValueError, RecursionError): return None kind = document.get("kind") if isinstance(document, dict) else None return kind if isinstance(kind, str) and kind else None @@ -487,7 +512,13 @@ def _validate(written: Path, args: argparse.Namespace, *, print(" validation skipped (--no-validate)") return 0 repo_root = Path(args.repo_root).resolve() - kind = _written_kind(written) + try: + kind = _written_kind(written) + except _RepeatedKey as exc: + print(f" validation FAILED — {written} gives {exc}, so it has no one " + f"meaning: its kind cannot be read, and no validator can be " + f"chosen for it.", file=sys.stderr) + return 1 if kind is None: print(f" validation FAILED — {written} declares no kind, so no " f"validator can be chosen for it, and a snapshot that does not " diff --git a/src/opendox/default_projection.py b/src/opendox/default_projection.py index f6874376..0849baa6 100644 --- a/src/opendox/default_projection.py +++ b/src/opendox/default_projection.py @@ -29,19 +29,53 @@ anything is written. The write is ATOMIC: a temporary sibling, then one `os.replace`, so a request never reads a half-written snapshot. -`VALIDATOR`, THE VALIDATOR LOOKUP'S DEFAULT, FOR openDox's OWN KINDS. It is -openDox's own validator, plan 034's T057, which this tree does not carry yet. -Until it does, this stand-in answers every validation `VALIDATOR_UNAVAILABLE`, -naming T057, and never `VALIDATED`: nothing here has checked anything. So a -generate verb warns that its snapshot was not checked, and fails under -`--strict`, and a workbench manifest saved with `validate=True` is refused as -unvalidated, which is what a lone openDox answered before T055 whenever no -validator was reachable. T057's validator replaces it here, under the same -kinds. +`VALIDATORS`, THE VALIDATOR LOOKUP'S DEFAULT, ONE PER OWN KIND (plan 034's +T058). Each is an adapter over openDox's own validator, `opendox.validator` +(T057), bound to one of `OWN_KINDS`, and it keeps the lookup's protocol: +`validate(path, *, strict, search_from)` answers a +`projection_seams.ValidationResult`. The seam registers one validator per +kind and hands it only a path, so the adapter is what knows the kind: the one +it is registered under. + +* IT READS THE DOCUMENT AS ITS KIND IS WRITTEN. The neutral snapshot is JSON, + as this module's writer writes it, and it is parsed as JSON alone: NaN, the + infinities and a key given twice are not JSON, and are refused. The + workbench manifest is YAML, parsed with PyYAML's safe loader, as + `workbench.py` reads it. Then `opendox.validator.validator_for(kind)` judges + it against openDox's packaged copy of that kind's schema, which is proved + against its recorded digest on every call. +* THREE OUTCOMES, as `cli._validate` gives them their consequences. No + violation is `VALIDATED`. Any violation is `NOT_CONFORMANT`, return code 1, + and the standard output names each one as `[] : `, so + the rule's identifier reaches the verb's report (F7.2 asserts T051's + `EXPECTED_RULE` there). A document that cannot be read as JSON, or as + YAML, breaks `SYNTAX_RULE`. One that can be read, but holds a number that + cannot be read as written (an infinity, a NaN, one binary64 would round, + or a spelling that cannot be proved), breaks `NUMBER_RULE` instead, so the + report names the numeric policy and not a syntax error. + `ValidatorUnavailable`, a packaged copy that failed its + identity check or cannot be evaluated, is `VALIDATOR_UNAVAILABLE`, with the + validator's own reason, and so is a document that could not be read. That + is "the check could not be performed", never a pass, and `--strict` makes it + fatal. +* `strict` CHANGES NOTHING HERE: openDox's validator has no warnings to + harden. `search_from` IS NOT READ: the schemas are package data, so nothing + is searched for, and no path can make the validator reachable or not. No + subprocess runs, so there is no dependency remedy (`dependency_remedy` is + None). +* THE WORKBENCH MANIFEST'S TWO VALIDATOR RULES. Its schema says of two rules + that it cannot state them, and leaves them to the validator. The consumer's + script checked them in single-file mode, and they are carried here, under + its identifiers, so routing `validate_manifest` to openDox's validator drops + neither (the holder's decision, 2026-09-28). `workbench-pinned-not-checked`: + every `recipe.pinned` keyword is also in `recipe.checked`. + `workbench-candidate-overlap`: no `recipe.new_candidates` document is + already a member or excluded. IMPORT WEIGHT. `opendox.generator_seam`, `opendox.projection_seams` and the standard library. So this module imports with no extra installed and no -sibling present. +sibling present. `opendox.validator` (the standard library and +`opendox.contracts`) and PyYAML are imported when a validation runs. A CREATED FILE: it has no row in openxFactory's `docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES @@ -52,28 +86,54 @@ import contextlib import json +import math import os import stat import uuid +from decimal import Decimal, InvalidOperation from pathlib import Path from typing import Any from opendox import generator_seam, projection_seams -__all__ = ["CORPUS_ROOT", "CorpusRoot", "OWN_KINDS", "OwnValidatorNotBuilt", - "SnapshotNotWritable", "VALIDATOR", "WRITER", "Writer"] +__all__ = ["CORPUS_ROOT", "CorpusRoot", "OWN_KINDS", "OwnValidator", + "NUMBER_RULE", "SYNTAX_RULE", "SnapshotNotWritable", "VALIDATORS", "WORKBENCH_RULES", + "WRITER", "Writer"] #: The workbench manifest's kind, `opendox.workbench.KIND`, restated because #: `workbench` imports PyYAML and this module must import with nothing extra. #: `tests/test_projection_seams.py` holds the two spellings together. WORKBENCH_KIND = "ideation-workbench" -#: The kinds openDox's own validator answers for, which the entry points -#: register it under: the neutral snapshot every generate verb writes with -#: openDox's own generator, and the workbench manifest `workbench.save()` -#: validates. T057 names its full input set, and registers under it. +#: The kinds openDox's own validator answers for at the validator lookup, +#: which the entry points register it under: the neutral snapshot every +#: generate verb writes with openDox's own generator, and the workbench +#: manifest `workbench.save()` validates. `opendox.validator` validates the +#: doxBench wire kinds too, and they reach it through their own seam +#: (`serve_wire.register_doxbench_validators`, T085), not through this one. OWN_KINDS: tuple[str, ...] = (generator_seam.NEUTRAL_SNAPSHOT_KIND, WORKBENCH_KIND) +#: How each own kind is written, and so how its document is read. +_SYNTAX: dict[str, str] = {generator_seam.NEUTRAL_SNAPSHOT_KIND: "JSON", + WORKBENCH_KIND: "YAML"} + +#: The rule a document breaks when it cannot be read as JSON, or as YAML, as +#: its kind is written. It is the adapter's, and no contract's: a contract's +#: rules are about a document that could be read. +SYNTAX_RULE = "document-syntax" + +#: The rule a document breaks when it reads as JSON, or as YAML, but holds a +#: number that cannot be read as written (`_exact`): no verdict over the +#: float it was read as would be a verdict over the number written. Kept apart +#: from `SYNTAX_RULE`, so the report does not call a valid document malformed +#: (Copilot at openDox-code#68 c7768ed5, r4146428769). +NUMBER_RULE = "document-number" + +#: The workbench manifest's two validator rules, which its schema leaves to +#: the validator, under the identifiers the consumer's script gave them. +WORKBENCH_RULES: tuple[str, ...] = ("workbench-pinned-not-checked", + "workbench-candidate-overlap") + class CorpusRoot: """openDox's own corpus-root predicate: a git repository's root.""" @@ -222,21 +282,241 @@ def write_snapshot(self, snapshot: dict[str, Any], path: Path | str, return target -class OwnValidatorNotBuilt: - """The validator lookup's default until openDox's own validator (T057) is - in this tree. It concludes nothing, and says so.""" - - #: No dependency to install would make it run. +class _Unprovable(ValueError): + """A number the document holds that cannot be read as written.""" + + +class _NotJSON(ValueError): + """A JSON text holds what JSON does not: a key given twice, NaN or an + infinity.""" + + +def _refuse_constant(name: str) -> Any: + raise _NotJSON(f"{name} is not JSON") + + +def _exact(text: str, value: float) -> float: + """`value`, the float a number literal `text` was read as, once it is + proved to be the number written: finite, and not rounded. + + * FINITE. `1e999` is a valid JSON number that Python reads as an infinity + without calling `parse_constant`, and JSON carries no infinity (Copilot + at openDox-code#68 21e4723f, r4139840593). + * NOT ROUNDED. A float holds what binary64 holds, the precision JSON + readers share (RFC 8259 section 6), so `1.0000000000000001` reads as + `1.0`, and would then meet `const: 1` (Copilot at openDox-code#68 + 69ca0e27, r4139937566). A literal whose value differs from the + shortest spelling of the float read from it is refused, since no + verdict over the float would be a verdict over the number written. + `0.1`, `2.50` and `1E2` read as written, and so does every float + openDox's own writer writes, which is the float's own shortest + spelling. + * PROVABLE. A spelling this cannot compare with the float is refused, + not trusted. YAML's base-60 floats (`0:1.0000000000000001`) are read + and rounded by PyYAML, but `Decimal` cannot parse them, so no proof + was made, and such a literal passed as `1.0` (Copilot at + openDox-code#68 80153754, r4146201125). JSON has no such spelling, and + openDox's writers write none.""" + if not math.isfinite(value): + raise _Unprovable(f"the number {text[:40]} reads as {value}, and JSON " + "carries no infinity or NaN") + try: + written = Decimal(text.replace("_", "")) + except InvalidOperation: + raise _Unprovable(f"the number {text[:40]} is in a spelling that cannot " + "be proved as written (a YAML base-60 number, say), and " + "JSON has no such spelling") from None + if Decimal(repr(value)) != written: + raise _Unprovable(f"the number {text[:40]} cannot be read as written: " + f"the precision JSON readers share holds it as {value!r}") + return value + + +def _json_float(text: str) -> float: + """A JSON number with a fraction or an exponent (`parse_float`).""" + return _exact(text, float(text)) + + +def _refuse_repeated_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + document: dict[str, Any] = {} + for key, value in pairs: + if key in document: + raise _NotJSON(f"the key {key!r} is given twice in one object") + document[key] = value + return document + + +def _names(value: Any) -> list[str]: + """A list's string entries, once each, in order. Anything else answers + none: its shape is the schema's to judge, and these rules only compare. + + Linear: the schema bounds none of the three lists, so membership is a + set's, and the list only keeps the order (Copilot at openDox-code#68 + 09cd1e8a, r4139734444).""" + if not isinstance(value, list): + return [] + names: list[str] = [] + seen: set[str] = set() + for item in value: + if isinstance(item, str) and item not in seen: + seen.add(item) + names.append(item) + return names + + +#: How many names a rule's detail quotes before it says how many more, and +#: how much of each name it quotes. +_QUOTED = 10 +_NAME_CHARS = 80 + + +def _quoted(names: list[str]) -> str: + """`names` as a detail quotes them: the first few, each cut to a readable + length, and a count of the rest, so one violation stays one readable line. + The schema bounds neither the lists nor their strings (Copilot at + openDox-code#68 21e4723f, r4139769791).""" + shown = repr([name if len(name) <= _NAME_CHARS else name[:_NAME_CHARS - 1] + "…" + for name in names[:_QUOTED]]) + return shown if len(names) <= _QUOTED else f"{shown[:-1]}, and {len(names) - _QUOTED} more]" + + +def _documents(entries: Any) -> set[str]: + """The `document` of each entry of a members or excluded list.""" + if not isinstance(entries, list): + return set() + return {entry["document"] for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("document"), str)} + + +class OwnValidator: + """openDox's own validator for ONE of its kinds, behind the validator + lookup's protocol (plan 034's T058; this module's docstring).""" + + #: No subprocess runs, so no dependency to install would make it run. dependency_remedy = None + def __init__(self, kind: str) -> None: + if kind not in _SYNTAX: + raise ValueError( + f"openDox's own validator is bound only to openDox's own kinds " + f"{list(_SYNTAX)}, each read as it is written, not {kind!r}") + self.kind = kind + self.syntax = _SYNTAX[kind] + + def __repr__(self) -> str: + return f"" + + def _unavailable(self, reason: str) -> projection_seams.ValidationResult: + return projection_seams.ValidationResult( + False, -1, "", "", "opendox.validator", + projection_seams.VALIDATOR_UNAVAILABLE, reason) + + def _read(self, text: str) -> Any: + """The document, parsed as its kind is written. Raises `ValueError` + (a YAML error included) where it is not.""" + if self.syntax == "JSON": + return json.loads(text, parse_constant=_refuse_constant, + parse_float=_json_float, + object_pairs_hook=_refuse_repeated_keys) + import yaml + + class _Loader(yaml.SafeLoader): + """PyYAML's safe loader, whose floats are proved as the snapshot's + JSON numbers are (`_exact`): finite, and not rounded.""" + + def construct_float(loader, node): + return _exact(str(node.value), + yaml.SafeLoader.construct_yaml_float(loader, node)) + + _Loader.add_constructor("tag:yaml.org,2002:float", construct_float) + try: + return yaml.load(text, Loader=_Loader) # noqa: S506 - a SafeLoader subclass + except yaml.YAMLError as exc: + raise ValueError(" ".join(str(exc).split())) from exc + + @staticmethod + def _workbench_rules(document: Any) -> list: + """The manifest's two validator rules (this module's docstring).""" + from opendox.validator import Violation + + if not isinstance(document, dict) or not isinstance(document.get("recipe"), dict): + return [] + recipe = document["recipe"] + found = [] + checked = set(_names(recipe.get("checked"))) + stray = [name for name in _names(recipe.get("pinned")) if name not in checked] + if stray: + found.append(Violation( + WORKBENCH_RULES[0], ("recipe", "pinned"), "workbench-rule", + f"pinned keyword(s) {_quoted(stray)} are not in checked: every pinned " + "keyword MUST also be checked")) + placed = _documents(document.get("members")) | _documents(document.get("excluded")) + overlap = [name for name in _names(recipe.get("new_candidates")) if name in placed] + if overlap: + found.append(Violation( + WORKBENCH_RULES[1], ("recipe", "new_candidates"), "workbench-rule", + f"new_candidates {_quoted(overlap)} already appear in members or " + "excluded: a new candidate is a document the set has not " + "placed yet")) + return found + def validate(self, path: Path | str, *, strict: bool = False, search_from: tuple = ()) -> projection_seams.ValidationResult: + """Validate the document at `path` as this validator's kind. `strict` + and `search_from` are the protocol's, and change nothing here.""" + from opendox import validator as own + + try: + data = Path(path).read_bytes() + except OSError as exc: + return self._unavailable( + f"the document could not be read ({exc.strerror or exc}), so " + "nothing was judged") + try: + kind_validator = own.validator_for(self.kind) + except (own.ValidatorUnavailable, own.UnknownKind) as exc: + return self._unavailable(" ".join(str(exc).split())) + except OSError as exc: + # A packaged file that is present but cannot be read (its + # permissions, say). `opendox.contracts` refuses a MISSING copy + # as `CopyRefused`, and any other read failure reaches here as + # itself. It is still "the check could not be performed", so the + # verb reports it, and `--strict` fails, without a traceback + # (Copilot at openDox-code#68 09cd1e8a, r4139734412). + return self._unavailable( + f"openDox's packaged contracts could not be read " + f"({type(exc).__name__}: {exc.strerror or exc}), so nothing " + "was judged") + ran = (f"opendox.validator, over its packaged copy {kind_validator.copy_id} " + f"(sha256 {kind_validator.digest[:12]})") + try: + document = self._read(data.decode("utf-8")) + except _Unprovable as exc: + violations = [own.Violation( + NUMBER_RULE, (), "number", + f"the document reads as {self.syntax}, but holds a number " + f"that cannot be read as written, so no verdict over it would " + f"be a verdict over the document: {' '.join(str(exc).split())}")] + except (UnicodeDecodeError, ValueError, RecursionError) as exc: + violations = [own.Violation( + SYNTAX_RULE, (), "syntax", + f"the document cannot be read as {self.syntax}, which is how " + f"a document of kind {self.kind!r} is written: " + f"{' '.join(str(exc).split()) or type(exc).__name__}")] + else: + violations = kind_validator.violations(document) + if self.kind == WORKBENCH_KIND: + violations += self._workbench_rules(document) + if not violations: + return projection_seams.ValidationResult( + True, 0, f"{self.kind}: 0 violations, by {ran}\n", "", ran) + lines = own.report(violations) + lines.append(f"{len(violations)} violation(s) of the {self.kind} " + f"contract, by {ran}") return projection_seams.ValidationResult( - False, -1, "", "", None, projection_seams.VALIDATOR_UNAVAILABLE, - "openDox's own validator is plan 034's T057, and this build does " - "not carry it yet, so nothing of openDox's own kinds is checked") + False, 1, "\n".join(lines) + "\n", "", ran) CORPUS_ROOT = CorpusRoot() WRITER = Writer() -VALIDATOR = OwnValidatorNotBuilt() +VALIDATORS: dict[str, OwnValidator] = {kind: OwnValidator(kind) for kind in OWN_KINDS} diff --git a/src/opendox/projection_seams.py b/src/opendox/projection_seams.py index d3d73160..c426196a 100644 --- a/src/opendox/projection_seams.py +++ b/src/opendox/projection_seams.py @@ -601,4 +601,4 @@ def register_defaults() -> None: corpus_root.register_default(default_projection.CORPUS_ROOT) writer.register_default(default_projection.WRITER) for kind in default_projection.OWN_KINDS: - validators.register_default(kind, default_projection.VALIDATOR) + validators.register_default(kind, default_projection.VALIDATORS[kind]) diff --git a/tests/fixtures/opendox-snapshot.schema.yaml b/tests/fixtures/opendox-snapshot.schema.yaml deleted file mode 100644 index 2be5018f..00000000 --- a/tests/fixtures/opendox-snapshot.schema.yaml +++ /dev/null @@ -1,443 +0,0 @@ -# openDox's own neutral snapshot contract (plan 034, T053). -# -# WHY THIS FILE IS JSON. It is YAML whose body is one JSON object, and that is -# deliberate. The leg's required `validate` check installs pytest and nothing -# else, so `tests/test_opendox_snapshot_contract.py` reads this file with -# Python's built-in `json` module once these comment lines are set aside. -# JSON is YAML, so every YAML loader in the family reads the same object, and -# the same test proves that PyYAML agrees wherever PyYAML is installed. This -# leg's negative chat-turn examples already take this form. -# -# SECTIONS. openDox's views render six stations from five sections, as -# openDox's own STAGE_FIELDS declares them: source reads documents, grouping -# reads clusters, candidate reads possibles, selection reads staged_topics, -# and the submission and completion stations read the changes entries whose -# status is active and archived. Every station section is required, and it -# is an empty list when its station holds nothing. keyword_index is optional: -# without it, a reader derives the keyword rail from the documents' topics. -# -# CLOSED VALUES, all neutral. A document's stage is one of the six station -# role keys, a candidate's state is one of unselected, selected, declined and -# replaced, and a changes entry's status is active or archived. openDox's -# views match them through SNAPSHOT_VALUES, whose defaults T054 moves to -# these values. -# -# DETERMINISTIC, which is the generator's to keep and no schema can check: the -# same tree yields a byte-identical snapshot, so nothing in it records when -# the generator ran. generated_at, when present, is fixed by the source -# revision (its commit date, or a date recorded with it), never read from the -# clock. FORWARD-COMPATIBLE. A reader ignores unknown properties, so no object -# sets additionalProperties false, and an additive field needs no -# schema_version bump. -# -# RULES. Every rule has an identifier. A subschema names, in x-rule, the rule -# its keywords enforce; x-rules lists every rule with its class; and a -# validator's refusal names the rule it broke. A shape rule is enforced by -# this schema's own keywords. A reference rule is a cross-reference that no -# JSON Schema keyword can state, so a validator enforces it. -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "opendox-snapshot.schema.yaml", - "title": "openDox neutral snapshot", - "contract_schema_version": 1, - "description": "openDox's own snapshot: what its neutral generator writes over a plain repository, and what its views read, with no consumer installed (plan 034 T053, on R1Q11 (a) and R1Q12 (a): opensoft/openxFactory issue 656, comment 5850003126). openXdox's governed generator keeps its own contract, openXdox-spec's ideation-dashboard-snapshot, which this schema leaves unchanged; a contributed generator declares which of the two kinds it writes (T052). The file's comment header, the section descriptions and the x-rules catalog say the rest.", - "x-rule": "envelope-keys", - "type": "object", - "required": [ - "schema_version", - "kind", - "repository", - "generation", - "documents", - "clusters", - "possibles", - "staged_topics", - "changes" - ], - "properties": { - "schema_version": {"x-rule": "schema-version-is-1", "const": 1}, - "kind": {"x-rule": "kind-is-opendox-snapshot", "const": "opendox-snapshot"}, - "repository": {"x-rule": "repository-is-text", "type": "string", "minLength": 1}, - "generation": {"$ref": "#/$defs/generation"}, - "documents": { - "description": "The source station, and the product's whole document list: every document the corpus adapter lists, in the station its stage names. Every entry carries its stage, and no reader supplies one: the generator writes source for a document that declares no stage.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/document"} - }, - "clusters": { - "description": "The grouping station: the groups that form around topics documents share, each with one edge per member document.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/group"} - }, - "possibles": { - "description": "The candidate station.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/candidate"} - }, - "staged_topics": { - "description": "The selection station.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/selection"} - }, - "changes": { - "description": "The submission station (status active) and the completion station (status archived), which share this one section.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/submission"} - }, - "keyword_index": { - "description": "Optional. The keyword rail's seed. When present, every topic the documents carry has one entry, and each entry counts the documents that carry its keyword.", - "x-rule": "section-is-a-list", - "type": "array", - "items": {"$ref": "#/$defs/keyword_entry"} - } - }, - "$defs": { - "id": {"x-rule": "id-is-text", "type": "string", "minLength": 1}, - "path": { - "description": "A path relative to the repository root, as the corpus adapter lists it.", - "x-rule": "path-is-repo-relative", - "type": "string", - "pattern": "^(?!/)(?![A-Za-z]:)(?![\\s\\S]*[\\\\\\u0000-\\u001f\\u007f-\\u009f])(?!(?:[\\s\\S]*/)?\\.\\.(?:/|$))[\\s\\S]+$" - }, - "topic": { - "x-rule": "topic-is-trimmed-text", - "type": "string", - "pattern": "^(?![\\s\\ufeff])(?![\\s\\S]*[\\s\\ufeff]$)[^\\u0000-\\u001f\\u007f-\\u009f]+$" - }, - "stage_role": { - "description": "The station a document sits in: one of the six station role keys, in spine order, exactly openDox's display_profile.STAGE_ROLES. The set is closed. A declared value outside it is not a declaration: the generator reads that document as a source and writes stage source, so the value never reaches a snapshot.", - "x-rule": "stage-is-a-station-role", - "enum": ["source", "grouping", "candidate", "selection", "submission", "completion"] - }, - "candidate_state": { - "description": "A candidate's state, in openDox's own words for the candidate station (NEUTRAL_DISPLAY's candidate vocabulary). A candidate is unselected until an act selects, declines or replaces it.", - "x-rule": "candidate-state-is-known", - "enum": ["unselected", "selected", "declined", "replaced"] - }, - "submission_status": { - "description": "The station a changes entry sits in: active for submission, archived for completion, as openDox's STAGE_FIELDS declares them.", - "x-rule": "submission-status-is-known", - "enum": ["active", "archived"] - }, - "generation": { - "description": "The generation stamp. source_revision is the determinism anchor: the tree revision the snapshot projects.", - "x-rule": "generation-anchored", - "type": "object", - "required": ["source_revision"], - "properties": { - "source_revision": {"x-rule": "generation-anchored", "type": "string", "minLength": 1}, - "generated_at": { - "x-rule": "generated-at-is-rfc3339", - "type": "string", - "format": "date-time", - "pattern": "^(?![\\s\\S]*[\\u0000-\\u001f\\u007f-\\u009f])(?!0000)(?:[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)[Tt](?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" - }, - "generator_version": {"x-rule": "generation-anchored", "type": "string", "minLength": 1} - } - }, - "document": { - "description": "One document the corpus adapter lists. title and summary are the default adapter's small neutral field set (R1Q13 (a)). Neither is required, and the generator writes null for one the document does not give. topics, which every entry carries, are the topics the generator assigns: the ones the document declares, or the ones its topic rule derives when it declares none, and an empty list when there are none.", - "x-rule": "document-keys", - "type": "object", - "required": ["id", "path", "stage", "topics"], - "properties": { - "id": {"$ref": "#/$defs/id"}, - "path": {"$ref": "#/$defs/path"}, - "stage": {"$ref": "#/$defs/stage_role"}, - "title": { - "x-rule": "title-and-summary-are-text", - "type": ["string", "null"], - "minLength": 1 - }, - "summary": { - "x-rule": "title-and-summary-are-text", - "type": ["string", "null"], - "minLength": 1 - }, - "topics": { - "x-rule": "topics-are-unique", - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/topic"} - } - } - }, - "group": { - "description": "One group in the grouping station. document_edges holds one edge per member document, naming the topics that matched; the funnel draws them.", - "x-rule": "group-keys", - "type": "object", - "required": ["id", "name", "topics", "document_edges"], - "properties": { - "id": {"$ref": "#/$defs/id"}, - "name": {"x-rule": "group-keys", "type": "string", "minLength": 1}, - "topics": { - "x-rule": "group-has-a-topic", - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/topic"} - }, - "document_edges": { - "x-rule": "group-keys", - "type": "array", - "items": {"$ref": "#/$defs/edge"} - } - } - }, - "edge": { - "x-rule": "edge-keys", - "type": "object", - "required": ["document", "matched_topics"], - "properties": { - "document": {"$ref": "#/$defs/id"}, - "matched_topics": { - "x-rule": "edge-keys", - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": {"$ref": "#/$defs/topic"} - } - } - }, - "candidate": { - "description": "One candidate in the candidate station. claiming_clusters holds the groups that claim it; pick holds the selection a selected candidate went to.", - "x-rule": "candidate-keys", - "type": "object", - "required": ["id", "title", "state"], - "properties": { - "id": {"$ref": "#/$defs/id"}, - "title": {"x-rule": "candidate-keys", "type": "string", "minLength": 1}, - "claim": {"x-rule": "candidate-keys", "type": "string", "minLength": 1}, - "state": {"$ref": "#/$defs/candidate_state"}, - "claiming_clusters": { - "x-rule": "candidate-keys", - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/id"} - }, - "pick": { - "x-rule": "selected-candidate-has-pick", - "type": "object", - "required": ["staging_id"], - "properties": {"staging_id": {"$ref": "#/$defs/id"}} - }, - "reason": {"x-rule": "closed-candidate-has-reason", "type": "string", "minLength": 1} - }, - "allOf": [ - { - "if": {"required": ["state"], "properties": {"state": {"const": "selected"}}}, - "then": {"x-rule": "selected-candidate-has-pick", "required": ["pick"]} - }, - { - "if": {"required": ["state"], "properties": {"state": {"enum": ["declined", "replaced"]}}}, - "then": {"x-rule": "closed-candidate-has-reason", "required": ["reason"]} - } - ] - }, - "selection": { - "description": "One selection in the selection station. files lists what it is made of, and target_change names the changes entry it went on to.", - "x-rule": "selection-keys", - "type": "object", - "required": ["staging_id"], - "properties": { - "staging_id": {"$ref": "#/$defs/id"}, - "files": { - "x-rule": "selection-keys", - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/path"} - }, - "target_change": {"$ref": "#/$defs/id"} - } - }, - "submission": { - "description": "One entry of the submission or the completion station. files lists what it is made of, as a selection's files do, and the tile opens onto them.", - "x-rule": "submission-keys", - "type": "object", - "required": ["id", "status"], - "properties": { - "id": {"$ref": "#/$defs/id"}, - "status": {"$ref": "#/$defs/submission_status"}, - "files": { - "x-rule": "submission-keys", - "type": "array", - "uniqueItems": true, - "items": {"$ref": "#/$defs/path"} - } - } - }, - "keyword_entry": { - "description": "declared_doc_count counts the documents whose topics carry the keyword.", - "x-rule": "keyword-entry-keys", - "type": "object", - "required": ["keyword", "declared_doc_count"], - "properties": { - "keyword": {"$ref": "#/$defs/topic"}, - "declared_doc_count": {"x-rule": "keyword-entry-keys", "type": "integer", "minimum": 0} - } - } - }, - "x-rules": [ - { - "id": "envelope-keys", - "class": "shape", - "says": "A snapshot is an object carrying schema_version, kind, repository, generation and the five station sections: documents, clusters, possibles, staged_topics and changes." - }, - {"id": "schema-version-is-1", "class": "shape", "says": "schema_version is 1."}, - { - "id": "kind-is-opendox-snapshot", - "class": "shape", - "says": "kind is opendox-snapshot. The governed generator's ideation-dashboard-snapshot is a different contract, and this schema refuses it." - }, - { - "id": "repository-is-text", - "class": "shape", - "says": "repository, the canonical id of the repository the snapshot projects, is non-empty text." - }, - { - "id": "generation-anchored", - "class": "shape", - "says": "generation is an object carrying source_revision, the revision of the tree the snapshot projects, as non-empty text; generator_version, when present, is non-empty text too." - }, - { - "id": "generated-at-is-rfc3339", - "class": "shape", - "says": "generation.generated_at, when present, is an RFC 3339 date-time on a day the calendar has (the pattern knows each month's length and the leap years), with no control character. It is narrower than RFC 3339 in two places. Its year is never 0000, which Python's datetime cannot hold. Its seconds run from 00 to 59 and are never a leap second's 60, which a git commit date cannot hold and neither Python's datetime nor a browser's Date can read. jsonschema's date-time checker refuses both. It is fixed by the source revision, never read from the clock." - }, - { - "id": "section-is-a-list", - "class": "shape", - "says": "Each section is a list: documents, clusters, possibles, staged_topics, changes and, when present, keyword_index. A station with nothing in it is an empty list." - }, - { - "id": "id-is-text", - "class": "shape", - "says": "Every entry id, and every reference to one, is non-empty text." - }, - { - "id": "document-keys", - "class": "shape", - "says": "A document is an object carrying id, path, stage and topics." - }, - { - "id": "path-is-repo-relative", - "class": "shape", - "says": "A path is relative to the repository root: it does not start with a slash or with a drive letter and a colon (C:/x or C:x, which Windows joins onto a root as a path outside it), holds no backslash and no control character (U+0000 to U+001F, U+007F to U+009F), and has no .. segment." - }, - { - "id": "stage-is-a-station-role", - "class": "shape", - "says": "A document's stage is one of the six station role keys: source, grouping, candidate, selection, submission, completion." - }, - { - "id": "title-and-summary-are-text", - "class": "shape", - "says": "A document's title and summary are each non-empty text, or null." - }, - { - "id": "topics-are-unique", - "class": "shape", - "says": "A document's topics are a list that names each topic once." - }, - { - "id": "topic-is-trimmed-text", - "class": "shape", - "says": "A topic is non-empty text with no leading or trailing whitespace and no control character (U+0000 to U+001F, U+007F to U+009F). Whitespace is what both Python and a browser count as whitespace, U+FEFF included, so both refuse the same topics." - }, - { - "id": "group-keys", - "class": "shape", - "says": "A group (a clusters entry) is an object carrying id, name, topics and document_edges. Its name is non-empty text, and its edges are a list." - }, - { - "id": "group-has-a-topic", - "class": "shape", - "says": "A group's topics name at least one topic, each once: a group forms around topics that its documents share." - }, - { - "id": "edge-keys", - "class": "shape", - "says": "A group's document edge is an object that names the document and at least one matched topic, each once." - }, - { - "id": "candidate-keys", - "class": "shape", - "says": "A candidate (a possibles entry) is an object carrying id, title and state. Its title, and its claim when present, are non-empty text; claiming_clusters, when present, names each group once." - }, - { - "id": "candidate-state-is-known", - "class": "shape", - "says": "A candidate's state is one of unselected, selected, declined and replaced." - }, - { - "id": "selected-candidate-has-pick", - "class": "shape", - "says": "A selected candidate carries pick, an object whose staging_id names the selection it went to." - }, - { - "id": "closed-candidate-has-reason", - "class": "shape", - "says": "A declined or replaced candidate carries reason, non-empty text that says why." - }, - { - "id": "selection-keys", - "class": "shape", - "says": "A selection (a staged_topics entry) is an object carrying staging_id; files, when present, lists repository-relative paths, each once." - }, - { - "id": "submission-keys", - "class": "shape", - "says": "A changes entry, a submission or a completed item, is an object carrying id and status; files, when present, lists repository-relative paths, each once." - }, - { - "id": "submission-status-is-known", - "class": "shape", - "says": "A changes entry's status is active (the submission station) or archived (the completion station)." - }, - { - "id": "keyword-entry-keys", - "class": "shape", - "says": "A keyword_index entry is an object carrying keyword and declared_doc_count, a whole number no less than 0." - }, - { - "id": "ids-are-unique", - "class": "reference", - "says": "Within each section, entry ids are unique: documents, clusters, possibles and changes by id, and staged_topics by staging_id." - }, - { - "id": "edge-names-a-document", - "class": "reference", - "says": "Every group edge names a document in documents." - }, - { - "id": "one-edge-per-document", - "class": "reference", - "says": "Within one group, the edges name each document once: one edge per member document. A document may feed several groups." - }, - { - "id": "candidate-names-a-group", - "class": "reference", - "says": "Every group that a candidate's claiming_clusters names is in clusters." - }, - { - "id": "pick-names-a-selection", - "class": "reference", - "says": "A candidate's pick.staging_id names a selection in staged_topics." - }, - { - "id": "target-names-a-submission", - "class": "reference", - "says": "A selection's target_change names an entry in changes." - }, - { - "id": "keyword-index-matches-topics", - "class": "reference", - "says": "keyword_index, when present, agrees with the documents: every topic a document carries has an entry, no keyword has two, and each entry's declared_doc_count is the number of documents that carry its keyword, 0 for a keyword that none carries." - } - ] -} diff --git a/tests/test_neutral_projection.py b/tests/test_neutral_projection.py index c3614a7a..80fed705 100644 --- a/tests/test_neutral_projection.py +++ b/tests/test_neutral_projection.py @@ -15,11 +15,13 @@ seam since T055, with openDox's own generator where no host registered one, and T056 and T063 quote it. -THE SCHEMA. `tests/fixtures/opendox-snapshot.schema.yaml` is openDox-spec's -neutral snapshot contract, copied byte for byte from openDox-spec#16 at -`cd49eb25` (T053), and held here to that file's sha256. T057 ships the packaged -copy, and that copy replaces this one when it lands. The evaluator below is a -port of openDox-spec's own (`tests/test_opendox_snapshot_contract.py` there): +THE SCHEMA is openDox-spec's neutral snapshot contract (T053), read from the +product's PACKAGED copy (`opendox.contracts`, T057) through +`contracts.verified_bytes()`, which proves the bytes against `copies.yaml`'s +recorded digest before a byte is parsed. T058 made that swap: T054 held a copy +of its own at `tests/fixtures/opendox-snapshot.schema.yaml`, pinned by a +digest in this file, and the tree now carries one copy, the product's. The +evaluator below is a port of openDox-spec's own (`tests/test_opendox_snapshot_contract.py` there): the JSON Schema keywords the contract uses, as draft 2020-12 defines them, and its seven reference rules. The leg's test extra installs no `jsonschema`, so none is imported. @@ -77,6 +79,7 @@ from opendox import ( authoring, + contracts, corpus_adapter, default_generator, display_profile, @@ -92,14 +95,12 @@ FIXTURES = ROOT / "tests" / "fixtures" PLAIN = FIXTURES / "plain-documents" # T050, openDox-code#53 MALFORMED = FIXTURES / "malformed" # T051, openDox-code#56 -SCHEMA_PATH = FIXTURES / "opendox-snapshot.schema.yaml" DISPLAY_JS = SRC / "opendox" / "web" / "views" / "display.js" WHEEL_MODEL_JS = SRC / "opendox" / "web" / "views" / "wheel-model.js" NODE = shutil.which("node") -#: The sha256 of `contracts/schemas/opendox-snapshot.schema.yaml` at -#: openDox-spec#16's head, `cd49eb25` (T053; its PR records the same digest). -SCHEMA_SHA256 = "f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a" +#: The packaged copy of the neutral contract that openDox's validator reads. +SCHEMA_COPY = "opendox-snapshot" #: The four packages a neutral openDox must import without (#1144's F2.1). SIBLINGS = ("openxdox", "ideation_dashboard", "doc_health", @@ -235,16 +236,17 @@ def _no_constants(name: str) -> Any: raise ValueError(f"{name} is not JSON") -def _read_schema(path: Path) -> Any: +def _read_schema(text: str) -> Any: """The schema file's body: one JSON object after its `#` comment lines.""" - lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + lines = text.splitlines(keepends=True) while lines and (lines[0].startswith("#") or not lines[0].strip()): lines.pop(0) return json.loads("".join(lines), object_pairs_hook=_no_duplicate_keys, parse_constant=_no_constants) -SCHEMA = _read_schema(SCHEMA_PATH) +SCHEMA_BYTES = contracts.verified_bytes(SCHEMA_COPY) +SCHEMA = _read_schema(SCHEMA_BYTES.decode("utf-8")) class Violation(NamedTuple): @@ -486,13 +488,16 @@ def violations(snapshot: Any) -> list[Violation]: def test_the_schema_copy_is_openDox_specs_contract_and_matches_the_product() -> None: - """The copy is T053's file, and the product's own declarations are the - contract's closed values (T053's writer asked for this cross-check).""" - digest = hashlib.sha256(SCHEMA_PATH.read_bytes()).hexdigest() - assert digest == SCHEMA_SHA256, ( - f"tests/fixtures/opendox-snapshot.schema.yaml is {digest}, not " - "openDox-spec#16's contract at cd49eb25. Copy the spec leg's file " - "again and update SCHEMA_SHA256 with it, in one commit") + """The copy is T053's file, the one the product ships and its validator + reads, and the product's own declarations are the contract's closed values + (T053's writer asked for this cross-check).""" + pinned = contracts.record().copy(SCHEMA_COPY) + assert pinned.path == "contracts/schemas/opendox-snapshot.schema.yaml" + assert hashlib.sha256(SCHEMA_BYTES).hexdigest() == pinned.sha256 + assert SCHEMA == contracts.load(SCHEMA_COPY), ( + "this file's strict JSON read and the product's YAML read are one contract") + assert not (FIXTURES / "opendox-snapshot.schema.yaml").exists(), ( + "T058 replaced T054's copy with the packaged one: the tree carries one") defs = SCHEMA["$defs"] assert SCHEMA["properties"]["kind"]["const"] == gs.NEUTRAL_SNAPSHOT_KIND assert SCHEMA["properties"]["schema_version"]["const"] == projection.SCHEMA_VERSION diff --git a/tests/test_post_render_validator.py b/tests/test_post_render_validator.py new file mode 100644 index 00000000..f2772edf --- /dev/null +++ b/tests/test_post_render_validator.py @@ -0,0 +1,520 @@ +"""The post-render validator in the generate verbs: plan 034's T058 (#1144's +7.2, in part). + +T058 replaces the validator lookup's stand-in with openDox's own validator +(`opendox.validator`, T057), behind the lookup's protocol, one adapter per own +kind (`default_projection.VALIDATORS`). So the generate verbs validate the +neutral snapshot they write against T053's schema, read from T057's packaged +copy. `--strict` makes a validator that cannot run fatal, and `--no-validate` +skips validation. This file holds: + +1. F7.2, #1144's Group 7 falsifier, through `python -m opendox.cli generate + --strict` with neither sibling importable (`tests/standalone_child.py`). + The good fixture exits 0. The malformed one exits non-zero, naming + `EXPECTED_RULE` on standard error, with no `No such file or directory`. + `generate-and-open` gives the same verdicts, and serves nothing it refused. +2. `--no-validate` skips the check, and the malformed snapshot stands. +3. The adapter's three outcomes: `VALIDATED`, `NOT_CONFORMANT` naming each + rule, and `VALIDATOR_UNAVAILABLE` for `ValidatorUnavailable` and for a + document it could not read. A document that cannot be read as JSON (or + as YAML) breaks `SYNTAX_RULE`, and a readable one holding a number that + cannot be read as written breaks `NUMBER_RULE`. `strict` and + `search_from` change nothing. +4. The workbench manifest, read as YAML, with the two validator rules its + schema leaves to the validator, and `workbench.save(validate=True)` over + them. + +Every case starts with nothing registered at the four projection seams and +puts back what it found. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest +import yaml + +from opendox import contracts +from opendox import default_projection +from opendox import projection_seams as ps +from opendox import validator as own +from opendox import workbench +from opendox.boundary import OutputBoundary +from standalone_child import Child, fresh_repository, run_module + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "tests" / "fixtures" +PLAIN = FIXTURES / "plain-documents" # T050 +MALFORMED = FIXTURES / "malformed" # T051 +EXPECTED_RULE = (MALFORMED / "EXPECTED_RULE").read_text(encoding="utf-8").strip() +NEUTRAL = "opendox-snapshot" +NOW = "2026-09-27T12:00:00Z" + + +_SINGLE_SEAMS = (ps.registry, ps.corpus_root, ps.writer) + + +@pytest.fixture(autouse=True) +def _isolated_seams(): + """Nothing registered at the four projection seams, and each is PUT BACK + whole, records included, since two cases call `register_defaults()`.""" + single = [(seam._registered, seam._is_default, seam._default_read) + for seam in _SINGLE_SEAMS] + kinds = (dict(ps.validators._registered), set(ps.validators._default_read)) + for seam in _SINGLE_SEAMS: + seam.unregister() + ps.validators.unregister() + yield + for seam, held in zip(_SINGLE_SEAMS, single): + seam._registered, seam._is_default, seam._default_read = held + ps.validators._registered, ps.validators._default_read = kinds + + +def _generate(tmp_path: Path, fixture: Path, *extra: str) -> tuple[Child, int, Path]: + """`python -m opendox.cli generate` over a fresh copy of `fixture`.""" + repo = fresh_repository(fixture, tmp_path) + out = tmp_path / "out" / "snapshot.json" + child, status = run_module(tmp_path, "opendox.cli", "generate", + "--repo-root", str(repo), "--repository", "fixture", + "--output", str(out), *extra) + return child, status, out + + +def _snapshot(tmp_path: Path, fixture: Path) -> Path: + """A snapshot of `fixture`, written by the verb with validation skipped.""" + child, status, out = _generate(tmp_path, fixture, "--no-validate") + assert status == 0, child.stderr_text() + return out + + +def _file(tmp_path: Path, name: str, data: str | bytes) -> Path: + path = tmp_path / name + path.write_bytes(data if isinstance(data, bytes) else data.encode("utf-8")) + return path + + +# --------------------------------------------------------------------------- +# 1 — F7.2, through the module +# --------------------------------------------------------------------------- + +def test_the_expected_rule_is_one_rule_of_the_neutral_contract() -> None: + rules = {rule["id"] for rule in contracts.load(NEUTRAL)["x-rules"]} + assert EXPECTED_RULE and EXPECTED_RULE in rules, EXPECTED_RULE + + +def test_F7_2_the_good_fixture_validates_under_strict(tmp_path) -> None: + child, status, out = _generate(tmp_path, PLAIN, "--strict") + assert status == 0, child.stderr_text() + assert child.refused() == [], child.refused() + assert (" validation: opendox-snapshot: 0 violations, by opendox.validator, " + "over its packaged copy opendox-snapshot") in child.stdout_text() + assert "validation SKIPPED" not in child.stderr_text() + assert json.loads(out.read_text(encoding="utf-8"))["kind"] == NEUTRAL + + +def test_F7_2_the_malformed_fixture_is_refused_for_its_rule(tmp_path) -> None: + """The refusal carries the fixture's own rule identifier, and it is not a + refusal for a missing path.""" + child, status, _out = _generate(tmp_path, MALFORMED, "--strict") + err = child.stderr_text() + assert status != 0, "a malformed corpus validated" + assert child.refused() == [], child.refused() + assert EXPECTED_RULE in err + assert f"[{EXPECTED_RULE}] /documents/1/title:" in err, err + assert "No such file or directory" not in err + assert "the pinned validator REJECTED" in err and "This is the SNAPSHOT" in err + assert "1 violation(s) of the opendox-snapshot contract" in err + + +def test_F7_2_the_malformed_fixture_is_refused_without_strict_too(tmp_path) -> None: + """A snapshot the validator REJECTS fails the verb whatever `--strict` + says: `--strict` hardens only a validator that could not run.""" + child, status, _out = _generate(tmp_path, MALFORMED) + assert status == 1 + assert f"[{EXPECTED_RULE}] /documents/1/title:" in child.stderr_text() + + +@pytest.mark.parametrize("fixture,expected", [(PLAIN, 0), (MALFORMED, 1)]) +def test_generate_and_open_gives_the_same_verdicts(tmp_path, fixture, expected) -> None: + """`generate-and-open --no-open --no-serve --strict`: the good fixture + builds its server and prints its URL, and the malformed one stops before + a server is built, naming the rule.""" + repo = fresh_repository(fixture, tmp_path) + child, status = run_module(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--no-serve", "--strict", + "--run-dir", str(tmp_path / "run")) + assert status == expected, child.stderr_text() + assert child.refused() == [] + served = re.search(r"^http://127\.0\.0\.1:[0-9]+/index\.html$", + child.stdout_text(), re.M) + if expected == 0: + assert served, child.stdout_text() + else: + assert served is None and "serving" not in child.stdout_text() + assert f"[{EXPECTED_RULE}]" in child.stderr_text() + + +# --------------------------------------------------------------------------- +# 2 — `--no-validate` skips validation +# --------------------------------------------------------------------------- + +def test_no_validate_skips_validation_and_the_snapshot_stands(tmp_path) -> None: + child, status, out = _generate(tmp_path, MALFORMED, "--no-validate", "--strict") + assert status == 0, child.stderr_text() + assert " validation skipped (--no-validate)" in child.stdout_text() + assert "REJECTED" not in child.stderr_text() + assert out.is_file() + + +# --------------------------------------------------------------------------- +# 3 — the adapter's three outcomes +# --------------------------------------------------------------------------- + +def test_the_entry_points_register_openDoxs_own_validator_for_each_own_kind() -> None: + ps.register_defaults() + for kind in default_projection.OWN_KINDS: + assert ps.validators.for_kind(kind) is default_projection.VALIDATORS[kind] + assert set(default_projection.OWN_KINDS) <= set(own.KINDS) + + +def test_a_conformant_snapshot_is_validated(tmp_path) -> None: + result = default_projection.VALIDATORS[NEUTRAL].validate(_snapshot(tmp_path, PLAIN)) + assert (result.ok, result.returncode, result.outcome) == (True, 0, ps.VALIDATED) + digest = contracts.record().copy(NEUTRAL).sha256 + assert result.validator == (f"opendox.validator, over its packaged copy " + f"opendox-snapshot (sha256 {digest[:12]})") + assert result.summary().startswith("opendox-snapshot: 0 violations, by opendox.validator") + + +def test_a_malformed_snapshot_is_not_conformant_and_each_rule_is_named(tmp_path) -> None: + result = default_projection.VALIDATORS[NEUTRAL].validate(_snapshot(tmp_path, MALFORMED)) + assert (result.ok, result.returncode, result.outcome) == (False, 1, ps.NOT_CONFORMANT) + lines = result.stdout.splitlines() + assert lines[0].startswith(f"[{EXPECTED_RULE}] /documents/1/title: ") + assert lines[-1].startswith("1 violation(s) of the opendox-snapshot contract") + assert len(lines) == 2 and result.stderr == "" + + +def test_the_verdict_is_openDoxs_validators_own(tmp_path) -> None: + """The adapter adds nothing to a snapshot's verdict and drops nothing: its + lines are `opendox.validator.report()` over the same document.""" + path = _snapshot(tmp_path, MALFORMED) + result = default_projection.VALIDATORS[NEUTRAL].validate(path) + document = json.loads(path.read_text(encoding="utf-8")) + assert result.stdout.splitlines()[:-1] == own.report(own.validate(document, kind=NEUTRAL)) + + +@pytest.mark.parametrize("text,why", [ + ('{"kind": "opendox-snapshot", "schema_version": NaN}', "NaN is not JSON"), + ('{"kind": "opendox-snapshot", "kind": "opendox-snapshot"}', "given twice"), + ('{"kind": "opendox-snapshot"', "Expecting"), + (b'{"kind": "\xff"}', "codec"), + ("[" * 100_000 + "]" * 100_000, ""), +]) +def test_a_snapshot_that_is_not_json_breaks_the_syntax_rule(tmp_path, text, why) -> None: + result = default_projection.VALIDATORS[NEUTRAL].validate(_file(tmp_path, "s.json", text)) + assert (result.ok, result.returncode, result.outcome) == (False, 1, ps.NOT_CONFORMANT) + first = result.stdout.splitlines()[0] + assert first.startswith(f"[{default_projection.SYNTAX_RULE}] : the document " + "cannot be read as JSON, which is how a document of kind " + "'opendox-snapshot' is written: "), first + assert why in first + + +@pytest.mark.parametrize("text,why", [ + ('{"kind": "opendox-snapshot", "extra": 1e999}', "1e999 reads as inf"), + ('{"kind": "opendox-snapshot", "extra": [-1E+400]}', "-1E+400 reads as -inf"), + ('{"kind": "opendox-snapshot", "schema_version": 1.0000000000000001}', + "1.0000000000000001 cannot be read as written: the precision JSON readers " + "share holds it as 1.0"), + ('{"kind": "opendox-snapshot", "extra": 1.5e-400}', "1.5e-400 cannot be read as written"), +]) +def test_a_snapshot_number_that_cannot_be_read_as_written_breaks_the_number_rule( + tmp_path, text, why) -> None: + """Valid JSON, but a number no float verdict would judge honestly. It is + reported under its own rule, not as a syntax error (Copilot at + openDox-code#68 c7768ed5, r4146428769).""" + result = default_projection.VALIDATORS[NEUTRAL].validate(_file(tmp_path, "s.json", text)) + assert (result.ok, result.returncode, result.outcome) == (False, 1, ps.NOT_CONFORMANT) + first = result.stdout.splitlines()[0] + assert first.startswith(f"[{default_projection.NUMBER_RULE}] : the document " + "reads as JSON, but holds a number that cannot be read as " + "written, so no verdict over it would be a verdict over the " + "document: "), first + assert why in first + assert default_projection.SYNTAX_RULE not in result.stdout + + +@pytest.mark.parametrize("literal", ["0.1", "2.50", "1E2", "-0.0", "0.30000000000000004", + "1e-300", "100000000000000000000001"]) +def test_a_number_read_as_written_is_the_contracts_to_judge(tmp_path, literal) -> None: + """The control for the two cases above: a number the shared precision + holds as written is read, and judged by the contract, not the syntax or + number rules. So is any integer, which Python reads exactly.""" + result = default_projection.VALIDATORS[NEUTRAL].validate(_file( + tmp_path, "s.json", '{"kind": "opendox-snapshot", "extra": ' + literal + "}")) + assert f"[{default_projection.SYNTAX_RULE}]" not in result.stdout, result.stdout + assert f"[{default_projection.NUMBER_RULE}]" not in result.stdout, result.stdout + assert "[envelope-keys] :" in result.stdout + + +@pytest.mark.parametrize("value,why", [ + ("1.0000000000000001", "cannot be read as written"), + (".inf", "reads as inf"), + ("-.Inf", "reads as -inf"), + (".nan", "reads as nan"), + ("0:1.0000000000000001", "cannot be proved as written"), + ("190:20:30.15", "cannot be proved as written"), +]) +def test_a_manifest_number_is_read_as_the_snapshots_are(tmp_path, value, why) -> None: + document = _recipe_set().render().replace("schema_version: 1\n", + f"schema_version: {value}\n", 1) + assert f"schema_version: {value}\n" in document + result = default_projection.VALIDATORS[workbench.KIND].validate( + _file(tmp_path, "set.workbench.yaml", document)) + first = result.stdout.splitlines()[0] + assert first.startswith(f"[{default_projection.NUMBER_RULE}] : the document " + "reads as YAML, but holds a number that cannot be read as " + "written"), first + assert why in first + assert default_projection.SYNTAX_RULE not in result.stdout + + +def test_a_document_that_cannot_be_read_is_unavailable_not_a_verdict(tmp_path) -> None: + result = default_projection.VALIDATORS[NEUTRAL].validate(tmp_path) + assert (result.ok, result.outcome, result.available) == ( + False, ps.VALIDATOR_UNAVAILABLE, False) + assert result.validator == "opendox.validator" + assert "the document could not be read" in result.unavailable_reason + + +@pytest.mark.parametrize("failure", [ + contracts.CopyRefused("the packaged copy differs from its digest"), + own.SchemaNotEvaluable("the packaged copy uses a keyword this module does not evaluate"), +]) +def test_validator_unavailable_is_unavailable_with_its_reason(tmp_path, monkeypatch, + failure) -> None: + path = _snapshot(tmp_path, PLAIN) + + def refused(kind): + if isinstance(failure, contracts.CopyRefused): + raise own.ValidatorUnavailable(str(failure)) from failure + raise failure + + monkeypatch.setattr(own, "validator_for", refused) + result = default_projection.VALIDATORS[NEUTRAL].validate(path) + assert (result.ok, result.returncode, result.outcome) == ( + False, -1, ps.VALIDATOR_UNAVAILABLE) + assert result.validator == "opendox.validator" + assert result.unavailable_reason == str(failure) + + +def test_a_packaged_copy_that_fails_its_identity_is_unavailable(tmp_path, monkeypatch) -> None: + """End to end through the identity check: a copy whose bytes are not the + recorded ones is refused by `opendox.contracts`, so nothing is judged.""" + path = _snapshot(tmp_path, PLAIN) + real = contracts._read_package_file + + def tampered(name): + data = real(name) + return data + b"\n" if name.endswith("opendox-snapshot.schema.yaml") else data + + monkeypatch.setattr(contracts, "_read_package_file", tampered) + result = default_projection.VALIDATORS[NEUTRAL].validate(path) + assert result.outcome == ps.VALIDATOR_UNAVAILABLE + assert "is not the file the record pins" in result.unavailable_reason + + +def test_a_packaged_file_that_cannot_be_read_is_unavailable_not_a_traceback( + tmp_path, monkeypatch, capsys) -> None: + """A packaged record or copy that is present but unreadable raises an + `OSError` below `opendox.contracts`, which converts only a missing one. + The adapter reports it as unavailable, and the verb warns, or fails under + `--strict`, in its own words.""" + import argparse + + from opendox import cli + + path = _snapshot(tmp_path, PLAIN) + + def unreadable(name): + raise PermissionError(13, "Permission denied", name) + + monkeypatch.setattr(contracts, "_read_package_file", unreadable) + result = default_projection.VALIDATORS[NEUTRAL].validate(path) + assert (result.ok, result.outcome) == (False, ps.VALIDATOR_UNAVAILABLE) + assert result.unavailable_reason == ( + "openDox's packaged contracts could not be read (PermissionError: " + "Permission denied), so nothing was judged") + ps.register_defaults() + args = argparse.Namespace(repo_root=str(tmp_path), no_validate=False, strict=True) + assert cli._validate(path, args) == 1 + err = capsys.readouterr().err + assert "could not run: openDox's packaged contracts could not be read" in err + assert "--strict was given" in err + + +def test_the_rules_read_a_long_list_in_linear_time() -> None: + """None of the three lists is bounded by the schema, so a rule's reading + of one must not be quadratic: 50,000 distinct names, and a repeat of + each, are read in well under the seconds a quadratic scan would take.""" + import time + + names = [f"keyword-{index}" for index in range(50_000)] + started = time.monotonic() + read = default_projection._names(names + names + [7, None]) + elapsed = time.monotonic() - started + assert read == names + assert elapsed < 5, f"{elapsed:.1f}s to read 100,002 entries" + + +def test_a_kind_given_twice_chooses_no_validator_and_fails(tmp_path, capsys) -> None: + """The verb chooses a validator by the written snapshot's `kind`. A key + given twice leaves the document with no one meaning, so no validator is + chosen for it, and the verb fails whatever `--strict` says. With the last + `kind` winning, it had chosen `unknown`, found no validator, and exited 0.""" + import argparse + + from opendox import cli + + ps.register_defaults() + written = _file(tmp_path, "snapshot.json", + '{"kind": "opendox-snapshot", "schema_version": 1, "kind": "unknown"}') + args = argparse.Namespace(repo_root=str(tmp_path), no_validate=False, strict=False) + assert cli._validate(written, args) == 1 + err = capsys.readouterr().err + assert (f"validation FAILED — {written} gives the key 'kind' twice in one " + "object, so it has no one meaning") in err + assert "validation SKIPPED" not in err + + +def test_strict_and_search_from_change_nothing(tmp_path) -> None: + validator = default_projection.VALIDATORS[NEUTRAL] + for fixture in (PLAIN, MALFORMED): + path = _snapshot(tmp_path / fixture.name, fixture) + plain = validator.validate(path) + assert validator.validate(path, strict=True, + search_from=(tmp_path, Path("/nonexistent"))) == plain + + +def test_each_validator_reads_as_its_own_kind(tmp_path) -> None: + """A validator is bound to the kind it is registered under, so a snapshot + handed to the workbench manifest's validator is judged as a manifest.""" + result = default_projection.VALIDATORS[workbench.KIND].validate( + _snapshot(tmp_path, PLAIN)) + assert result.outcome == ps.NOT_CONFORMANT + assert "[const] /kind: " in result.stdout, result.stdout + + +# --------------------------------------------------------------------------- +# 4 — the workbench manifest, and its two validator rules +# --------------------------------------------------------------------------- + +def _recipe_set(**recipe) -> workbench.Workbench: + w = workbench.Workbench.create( + "fixture", "a recipe set", seed=workbench.SEED_RECIPE, + recipe={"checked": ["compost", "soil"], "pinned": ["soil"], **recipe}, now=NOW) + w.add_member("notes/in.md", sorted(workbench.VIA_VALUES)[0], now=NOW, + reason="a human chose it") + w.exclude("notes/out.md", "not about the shed", now=NOW) + return w + + +def _manifest(tmp_path: Path, document: dict) -> Path: + return _file(tmp_path, "set.workbench.yaml", yaml.safe_dump(document, sort_keys=False)) + + +def test_a_manifest_openDoxs_workbench_writes_is_validated(tmp_path) -> None: + w = _recipe_set() + result = default_projection.VALIDATORS[workbench.KIND].validate( + _file(tmp_path, "set.workbench.yaml", w.render())) + assert (result.ok, result.outcome) == (True, ps.VALIDATED), result.stdout + assert result.summary().startswith("ideation-workbench: 0 violations") + + +def test_a_pinned_keyword_that_is_not_checked_breaks_its_rule(tmp_path) -> None: + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["pinned"] = ["soil", "worms", "worms"] + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, document)) + assert result.outcome == ps.NOT_CONFORMANT + assert result.stdout.splitlines() == [ + "[workbench-pinned-not-checked] /recipe/pinned: pinned keyword(s) ['worms'] " + "are not in checked: every pinned keyword MUST also be checked", + "1 violation(s) of the ideation-workbench contract, by " + f"{result.validator}"] + + +def test_a_rules_detail_quotes_a_few_names_and_counts_the_rest(tmp_path) -> None: + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["pinned"] = [f"k{index:02d}" for index in range(25)] + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, document)) + first = result.stdout.splitlines()[0] + assert first.startswith("[workbench-pinned-not-checked] /recipe/pinned: pinned " + "keyword(s) ['k00', 'k01', ") + assert "'k09', and 15 more] are not in checked" in first and "'k10'" not in first + + +def test_a_rules_detail_cuts_a_long_name(tmp_path) -> None: + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["pinned"] = ["w" * 10_000] + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, document)) + first = result.stdout.splitlines()[0] + assert f"['{'w' * 79}…'] are not in checked" in first + assert len(first) < 300, len(first) + + +def test_a_new_candidate_already_placed_breaks_its_rule(tmp_path) -> None: + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["new_candidates"] = ["notes/fresh.md", "notes/out.md", "notes/in.md"] + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, document)) + assert result.outcome == ps.NOT_CONFORMANT + assert result.stdout.splitlines()[0] == ( + "[workbench-candidate-overlap] /recipe/new_candidates: new_candidates " + "['notes/out.md', 'notes/in.md'] already appear in members or excluded: a " + "new candidate is a document the set has not placed yet") + + +def test_the_two_rules_are_judged_beside_the_schema_and_never_crash(tmp_path) -> None: + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["pinned"] = [["unhashable"], "worms"] + document["recipe"]["new_candidates"] = {"not": "a list"} + document["members"].append("not a member") + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, document)) + rules = [line.split("]")[0][1:] for line in result.stdout.splitlines()[:-1]] + assert "workbench-pinned-not-checked" in rules + assert "workbench-candidate-overlap" not in rules + assert {"type"} <= set(rules), rules + + +def test_a_manifest_that_is_not_yaml_breaks_the_syntax_rule(tmp_path) -> None: + result = default_projection.VALIDATORS[workbench.KIND].validate( + _file(tmp_path, "set.workbench.yaml", "kind: [ideation-workbench\n")) + assert result.outcome == ps.NOT_CONFORMANT + assert result.stdout.startswith( + f"[{default_projection.SYNTAX_RULE}] : the document cannot be read as " + "YAML, which is how a document of kind 'ideation-workbench' is written: ") + + +def test_save_with_validate_keeps_a_valid_manifest_and_unwinds_a_broken_one(tmp_path) -> None: + ps.register_defaults() + boundary = OutputBoundary(tmp_path, [workbench.WORKBENCH_DIR]) + written = workbench.save(_recipe_set(), boundary, validate=True) + assert written.is_file() + broken = _recipe_set() + broken.data["name"] = "a broken set" + broken.data["recipe"]["pinned"] = ["worms"] + with pytest.raises(workbench.ManifestInvalid) as refused: + workbench.save(broken, boundary, validate=True) + assert "[workbench-pinned-not-checked] /recipe/pinned:" in str(refused.value) + assert not (tmp_path / workbench.manifest_relpath("a broken set")).exists() diff --git a/tests/test_projection_seams.py b/tests/test_projection_seams.py index 188a2ddf..1d5c5883 100644 --- a/tests/test_projection_seams.py +++ b/tests/test_projection_seams.py @@ -362,7 +362,7 @@ def test_register_defaults_registers_openDoxs_own_at_each_seam_and_reads_nothing assert ps.corpus_root._registered is default_projection.CORPUS_ROOT assert ps.writer._registered is default_projection.WRITER for kind in default_projection.OWN_KINDS: - assert ps.validators._registered[kind] == (default_projection.VALIDATOR, True) + assert ps.validators._registered[kind] == (default_projection.VALIDATORS[kind], True) for seam in SINGLE_SEAMS.values(): host = _stub(seam) assert seam.register(host) is host, ( @@ -1273,11 +1273,21 @@ def operation(repo_root, repository, *, source_revision=None, generated_at=None) "the verb goes on to the validator registered for the snapshot's kind") -def test_the_validator_stand_in_concludes_nothing_and_names_T057(tmp_path) -> None: - result = default_projection.VALIDATOR.validate(tmp_path / "x.json") - assert result.available is False and result.ok is False - assert result.validator is None and "T057" in result.unavailable_reason - assert default_projection.VALIDATOR.dependency_remedy is None +def test_openDoxs_own_validator_is_bound_to_each_own_kind(tmp_path) -> None: + """T058: the stand-in is gone, and openDox's own validator is registered + per kind. The seam hands a validator only a path, so each is bound to the + kind it is registered under, and reads its document as that kind is + written. It runs no subprocess, so it declares no remedy.""" + validators = default_projection.VALIDATORS + assert tuple(validators) == default_projection.OWN_KINDS + assert {kind: v.kind for kind, v in validators.items()} == { + kind: kind for kind in default_projection.OWN_KINDS} + assert (validators[NEUTRAL].syntax, validators[workbench.KIND].syntax) == ("JSON", "YAML") + assert all(v.dependency_remedy is None for v in validators.values()) + assert not hasattr(default_projection, "OwnValidatorNotBuilt") + assert not hasattr(default_projection, "VALIDATOR") + with pytest.raises(ValueError, match="openDox's own kinds"): + default_projection.OwnValidator("ideation-dashboard-snapshot") def test_a_validation_results_outcome_follows_ok_unless_given() -> None: @@ -1486,20 +1496,44 @@ def test_validation_is_by_the_written_snapshots_kind(tmp_path, capsys) -> None: assert cli._validate(written, _validate_args(tmp_path)) == 0 assert host_validator.calls == [{"path": written, "strict": False, "search_from": (written.parent, tmp_path.resolve())}] - assert ps.validators.for_kind(NEUTRAL) is default_projection.VALIDATOR, ( + assert ps.validators.for_kind(NEUTRAL) is default_projection.VALIDATORS[NEUTRAL], ( "the host's kind took nothing from openDox's own") assert "validation: stand-in: 0 error(s)" in capsys.readouterr().out -def test_openDoxs_own_kind_meets_the_stand_in_and_strict_makes_it_fatal(tmp_path, capsys) -> None: +def test_openDoxs_own_kind_meets_openDoxs_own_validator(tmp_path, capsys) -> None: + """T058: a snapshot of openDox's own kind is judged by openDox's own + validator. This one lacks most of the contract, so it is NOT CONFORMANT: + the verb fails, blames the snapshot, and names each broken rule.""" + ps.register_defaults() + written = _written(tmp_path) + assert cli._validate(written, _validate_args(tmp_path)) == 1 + err = capsys.readouterr().err + assert "REJECTED" in err and "This is the SNAPSHOT" in err + assert "[envelope-keys] : 'documents' is required" in err, err + assert "6 violation(s) of the opendox-snapshot contract, by opendox.validator" in err + assert "validation SKIPPED" not in err + + +def test_openDoxs_own_validator_unavailable_is_skipped_and_strict_makes_it_fatal( + tmp_path, capsys, monkeypatch) -> None: + """T058: `ValidatorUnavailable` (here a packaged copy that fails its + identity check) is VALIDATOR UNAVAILABLE, never a pass. The verb warns and + goes on, and `--strict` makes it fatal.""" + from opendox import contracts + + def refused(copy_id): + raise contracts.CopyRefused(f"the packaged copy {copy_id} differs from its digest") + + monkeypatch.setattr(contracts, "verified_bytes", refused) ps.register_defaults() written = _written(tmp_path) assert cli._validate(written, _validate_args(tmp_path)) == 0 err = capsys.readouterr().err - assert "validation SKIPPED" in err and "'opendox-snapshot'" in err and "T057" in err - assert "the validator registered for kind 'opendox-snapshot' reached no verdict" in err - assert str(written.parent) in err and str(tmp_path.resolve()) in err - assert "the ENVIRONMENT, not the snapshot" in err + assert "validation SKIPPED" in err + assert ("the validator was found (opendox.validator) but could not run: the " + "packaged copy opendox-snapshot differs from its digest") in err + assert "the ENVIRONMENT, not the snapshot" in err and "pip install" not in err assert cli._validate(written, _validate_args(tmp_path, "--strict")) == 1 assert "--strict was given" in capsys.readouterr().err @@ -1625,9 +1659,11 @@ def test_a_manifest_is_validated_by_the_validator_for_its_kind(tmp_path) -> None "search_from": (manifest.resolve().parent,)}] ps.validators.unregister() ps.register_defaults() - unchecked = workbench.validate_manifest(manifest, search_from=tmp_path) - assert not unchecked.ok and unchecked.validator is None - assert "T057" in unchecked.stderr and "validator not found" in unchecked.summary() + judged = workbench.validate_manifest(manifest, search_from=tmp_path) + assert not judged.ok and judged.returncode == 1 + assert str(judged.validator).startswith("opendox.validator, over its packaged copy " + "ideation-workbench") + assert "[required] :" in judged.stdout, judged.stdout ps.validators.unregister() refused = workbench.validate_manifest(manifest) assert not refused.ok and "ideation-workbench" in refused.stderr