diff --git a/src/opendox/contracts/__init__.py b/src/opendox/contracts/__init__.py index e114eb50..91010963 100644 --- a/src/opendox/contracts/__init__.py +++ b/src/opendox/contracts/__init__.py @@ -4,19 +4,27 @@ batch G amends on R1Q11 (a) and R1Q12 (a) (`openxFactory#656` comment `5850003126`): *"openDox's validator validates its spec leg's FOUR kinds ... The code leg carries digest-checked copies of the four, which a test holds to -the spec-leg commit the openDox root pins."* 7.1 also settles how the four +the spec-leg commit the openDox root pins."* 7.1 also settles how the copies reach an install. They ship as PACKAGE DATA, *"so `pip install openDox-code` puts them on disk beside the validator and the assembly root remains their source of truth for editing"*. So a code-leg checkout with no assembly root around it still has them, and so does an install. `opendox.validator` reads -them from here, and so do its doxBench validators +the validator's copies from here, and so do its doxBench validators (`opendox.validator.doxbench_validators`, the default T085 registers). +T005's batch Q adds to batch G's text on R2Q22 (a) (`6003486656`) and N-15 +(`6013547504`): openDox-spec's three health schemas travel as the four do, +and the finding's neutral shape is *"a copy the engine reads and NOT a +validator kind"*, because a finding's `kind` is its family and no `kind` +constant can name the schema. Plan 038's T041 carries that copy here, at the +spec-leg commit T060 pinned (`dox-v1.2`), and `opendox.health_contract` +mirrors it. T047 and T054 add the two file kinds after it. + WHAT IS HERE. -* `schemas/`: the four copies. Each one is byte for byte the spec leg's file - of the same name, `contracts/schemas/.schema.yaml` in - opensoft/openDox-spec. +* `schemas/`: the copies, the validator's four and the finding shape. Each one + is byte for byte the spec leg's file of the same name, + `contracts/schemas/.schema.yaml` in opensoft/openDox-spec. * `copies.yaml`: the record. It names the spec-leg commit the copies were taken at, and each copy's id, spec-leg path and sha256. @@ -29,7 +37,7 @@ recomputed digest can never equal an empty recorded one, so an empty digest is drift, and a file that merely exists proves nothing. -CONSUMED, NOT OWNED. openDox-spec owns these four schemas. The code leg +CONSUMED, NOT OWNED. openDox-spec owns these schemas. The code leg releases none of them, and a copy is changed in the spec leg and then copied here again, never edited here. The copies, the record, and the `commit` it names move together, in one commit. @@ -78,11 +86,13 @@ SCHEMA_DIR = "schemas" #: THE INPUT SET, as a record must hold it: openDox's own spec leg's four -#: schemas (7.1, as T007's batch G amends it). A record that names any other +#: validated schemas (7.1, as T007's batch G amends it), and the finding's +#: neutral shape, which the health engine reads and no validator kind names +#: (batch Q's addendum; N-15; plan 038 T041). A record that names any other #: copy, or leaves one of these out, is refused, like a record naming another #: leg. So no edit to the record lets a copy of `gate-intent` or of the #: possibles register (7.1b) be served, even with its file beside the others. -COPY_IDS = frozenset({"ideation-workbench", "opendox-snapshot", +COPY_IDS = frozenset({"ideation-workbench", "opendox-health-finding", "opendox-snapshot", "xfactory-workbench-chat-turn", "xfactory-workbench-model-catalog"}) _ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") @@ -214,8 +224,9 @@ def record() -> Record: copies.append(PackagedCopy(copy_id, path, digest)) recorded = {copy.id for copy in copies} if recorded != COPY_IDS: - raise _refuse(f"it records {sorted(recorded)}, not openDox's four, " - f"{sorted(COPY_IDS)} (7.1; 7.1b keeps every other schema out)") + raise _refuse(f"it records {sorted(recorded)}, not the copies openDox carries, " + f"{sorted(COPY_IDS)} (7.1, as batches G and Q amend it; 7.1b keeps " + "every other schema out)") return Record(SPEC_LEG, commit, tuple(copies)) diff --git a/src/opendox/contracts/copies.yaml b/src/opendox/contracts/copies.yaml index cff23db1..5c3414b7 100644 --- a/src/opendox/contracts/copies.yaml +++ b/src/opendox/contracts/copies.yaml @@ -11,17 +11,25 @@ # WHERE THE VALUES CAME FROM, read with `git show : | sha256sum` # in opensoft/openDox-spec. # -# * `commit` is the spec-leg commit the openDox root pins: T053 as landed, -# openDox-spec#16's squash f7ee3c76, which the root's `contracts/spec-pin.yaml` -# and `spec` gitlink name since opensoft/openDox#14 (dox-v1.1). It is the one -# commit that carries all four files. +# * `commit` is the spec-leg commit the openDox root pins: T040 as landed, +# openDox-spec#17's squash 7db9438b, which the root's `contracts/spec-pin.yaml` +# and `spec` gitlink name since opensoft/openDox#20 (plan 038 T060, +# dox-v1.2). It is the one commit that carries all five files. # * Each digest is the one the root's `contracts/manifest.yaml` records for that -# file at that commit. Three of the four files are unchanged from the root's -# previous spec pin, 8fe8c4c7. The fourth is T053's `opendox-snapshot`. +# file at that commit. Four of the five files are unchanged from the root's +# previous spec pin, f7ee3c76 (T053, dox-v1.1). The fifth is T040's +# `opendox-health-finding`, which plan 038's T041 copies here. # -# `commit` moved here from #16's head, cd49eb25, in lockstep with the root's -# spec pin. f7ee3c76 has cd49eb25's tree, so no digest moved. A digest moves -# only when its file changes. +# WHAT THE FIVE ARE (7.1, as T007's batch G amends it and T005's batch Q adds +# to it; N-15). Four are the schemas of the validator's kinds. The fifth is +# the finding's neutral shape: a copy the health engine reads, and no kind the +# validator validates, because a finding's `kind` is its family and no `kind` +# constant can name the schema. `opendox.health_contract` mirrors it. +# +# `commit` moved here from f7ee3c76 in lockstep with the root's spec pin (T041). +# openDox-spec#17 only added files, so no digest of the four moved. Before +# that, it moved from openDox-spec#16's head, cd49eb25, to its squash f7ee3c76, +# which has cd49eb25's tree. A digest moves only when its file changes. # # NEVER EDIT A COPY OR A DIGEST IN PLACE. A copy changes in the spec leg. It # arrives here when the spec leg's file is copied at the pinned commit and its @@ -29,11 +37,14 @@ schema_version: 1 kind: packaged-contract-copies spec_leg: opensoft/openDox-spec -commit: "f7ee3c763b3af4581daf1cd54406e5111e9358e6" +commit: "7db9438b4cc4446ab4e6ab5b552c220312deccc9" copies: - id: ideation-workbench path: contracts/schemas/ideation-workbench.schema.yaml sha256: "d30438491119c20928fbe4e85088fc33682829eeb6558d87dafce651000faafc" + - id: opendox-health-finding + path: contracts/schemas/opendox-health-finding.schema.yaml + sha256: "6fb9b29f23a4270fa0310624d9e59b5d82e9888a5168600671e90ed30f781016" - id: opendox-snapshot path: contracts/schemas/opendox-snapshot.schema.yaml sha256: "f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a" diff --git a/src/opendox/contracts/schemas/opendox-health-finding.schema.yaml b/src/opendox/contracts/schemas/opendox-health-finding.schema.yaml new file mode 100644 index 00000000..8ccdc5f6 --- /dev/null +++ b/src/opendox/contracts/schemas/opendox-health-finding.schema.yaml @@ -0,0 +1,396 @@ +# openDox's health finding: the neutral shape of ONE finding (plan 038, T040). +# +# WHY THIS FILE IS JSON. It is YAML whose body is one JSON object, as +# opendox-snapshot.schema.yaml is and for the same reason: this leg's required +# `validate` check installs pytest and nothing else, so +# `tests/test_opendox_health_contracts.py` reads it with Python's built-in +# `json` module once these comment lines are set aside, and proves that PyYAML +# reads the same object wherever PyYAML is installed. +# +# A SHAPE THE ENGINE READS, NOT A FILE KIND (decision N-15). An instance is one +# finding, as `opendox health list --json` emits one per array element. It +# carries no schema_version, and its `kind` is the finding's FAMILY +# (broken-link, orphan, ...), so no `kind` constant can name this schema, and +# openDox's validator validates no instance against it. openDox-code carries a +# digest-checked copy that its health contract module mirrors. The two file +# kinds beside it, opendox-health-packs and opendox-health-dispositions, are +# documents with a schema_version and a `kind` constant of their own. +# +# THE ID RULE (decision N-13, refined by review round 1's ADV-07): +# +# key = {"identity": , "kind": , "pack_id": , +# "path": }, written as JSON with its keys sorted at every +# level, no whitespace, and every character as itself, then encoded +# as UTF-8. In Python: +# json.dumps(key, sort_keys=True, separators=(",", ":"), +# ensure_ascii=False).encode("utf-8") +# h16 = the first 16 hex digits of sha256(key) +# id = pack_id + "." + kind + "." + h16 +# +# `identity` is the key the family supplies, and it is POSITION-INDEPENDENT: a +# link target as written, a pair of paths, a heading key, never a line number. +# The engine's own findings have the engine's own keys: {category, entry} for +# an install-level or pre-run finding, and {collided_id} for a collision, +# whatever the collision's path. +# `locator` is display only and outside the hash, so an edit above a finding +# moves its locator and keeps its id, and an exception keyed by that id keeps +# suppressing it. The engine sets the id, and a pack's own value is ignored. +# +# NO DOCUMENT TEXT (R2Q25 (a)). `evidence` holds locators only, and `message` +# is one line of the family's own words. Neither can carry more than 200 +# characters in one string, and no key of evidence or identity is named +# excerpt, text, content or quote. The finding is CLOSED: a field this schema +# does not name would be a channel around those bounds, so it is refused. +# +# TEXT UTF-8 CAN ENCODE. No string a finding carries holds a lone surrogate +# (U+D800 to U+DFFF). JSON can spell one (\ud800), and a file name that is not +# UTF-8 decodes to them, but UTF-8 cannot encode one, so the id could not be +# computed and the finding could not be stored or emitted. +# +# RULES. Every rule has an identifier, as in opendox-snapshot.schema.yaml. A +# subschema names in x-rule the rule its keywords enforce, and x-rules lists +# every rule with its class. A shape rule is enforced by this schema's own +# keywords. A reference rule is a cross-field rule that no JSON Schema keyword +# can state, so the reader enforces it. +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "opendox-health-finding.schema.yaml", + "title": "openDox health finding", + "contract_schema_version": 1, + "description": "One health finding, in the neutral shape openDox's health engine stores, lists and shows (plan 038 T040, on R2Q10 (a), R2Q22 (a) and R2Q25 (a): opensoft/openxFactory issue 656, comment 6003486656; decisions N-3, N-13, N-15 and OQ-H15-19, comment 6013547504). The product's own families and every manifest-listed pack return findings in this shape; the engine sets id, pack_id, pack_version and baseline_class, and a pack is never asked for them (15.7). The file's comment header, the field descriptions and the x-rules catalog say the rest.", + "x-rule": "finding-keys", + "type": "object", + "required": [ + "id", + "kind", + "pack_id", + "pack_version", + "path", + "identity", + "severity", + "resolution_class", + "message", + "evidence" + ], + "additionalProperties": false, + "properties": { + "id": { + "description": "pack_id, the family and h16, joined by dots (the file's comment header gives the rule). Set by the engine. It is a valid git ref-name component, so the fix draft for a finding is the branch health-fix-.", + "x-rule": "id-is-well-formed", + "type": "string", + "pattern": "^[a-z0-9-]+\\.[a-z0-9-]+\\.[0-9a-f]{16}$(?!\\n)" + }, + "kind": { + "description": "The family that raised the finding: broken-link, orphan, empty-stub, and the rest a family or pack declares. identity-collision is the engine's own (the x-rules catalog).", + "x-rule": "kind-is-a-family-name", + "type": "string", + "pattern": "^[a-z0-9-]+$(?!\\n)" + }, + "pack_id": { + "description": "The pack the finding is attributed to: opendox for the product's own families and for an install-level finding, otherwise the id of the manifest entry that launched the pack (15.7, OQ-H15-19). Stamped by the engine.", + "x-rule": "pack-id-is-a-name", + "type": "string", + "pattern": "^[a-z0-9-]+$(?!\\n)" + }, + "pack_version": { + "description": "The manifest entry's version, or for opendox the installed version (15.7, OQ-H15-18). Stamped by the engine. A family's own version, if it has one, rides in evidence as family_version.", + "x-rule": "pack-version-is-text", + "type": "string", + "minLength": 1, + "pattern": "^(?![\\s\\S]*[\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff])" + }, + "path": { + "description": "The document the finding is about, relative to the corpus root and separated by slashes. Empty for an install-level or pre-run finding, which names no document.", + "x-rule": "path-is-corpus-relative", + "type": "string", + "pattern": "^(?!/)(?![A-Za-z]:)(?![\\s\\S]*[\\\\\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff])(?!(?:[\\s\\S]*/)?\\.\\.(?:/|$))" + }, + "identity": { + "description": "The position-independent key the family supplies, which the id hashes: a link target as written, a pair of paths, a heading key. Empty when the family raises at most one finding per document. It holds no number, because a line number moves with every edit above it and a number has no one canonical JSON spelling across languages (1.0 and 1, 1e21). Its strings and keys are bounded as evidence's are, and hold no lone surrogate.", + "x-rule": "identity-is-an-object", + "type": "object", + "propertyNames": {"$ref": "#/$defs/identity_key"}, + "additionalProperties": {"$ref": "#/$defs/identity_value"} + }, + "locator": { + "description": "Display only, and outside the id: a line span, a link target as written, or both. A reader that shows the passage reads it from git when it renders (R2Q25 (a)).", + "x-rule": "locator-keys", + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "dependentRequired": {"line_start": ["line_end"], "line_end": ["line_start"]}, + "properties": { + "line_start": {"$ref": "#/$defs/line"}, + "line_end": {"$ref": "#/$defs/line"}, + "target": { + "x-rule": "locator-target-is-short-text", + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^(?![\\s\\S]*[\\ud800-\\udfff])" + } + } + }, + "severity": { + "x-rule": "severity-is-known", + "enum": ["error", "warning", "info"] + }, + "resolution_class": { + "description": "The three classes, spelled exactly as 14.6 rules them, in the store, the CLI, the view and the pack contract alike.", + "x-rule": "resolution-class-is-one-of-three", + "enum": ["auto-fix", "assisted", "human-only"] + }, + "message": { + "description": "One line, written by the family in its own words, never document text (ADV-27): at most 200 characters, with no control character, no line or paragraph separator and no lone surrogate.", + "x-rule": "message-is-one-bounded-line", + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^(?![\\s\\S]*[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029\\ud800-\\udfff])" + }, + "evidence": { + "description": "Locators only, never document text (R2Q25 (a)): paths, line spans, link targets as written, digests, the family's own family_version, a refused patch's refused_patch and reason (15.2a). An empty object when the family has none. Every string in it, key or value, at any depth, is at most 200 characters with no lone surrogate, and no key is named excerpt, text, content or quote.", + "x-rule": "evidence-is-an-object", + "type": "object", + "propertyNames": {"$ref": "#/$defs/evidence_key"}, + "additionalProperties": {"$ref": "#/$defs/evidence_value"} + }, + "baseline_class": { + "description": "Set by the engine against the previous default-tip run (R2Q12 (a)). There is no fourth value (I-2 (a)).", + "x-rule": "baseline-class-is-known", + "enum": ["new", "pack-upgrade", "persistent"] + } + }, + "allOf": [ + { + "if": {"required": ["path"], "properties": {"path": {"const": ""}}}, + "then": { + "properties": { + "resolution_class": {"x-rule": "pathless-finding-is-human-only", "const": "human-only"} + } + } + }, + { + "if": { + "required": ["path"], + "properties": {"path": {"const": ""}, "kind": {"not": {"const": "identity-collision"}}} + }, + "then": { + "properties": { + "identity": { + "description": "An install-level or pre-run finding's identity is the engine's own: the failure's category, and the manifest entry it is about, or the empty string for none. A collision of two such findings has an empty path too, and keeps the collision's identity instead.", + "x-rule": "pathless-identity-is-category-and-entry", + "required": ["category", "entry"], + "additionalProperties": false, + "properties": { + "category": { + "x-rule": "pathless-identity-is-category-and-entry", + "type": "string", + "pattern": "^[a-z0-9-]+$(?!\\n)" + }, + "entry": { + "x-rule": "pathless-identity-is-category-and-entry", + "type": "string", + "pattern": "^(?:[a-z0-9-]+)?$(?!\\n)" + } + } + } + } + } + }, + { + "if": {"required": ["kind"], "properties": {"kind": {"const": "identity-collision"}}}, + "then": { + "properties": { + "resolution_class": {"x-rule": "identity-collision-is-human-only", "const": "human-only"}, + "identity": { + "description": "A collision finding's identity is the engine's own: the id the colliding findings shared.", + "x-rule": "collision-identity-is-the-collided-id", + "required": ["collided_id"], + "additionalProperties": false, + "properties": { + "collided_id": { + "x-rule": "collision-identity-is-the-collided-id", + "type": "string", + "pattern": "^[a-z0-9-]+\\.[a-z0-9-]+\\.[0-9a-f]{16}$(?!\\n)" + } + } + } + } + } + } + ], + "$defs": { + "line": { + "x-rule": "locator-line-is-a-line-number", + "type": "integer", + "minimum": 1 + }, + "identity_key": { + "allOf": [ + {"x-rule": "identity-names-no-text", "not": {"enum": ["excerpt", "text", "content", "quote"]}}, + {"x-rule": "identity-strings-are-short-text", "maxLength": 200, "pattern": "^(?![\\s\\S]*[\\ud800-\\udfff])"} + ] + }, + "identity_value": { + "allOf": [ + {"x-rule": "identity-holds-no-number", "type": ["string", "boolean", "null", "array", "object"]}, + {"x-rule": "identity-strings-are-short-text", "maxLength": 200, "pattern": "^(?![\\s\\S]*[\\ud800-\\udfff])"} + ], + "items": {"$ref": "#/$defs/identity_value"}, + "propertyNames": {"$ref": "#/$defs/identity_key"}, + "additionalProperties": {"$ref": "#/$defs/identity_value"} + }, + "evidence_key": { + "allOf": [ + {"x-rule": "evidence-names-no-text", "not": {"enum": ["excerpt", "text", "content", "quote"]}}, + {"x-rule": "evidence-strings-are-short-text", "maxLength": 200, "pattern": "^(?![\\s\\S]*[\\ud800-\\udfff])"} + ] + }, + "evidence_value": { + "x-rule": "evidence-strings-are-short-text", + "maxLength": 200, + "pattern": "^(?![\\s\\S]*[\\ud800-\\udfff])", + "items": {"$ref": "#/$defs/evidence_value"}, + "propertyNames": {"$ref": "#/$defs/evidence_key"}, + "additionalProperties": {"$ref": "#/$defs/evidence_value"} + } + }, + "x-rules": [ + { + "id": "finding-keys", + "class": "shape", + "says": "A finding is an object carrying id, kind, pack_id, pack_version, path, identity, severity, resolution_class, message and evidence, and optionally locator and baseline_class. It carries no other field." + }, + { + "id": "id-is-well-formed", + "class": "shape", + "says": "id is pack_id, the family and 16 lowercase hex digits, joined by dots: two names of lowercase letters, digits and hyphens, then the hash. It is a valid git ref-name component." + }, + { + "id": "kind-is-a-family-name", + "class": "shape", + "says": "kind, the family, is a name of lowercase letters, digits and hyphens." + }, + { + "id": "pack-id-is-a-name", + "class": "shape", + "says": "pack_id is a name of lowercase letters, digits and hyphens (OQ-H15-19): opendox for the product's own families, otherwise a manifest entry's id." + }, + { + "id": "pack-version-is-text", + "class": "shape", + "says": "pack_version is non-empty text with no control character (U+0000 to U+001F, U+007F to U+009F) and no lone surrogate (U+D800 to U+DFFF)." + }, + { + "id": "path-is-corpus-relative", + "class": "shape", + "says": "path is relative to the corpus root: it does not start with a slash or with a drive letter and a colon, holds no backslash, no control character and no lone surrogate, and has no .. segment. It is empty for an install-level or pre-run finding." + }, + { + "id": "identity-is-an-object", + "class": "shape", + "says": "identity is an object: the position-independent key the family supplies, empty when the family raises at most one finding per document." + }, + { + "id": "identity-holds-no-number", + "class": "shape", + "says": "No value in identity, at any depth, is a number: never a line number, and never a value whose canonical JSON differs between languages. Text, true, false, null, lists and objects are admitted." + }, + { + "id": "identity-strings-are-short-text", + "class": "shape", + "says": "Every string in identity, key or value, at any depth, is at most 200 characters and holds no lone surrogate (U+D800 to U+DFFF), which UTF-8 cannot encode and so the id could not hash." + }, + { + "id": "identity-names-no-text", + "class": "shape", + "says": "No key in identity, at any depth, is named excerpt, text, content or quote." + }, + { + "id": "locator-keys", + "class": "shape", + "says": "locator, when present, is an object carrying line_start and line_end together, target, or both, and no other key." + }, + { + "id": "locator-line-is-a-line-number", + "class": "shape", + "says": "locator.line_start and locator.line_end are whole numbers no less than 1." + }, + { + "id": "locator-target-is-short-text", + "class": "shape", + "says": "locator.target is non-empty text of at most 200 characters, with no lone surrogate (U+D800 to U+DFFF)." + }, + { + "id": "severity-is-known", + "class": "shape", + "says": "severity is one of error, warning and info." + }, + { + "id": "resolution-class-is-one-of-three", + "class": "shape", + "says": "resolution_class is one of auto-fix, assisted and human-only, spelled exactly so (14.6)." + }, + { + "id": "message-is-one-bounded-line", + "class": "shape", + "says": "message is one line of 1 to 200 characters, with no control character (U+0000 to U+001F, U+007F to U+009F) no line or paragraph separator (U+2028, U+2029) and no lone surrogate (U+D800 to U+DFFF). It is the family's own words, never document text." + }, + { + "id": "evidence-is-an-object", + "class": "shape", + "says": "evidence is an object of locators: paths, line spans, link targets as written, digests, family_version, and a refused patch's refused_patch and reason. It is empty when the family has none." + }, + { + "id": "evidence-strings-are-short-text", + "class": "shape", + "says": "Every string in evidence, key or value, at any depth, is at most 200 characters (R2Q25 (a)) and holds no lone surrogate (U+D800 to U+DFFF)." + }, + { + "id": "evidence-names-no-text", + "class": "shape", + "says": "No key in evidence, at any depth, is named excerpt, text, content or quote (R2Q25 (a))." + }, + { + "id": "baseline-class-is-known", + "class": "shape", + "says": "baseline_class, when present, is one of new, pack-upgrade and persistent (R2Q12 (a))." + }, + { + "id": "pathless-finding-is-human-only", + "class": "shape", + "says": "A finding whose path is empty, an install-level or pre-run finding, is human-only: it names no document a repair could edit." + }, + { + "id": "pathless-identity-is-category-and-entry", + "class": "shape", + "says": "A finding whose path is empty, other than an identity collision, has the engine's own identity, exactly {category, entry}: category is the failure's category, a name of lowercase letters, digits and hyphens, and entry is the id of the manifest entry it is about, or the empty string for none. So an install-level or pre-run finding keeps one id across runs. A collision keeps {collided_id}, whatever its path." + }, + { + "id": "identity-collision-is-human-only", + "class": "shape", + "says": "A finding of kind identity-collision, which the engine records against the family or pack whose findings shared one id, is human-only." + }, + { + "id": "collision-identity-is-the-collided-id", + "class": "shape", + "says": "A finding of kind identity-collision has the engine's own identity, exactly {collided_id}: the id the colliding findings shared." + }, + { + "id": "id-names-its-pack-and-kind", + "class": "reference", + "says": "The first two parts of id are the finding's own pack_id and kind." + }, + { + "id": "id-is-the-hash-of-its-key", + "class": "reference", + "says": "The last part of id is h16 of the finding's own identity, kind, pack_id and path, by the rule in this file's comment header. locator is outside the hash." + }, + { + "id": "locator-span-is-ordered", + "class": "reference", + "says": "locator.line_end is no less than locator.line_start." + } + ] +} diff --git a/src/opendox/health_contract.py b/src/opendox/health_contract.py new file mode 100644 index 00000000..7b7b2db3 --- /dev/null +++ b/src/opendox/health_contract.py @@ -0,0 +1,1040 @@ +"""THE HEALTH CONTRACT: openDox's finding vocabulary, in the ONE module a check +pack may import (plan 038 T041, U-0; decision N-3; R2Q18 (a)). + +WHY THIS FILE EXISTS. Release 2's health engine judges a corpus with the +product's own families and with check packs that a manifest pins (#1144 +requirements 14 and 15). Each of them returns findings in one neutral shape, +which openDox-spec owns as `opendox-health-finding` (T040, landed as +openDox-spec `7db9438b`, bundled as `dox-v1.2` by T060; R2Q22 (a)). This module +states that shape in code: the three resolution classes, spelled exactly as +14.6 rules them (`auto-fix`, `assisted`, `human-only`), the severities, the +fields with `pack_id` and `pack_version` (15.7), the id rule, and the bounds. +openDox-code carries a digest-checked copy of the schema beside the validator +(`opendox.contracts`); this module MIRRORS that copy and never reads it, +because it may import the standard library alone. `tests/test_health_contract.py` +holds the two equal, constant for constant. + +THE STANDARD LIBRARY ONLY (R2Q18 (a); N-3). A pack runs in a sandbox that binds +the install's interpreter, its standard library and this one module, never +`site-packages` (T048). So this module imports nothing else, of openDox or of +anyone, and the test proves it three ways: by its source, in a fresh +interpreter, and run alone under `python -I -S`. + +THE ID RULE (N-13, refined by review round 1's ADV-07), as the copy's header +spells it: + + key = {"identity": , "kind": , "pack_id": , + "path": }, as + json.dumps(key, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + h16 = the first 16 hex digits of sha256(key) + id = pack_id + "." + kind + "." + h16 + +`identity` is the POSITION-INDEPENDENT key a family supplies (a link target as +written, a pair of paths, a heading key), never a line number. `locator` is +display only and outside the hash, so an edit above a finding moves its locator +and keeps its id, and an exception keyed by that id keeps suppressing it. The +ENGINE sets the id: `make_finding()` takes none, so a pack's own value never +reaches a finding. `finding_id()` is the module's ONE hash path: every id it +sets or checks is computed there. + +`identity` IS STORED AND EMITTED (the holder, `openxFactory#656` `6018624750`): +the engine hashes it into the id, T042 stores it as canonical sorted-key JSON +(`identity_json()`), and the list and the HTTP response emit it. So it is +bounded as the schema bounds it: no key named `excerpt`, `text`, `content` or +`quote`, no number, every string at most 200 characters, `{category, entry}` for +a pathless finding and `{collided_id}` for a collision. + +THE ENGINE'S BOUNDS, STRICTER THAN THE SCHEMA'S. The contract module owns the +field bounds, so the engine's caps are here, each tested at its boundary: + +* `NAME_MAX`: `pack_id` and `kind` are 1 to 40 characters, so `id` is at most + 98 and the fix branch `health-fix-` at most 109, far below a ref path + component's 255 bytes. The schema's patterns bound neither. +* `IDENTITY_MAX_BYTES`: the identity's canonical JSON, in UTF-8, is at most + 2048 bytes. The schema bounds each string and leaves the whole unbounded. + T042 stores what this cap admits, and T046 enforces it at the engine's + door; both cite this constant. +* `NESTING_MAX`: no value in `identity` or `evidence` nests more than 16 + objects and arrays deep, counting the field's own object. The walk below + never recurses, and this bound keeps `json.dumps`, which does, far from + Python's recursion limit. +* Every value is a JSON value: text keys, finite numbers, and no tuple, set or + bytes (`finding-is-json`). +* No string a finding carries holds U+0000 (`text-holds-no-nul`; the holder's + `openxFactory#656` `6072086385` item 4), keys and values alike: Postgres + `jsonb` cannot store it (SQLSTATE 22P05), so such a finding could never be + stored. The schema admits it in `identity`, `evidence` and `locator.target`, + so this rule refuses it there, with a bounded `where`; in `message`, `path` + and `pack_version` the schema's own control-character rules refuse it + already. For a pack's output, this refusal is T045's whole-output + `pack-output-refused` (`6069024023` item 1's mapping), so a broken or hostile + pack loses its own output, never the run. T042's `_json_text` refuses it + too, as defence in depth. + +EVERY RULE HAS AN IDENTIFIER. A violation names the copy's own rule id (its +`x-rules` catalog: 24 shape rules and 3 reference rules) or one of the engine's +five (`ENGINE_RULES`). openDox's validator cannot build the finding schema: it +refuses a copy whose catalog names reference rules it does not implement +(`SchemaNotEvaluable`). The finding is no validator kind anyway (N-15), so all +27 rules are written here by hand, the three reference rules among them: +`id-names-its-pack-and-kind`, `id-is-the-hash-of-its-key` and +`locator-span-is-ordered`. As in openDox-spec's own test, a reference rule +judges only parts whose own rules hold, so one cause is reported once. + +A REFUSAL QUOTES NOTHING IT REFUSED, AND ITS POINTER IS BOUNDED. A finding may +carry document text where it must not, which is why it is refused. So no +refused string, key or value reaches a violation: its `detail` names a size, a +type or the admitted set, never a value of the finding, and a finding's names +and hashes are named only once admitted. Its `where` is a JSON pointer built +only from keys this module ADMITTED, so it names those verbatim (each within +the contract's own bound on a key) and never a refused one, and it is at most +`STRING_MAX` characters: the place's own pointer, or, when that is longer, its +nearest ancestor's that fits. So an engine may record a refusal's `rule` and +`where` as `evidence` of the finding against the pack, whose every string is +bounded at 200 characters. + +THE ENGINE'S OWN FINDINGS (contracts/health-finding.md § The id rule; the +holder's rulings `openxFactory#656` `6069024023`, items 1 and 2). + +* `CATEGORIES` fixes the failure categories of an install-level or pre-run + finding. A category is also that finding's `kind`, its `pack_id` is fixed + per category (`ENTRY_CATEGORIES`; the value is `6072086385` item 1's), and + its identity is `{category, entry}`, so the engine raises ONE such finding + per category and entry in a run, with the reasons in `evidence`, and it + keeps one id across runs. The install-level categories + (`INSTALL_CATEGORIES`) take entry "": a run raises ONE such finding + (`6072197564` (e)). `pathless_identity()` builds the identity and + `engine_finding()` the whole finding. +* A pathless finding's `identity.category` is one of `PATHLESS_CATEGORIES`, + the engine's categories and `identity-collision`, and nothing else + (`6072197564` (b)): only the engine raises a pathless finding. +* `collision_identity()` builds a collision's `{collided_id}`. +* `disappearance_identity()` builds the identity of the re-raise of an + uncited disappearance, in the form its original's path calls for: a pathed + original's `{disappeared_id}`, and for a pathless original the pathless form + the schema requires, `{category: , entry: }` (option (D)). T046 raises it. +* `ENGINE_KINDS` are the kinds only the engine raises, which T045 refuses in a + pack's declaration: the categories, `identity-collision`, and the PATHED + `uncited-disappearance` and `refused-patch` (`6086098003` item 1). +* `refused_entry()` builds `entry-refused`'s `identity.entry` from a refused + entry's id (`6086098003` item 4, `6088484643`): "" for no id, the id as + written when it is a pack-id-shaped name of at most 200 characters, and + otherwise `sha256-` and the digest of its canonical text. That digest is no + finding id: every id is still `finding_id()`'s. +* `IGNORED_FIELDS` and `STAMPED_FIELDS` split the fields a pack does not own + by what happens to a pack's value (`6072197564` (d)): its `id` is ignored, + and `pack_id`, `pack_version` and `baseline_class` are the engine's. + +T045 APPENDS the pack protocol to this module (the static declaration, the +stdout document and the patch type) at T041's landing, a cross-lane hand-off +(plan.md:458, § "Parallel slices, and the files only one writer may touch at +a time"). + +A CREATED FILE: it has no row in openxFactory's +`docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES +openxFactory and never what a destination assembles (RULED OQ-C). +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import math +import re +from typing import Any, Iterator, Mapping, NamedTuple + +__all__ = [ + "ASSISTED", + "AUTO_FIX", + "BASELINE_CLASSES", + "CATEGORIES", + "ENGINE_KINDS", + "ENGINE_RULES", + "ENTRY_CATEGORIES", + "FIELDS", + "FORBIDDEN_KEYS", + "FindingRefused", + "HASH_DIGITS", + "HUMAN_ONLY", + "IDENTITY_COLLISION", + "IDENTITY_MAX_BYTES", + "ID_MAX", + "IGNORED_FIELDS", + "INSTALL_CATEGORIES", + "NAME_MAX", + "NESTING_MAX", + "OPENDOX", + "OPTIONAL_FIELDS", + "PATHLESS_CATEGORIES", + "REFERENCE_RULES", + "REFUSED_PATCH", + "REQUIRED_FIELDS", + "RESOLUTION_CLASSES", + "SEVERITIES", + "SHAPE_RULES", + "STAMPED_FIELDS", + "STRING_MAX", + "UNCITED_DISAPPEARANCE", + "Violation", + "canonical_json", + "check_finding", + "collision_identity", + "disappearance_identity", + "engine_finding", + "finding_id", + "id_key", + "identity_json", + "make_finding", + "pathless_identity", + "refused_entry", + "to_json", + "violations", +] + +# --------------------------------------------------------------------------- +# the vocabulary +# --------------------------------------------------------------------------- + +#: The three resolution classes, spelled exactly as 14.6 rules them, in the +#: store, the CLI, the view and the pack contract alike. +AUTO_FIX = "auto-fix" +ASSISTED = "assisted" +HUMAN_ONLY = "human-only" +RESOLUTION_CLASSES: tuple[str, ...] = (AUTO_FIX, ASSISTED, HUMAN_ONLY) + +#: The severities, the schema's enum. Not `corpus_adapter.SEVERITIES`, which +#: adds `critical` for another surface. +SEVERITIES: tuple[str, ...] = ("error", "warning", "info") + +#: The baseline classes the engine sets (R2Q12 (a)). There is no fourth value +#: (I-2 (a)). +BASELINE_CLASSES: tuple[str, ...] = ("new", "pack-upgrade", "persistent") + +#: The pack id of the product's own families and of an install-level finding +#: (OQ-H15-19). +OPENDOX = "opendox" + +#: The kind of the engine's finding against a producer two of whose findings +#: in one run shared one id. +IDENTITY_COLLISION = "identity-collision" + +#: The kind of the engine's once-only re-raise of an uncited disappearance +#: (data-model.md § Baseline classes; T046 raises it). +UNCITED_DISAPPEARANCE = "uncited-disappearance" + +#: The kind of the engine's finding against a pack whose patch it refused, +#: naming `refused_patch` and `reason` (T049; data-model.md:209, :223, :369). +#: A PATHED engine kind, like `uncited-disappearance`, and no category (the +#: holder's `6086098003` item 1). +REFUSED_PATCH = "refused-patch" + +#: A finding's fields, in the schema's order. +REQUIRED_FIELDS: tuple[str, ...] = ( + "id", "kind", "pack_id", "pack_version", "path", "identity", "severity", + "resolution_class", "message", "evidence") +OPTIONAL_FIELDS: tuple[str, ...] = ("locator", "baseline_class") +FIELDS: tuple[str, ...] = ( + "id", "kind", "pack_id", "pack_version", "path", "identity", "locator", + "severity", "resolution_class", "message", "evidence", "baseline_class") + +#: The fields a pack does not own, split by what happens to its value (the +#: holder's `6072197564` (d)). A pack's own `id` is IGNORED: the engine computes +#: every id by the id rule (`finding_id()`), so `make_finding()` takes none and +#: a pack's value never reaches a finding (`6065680005` item 3). +IGNORED_FIELDS: tuple[str, ...] = ("id",) +#: The fields that are the ENGINE's: it stamps `pack_id` and `pack_version` +#: (15.7) and sets `baseline_class`; a pack's output that sets one is refused +#: (T045, `6065680005` item 3). +STAMPED_FIELDS: tuple[str, ...] = ("pack_id", "pack_version", "baseline_class") + +#: No key of `identity` or `evidence`, at any depth, is named one of these +#: (R2Q25 (a)). +FORBIDDEN_KEYS: tuple[str, ...] = ("excerpt", "text", "content", "quote") + +#: Every string of `identity`, `evidence` and `locator.target`, key or value, +#: and `message`, is at most this many characters (R2Q25 (a); ADV-27). +STRING_MAX = 200 + +#: The ENGINE's cap on `pack_id` and `kind`, stricter than the schema's +#: patterns, so a fix branch's name stays bounded. +NAME_MAX = 40 + +#: The id's hash: the first 16 hex digits of the key's SHA-256. +HASH_DIGITS = 16 + +#: The longest id: two names at the cap, the hash and two dots. +ID_MAX = 2 * NAME_MAX + HASH_DIGITS + 2 + +#: The ENGINE's cap on the identity's serialized size: its canonical JSON +#: (`identity_json()`), in UTF-8 bytes. The schema bounds each string and +#: leaves the whole unbounded, so this is stricter. The largest identity the +#: plan names, a pair of paths, fits in every UTF-8 width: two 200-character +#: strings of four-byte characters under two 40-character keys are 1693 +#: bytes. T042 stores what it admits, and T046 enforces it. +IDENTITY_MAX_BYTES = 2048 + +#: The ENGINE's bound on nesting in `identity` and `evidence`: objects and +#: arrays, the field's own object counted as the first. +NESTING_MAX = 16 + +#: The failure categories of an install-level or pre-run finding, whose +#: identity is the engine's own `{category, entry}` (contracts/health-finding.md +#: § The id rule; T041 fixes them with the id rule). `entry` is the manifest +#: entry's id, or "" for none. The nine T041 drafted are ACCEPTED as written, +#: and the last two are ADDED, by the holder's `6069024023` item 1. A category +#: is also its finding's `kind`. +CATEGORIES: tuple[str, ...] = ( + "no-sandbox", # no live sandbox, so no pack ran (R2Q16 (a)) + "entry-refused", # a manifest entry refused by name (15.1a) + "fetch-failed", # a git-URL source that could not be fetched (OQ-H15-14) + "digest-mismatch", # a pack tree whose digest is not its entry's (15.1a) + "declaration-refused", # a pack's `opendox-pack.yaml` refused (15.2; OQ-H15-10) + "pack-crashed", # a pack that exited non-zero or on a signal (15.6) + "pack-timed-out", # a pack that ran past its time budget (15.6) + "pack-bound-hit", # a pack that hit one of the engine's bounds, its + # stdout cap included (15.6) + "pack-output-refused", # a pack's stdout that is not one JSON document, or + # breaks the contract; refused whole (15.5, 15.6) + "manifest-refused", # `health/packs.yaml` refused as a whole; entry "" (15.1a) + "dispositions-refused", # `health/dispositions.yaml` refused; entry "" + # (contracts/health-exceptions.md; T054 raises it) +) + +#: The categories whose finding is against the PACK its manifest entry +#: launched: the finding's `pack_id` is the entry's id, which is its pack's id +#: (15.7), so `entry` is never empty for them. Every other category's finding is +#: against the product itself, `opendox`: no live sandbox, a refused manifest or +#: dispositions file, and a refused entry, whose id may be malformed, reserved +#: or repeated and so is no pack id to attribute a finding to; it rides only in +#: `identity.entry`. This is the holder's `6072086385` item 1, beside +#: `6069024023` item 1: it names the value that ruling's "one pack_id per +#: category" left open, after dox-v1.2's schema text for `pack_id`. +ENTRY_CATEGORIES: frozenset[str] = frozenset({ + "fetch-failed", "digest-mismatch", "declaration-refused", "pack-crashed", + "pack-timed-out", "pack-bound-hit", "pack-output-refused"}) + +#: The kinds only the ENGINE raises: its categories, a collision and the +#: re-raise of a disappearance. A pack declares none of them (T045 refuses +#: one; the holder's `6069024023` item 1). +ENGINE_KINDS: frozenset[str] = frozenset(CATEGORIES) | {IDENTITY_COLLISION, + UNCITED_DISAPPEARANCE, + REFUSED_PATCH} + +#: The install-level categories: a run raises ONE such finding, never one per +#: entry (data-model.md:382, "ONE install-level finding"; R2Q16 (a)), so its +#: entry is always "" and a non-empty one is refused (the holder's `6069024023` +#: item 1 and `6072197564` (e)). Only `ENTRY_CATEGORIES` and `entry-refused` +#: take an entry. +INSTALL_CATEGORIES: frozenset[str] = frozenset({ + "no-sandbox", "manifest-refused", "dispositions-refused"}) + +#: The categories a pathless finding's identity may name, a CLOSED set (the +#: holder's `6072197564` (b)): the engine's own, and `identity-collision`, the +#: kind of a pathless collision whose disappearance is re-raised (option (D)). +#: A pack raises no pathless finding (T045 refuses one; `6072086385` item 3). +PATHLESS_CATEGORIES: frozenset[str] = frozenset(CATEGORIES) | {IDENTITY_COLLISION} + +#: The copy's catalog: the rules its own keywords state, and the three +#: cross-field rules no JSON Schema keyword can state. +SHAPE_RULES: tuple[str, ...] = ( + "finding-keys", "id-is-well-formed", "kind-is-a-family-name", + "pack-id-is-a-name", "pack-version-is-text", "path-is-corpus-relative", + "identity-is-an-object", "identity-holds-no-number", + "identity-strings-are-short-text", "identity-names-no-text", "locator-keys", + "locator-line-is-a-line-number", "locator-target-is-short-text", + "severity-is-known", "resolution-class-is-one-of-three", + "message-is-one-bounded-line", "evidence-is-an-object", + "evidence-strings-are-short-text", "evidence-names-no-text", + "baseline-class-is-known", "pathless-finding-is-human-only", + "pathless-identity-is-category-and-entry", "identity-collision-is-human-only", + "collision-identity-is-the-collided-id") +REFERENCE_RULES: tuple[str, ...] = ( + "id-names-its-pack-and-kind", "id-is-the-hash-of-its-key", + "locator-span-is-ordered") + +#: The engine's own rules, stricter than the copy's. +NAME_CAP = "name-is-at-most-40-characters" +SIZE_CAP = "identity-is-within-the-size-cap" +NESTING_CAP = "nesting-is-within-the-cap" +NOT_JSON = "finding-is-json" +NO_NUL = "text-holds-no-nul" +ENGINE_RULES: tuple[str, ...] = (NAME_CAP, SIZE_CAP, NESTING_CAP, NOT_JSON, NO_NUL) + +# The pointers of the fields more than one rule reports at. +_AT_IDENTITY = "/identity" +_AT_ENTRY = "/identity/entry" +_AT_LOCATOR = "/locator" +_AT_TARGET = "/locator/target" +_AT_RESOLUTION_CLASS = "/resolution_class" + +# The schema's patterns, read as Python's `fullmatch`, which admits no trailing +# newline (the schema's `$(?!\n)`). +_NAME = re.compile(r"[a-z0-9-]+") +_ID = re.compile(r"([a-z0-9-]+)\.([a-z0-9-]+)\.([0-9a-f]{16})") +_ENTRY = re.compile(r"(?:[a-z0-9-]+)?") +_DRIVE = re.compile(r"[A-Za-z]:") +_SURROGATE = re.compile("[\ud800-\udfff]") +_CONTROL = re.compile("[\x00-\x1f\x7f-\x9f\ud800-\udfff]") +_NOT_ONE_LINE = re.compile("[\x00-\x1f\x7f-\x9f\u2028\u2029\ud800-\udfff]") +_NUL = "\x00" +_NUL_DETAIL = "holds U+0000, which a stored finding cannot carry (jsonb refuses it)" +_ENTRY_FORM_DETAIL = ("does not take its category's form: a pack's id for a category " + "against a pack, and empty for an install-level category or a " + "collision") + + +class Violation(NamedTuple): + """One rule a finding breaks: the rule's id, where (a JSON pointer into the + finding of at most `STRING_MAX` characters, built from keys this module + admitted, which it names verbatim; past the bound, the nearest ancestor's + pointer that fits), and what was found. No part quotes a string, key or + value it refused.""" + + rule: str + where: str + detail: str + + def line(self) -> str: + return f"[{self.rule}] {self.where}: {self.detail}" + + +class FindingRefused(ValueError): + """A finding, or a part of one, breaks a rule of the contract. `rule` and + `where` are the first violation's, and an engine records those, never the + refused value: `where` is at most `STRING_MAX` characters and names only + admitted keys.""" + + def __init__(self, violation: Violation, more: int = 0) -> None: + self.violation = violation + self.rule = violation.rule + self.where = violation.where + tail = f" (and {more} more)" if more else "" + super().__init__(f"the finding is refused: {violation.line()}{tail}") + + +# --------------------------------------------------------------------------- +# the id rule +# --------------------------------------------------------------------------- + +def canonical_json(value: Any) -> bytes: + """`value` as canonical JSON: sorted keys at every level, no whitespace, + each character as itself, in UTF-8. The id rule's spelling.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + + +def id_key(*, identity: Any, kind: str, pack_id: str, path: str) -> bytes: + """The bytes the id hashes (N-13; ADV-07). It checks nothing: + `finding_id()` checks the parts first.""" + return canonical_json({"identity": identity, "kind": kind, "pack_id": pack_id, + "path": path}) + + +def finding_id(*, identity: Any, kind: str, pack_id: str, path: str) -> str: + """The id the engine sets: `pack_id.kind.h16`. Refuses, with + `FindingRefused`, a key whose parts break their rules, so no id is ever + computed over a part the contract does not admit. + + THE ONE HASH PATH (the holder's `6069507373`, T046 item 2). Every id this + module sets or checks is computed here, and its callers look it up at call + time, so a test that patches `health_contract.finding_id` patches them all.""" + found = (list(_name(kind, "/kind", "kind-is-a-family-name")) + + list(_name(pack_id, "/pack_id", "pack-id-is-a-name")) + + list(_path(path)) + + list(_identity(identity, path=path, kind=kind))) + if found: + raise FindingRefused(found[0], len(found) - 1) + digest = hashlib.sha256(id_key(identity=identity, kind=kind, pack_id=pack_id, + path=path)).hexdigest() + return f"{pack_id}.{kind}.{digest[:HASH_DIGITS]}" + + +def identity_json(identity: Any) -> str: + """The identity as T042 stores it: its canonical JSON, after its own + bounds and the engine's cap. (The pathless and collision forms depend on + the finding's path and kind; `check_finding()` judges those.)""" + found = list(_identity(identity)) + if found: + raise FindingRefused(found[0], len(found) - 1) + return canonical_json(identity).decode("utf-8") + + +# --------------------------------------------------------------------------- +# the engine's own identities +# --------------------------------------------------------------------------- + +def pathless_identity(category: str, entry: str = "") -> dict[str, str]: + """An install-level or pre-run finding's identity: one of `CATEGORIES`, + and the manifest entry's id, or "" for none.""" + rule = "pathless-identity-is-category-and-entry" + if not isinstance(category, str) or category not in CATEGORIES: + raise FindingRefused(Violation( + rule, "/identity/category", + f"the category is not one of the engine's: {', '.join(CATEGORIES)}")) + if (not isinstance(entry, str) or not _ENTRY.fullmatch(entry) + or len(entry) > STRING_MAX): + raise FindingRefused(Violation( + rule, _AT_ENTRY, + "the entry is a manifest entry's id, of lowercase letters, digits and " + f"hyphens and at most {STRING_MAX} characters, or the empty string")) + if _entry_breaks_form(category, entry): + raise FindingRefused(Violation(rule, _AT_ENTRY, _ENTRY_FORM_DETAIL)) + return {"category": category, "entry": entry} + + +def collision_identity(collided_id: str) -> dict[str, str]: + """A collision finding's identity: the id the colliding findings shared.""" + found = list(_id(collided_id, "/identity/collided_id", + "collision-identity-is-the-collided-id")) + if found: + raise FindingRefused(found[0]) + return {"collided_id": collided_id} + + +def _spelling(value: Any) -> Any: + """`refused_entry()`'s `default` hook for a value JSON cannot represent: a + set's members in the order of their `ascii()` text, and anything else by + its `str()` (the holder's `6090537166` item 8 (b)).""" + if isinstance(value, (set, frozenset)): + return sorted(value, key=ascii) + return str(value) + + +def refused_entry(entry_id: Any) -> str: + """`entry-refused`'s `identity.entry` for a refused manifest entry's id + (the holder's `6086098003` item 4 and `6088484643`). Deterministic, total + over every value the manifest's YAML parser yields, and it never raises. + + * No id (`None`, as the parser gives an absent or null `id`): "". + * A string matching `[a-z0-9-]+` of at most `STRING_MAX` characters: the id + as written. + * Anything else (a malformed or over-long string, one carrying U+0000, a + non-string): `sha256-` and the lowercase SHA-256 hex of the ASCII bytes + of its CANONICAL TEXT, 71 characters. The canonical text is + `json.dumps(entry_id, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=True, default=...)`, whose `default` hook + spells a `set` or `frozenset` as the list of its members sorted by each + member's `ascii()` text, so a YAML `!!set` is spelled one way under every + `PYTHONHASHSEED`, and spells every other value JSON cannot represent (a + YAML date, timestamp or binary) by its `str()` (the holder's `6090537166` + item 8 (b)). When that raises (a mapping whose keys JSON cannot spell or + sort, a self-referencing value), the text is `ascii(entry_id)`, + Python's `repr()` with every non-ASCII character escaped (`6088732352` + (b)), so it is always ASCII. + A value neither spelling can write, an integer past Python's 4300-digit + conversion limit (which YAML yields from a long `0x` or `0b` literal) or + a value nested past the recursion limit, takes ONE fixed text, `` (item 8 (a)). So the digest never + raises. + + ACCEPTED LIMITS (`6088732352` (a), `6089449884`, `6090537166`): the text is + not injective across YAML value types (a date and its `str()`, a + non-string mapping key and its JSON spelling), two ids past the limits + share the fixed text, and a set reached only through the `ascii()` + fallback keeps Python's order. `identity-collision` reports any + collision. + + The digest is no finding id (every id is `finding_id()`'s); it keeps a + refused id out of the stored identity while keeping two distinct ids + apart.""" + if entry_id is None: + return "" + if (isinstance(entry_id, str) and len(entry_id) <= STRING_MAX + and _NAME.fullmatch(entry_id)): + return entry_id + try: + text = json.dumps(entry_id, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=True, default=_spelling) + except (TypeError, ValueError, RecursionError): + try: + text = ascii(entry_id) + except (ValueError, RecursionError): + text = "" + digest = hashlib.sha256(text.encode("ascii")).hexdigest() + return f"sha256-{digest}" + + +def disappearance_identity(original: dict[str, Any]) -> dict[str, str]: + """The identity of the engine's re-raise of an uncited disappearance, in + the form the ORIGINAL's path calls for (option (D), the holder's + `6069024023` item 2). T046 raises the re-raise: kind + `uncited-disappearance`, the original's path, human-only, and the + original's id and the baseline run in `evidence`. + + * A pathed original's: `{"disappeared_id": }`. + * A pathless original's: the pathless form the schema requires, + `{"category": , "entry": }`. A + pathless collision has no entry, so its entry is "". + + `original` is the finding as stored, checked whole first.""" + check_finding(original) + if original["path"] != "": + return {"disappeared_id": original["id"]} + return {"category": original["kind"], "entry": original["identity"].get("entry", "")} + + +# --------------------------------------------------------------------------- +# the rules +# --------------------------------------------------------------------------- + +def _escape(key: str) -> str: + """One JSON-pointer reference token (RFC 6901).""" + return key.replace("~", "~0").replace("/", "~1") + + +def _type(value: Any) -> str: + """The JSON type of a value, for a refusal's detail.""" + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, (int, float)): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "array" + if isinstance(value, dict): + return "object" + return f"a {type(value).__name__}, no JSON value" + + +def _text_problem(value: str) -> str | None: + if len(value) > STRING_MAX: + return f"is {len(value)} characters, above {STRING_MAX}" + if _SURROGATE.search(value): + return "holds a lone surrogate, which UTF-8 cannot encode" + return None + + +def _name(value: Any, where: str, rule: str) -> Iterator[Violation]: + if not isinstance(value, str) or not _NAME.fullmatch(value): + yield Violation(rule, where, "is not a name of lowercase letters, digits and hyphens" + f" ({_type(value)})") + elif len(value) > NAME_MAX: + yield Violation(NAME_CAP, where, f"is {len(value)} characters; the engine's cap is " + f"{NAME_MAX}, so a fix branch's name stays bounded") + + +def _id(value: Any, where: str, rule: str) -> Iterator[Violation]: + match = _ID.fullmatch(value) if isinstance(value, str) else None + if match is None: + yield Violation(rule, where, "is not a pack id, a family and 16 lowercase hex " + f"digits, joined by dots ({_type(value)})") + elif max(len(match.group(1)), len(match.group(2))) > NAME_MAX: + yield Violation(NAME_CAP, where, f"names a pack or a family above the engine's " + f"cap of {NAME_MAX} characters") + + +def _pack_version(value: Any) -> Iterator[Violation]: + if not isinstance(value, str) or not value or _CONTROL.search(value): + yield Violation("pack-version-is-text", "/pack_version", + "is not non-empty text free of control characters and lone " + f"surrogates ({_type(value)})") + + +def _path(value: Any) -> Iterator[Violation]: + if not isinstance(value, str): + yield Violation("path-is-corpus-relative", "/path", f"is not text ({_type(value)})") + return + if value.startswith("/") or _DRIVE.match(value): + problem = "starts at a root or a drive" + elif "\\" in value: + problem = "holds a backslash" + elif _CONTROL.search(value): + problem = "holds a control character or a lone surrogate" + elif ".." in value.split("/"): + problem = "has a .. segment" + else: + return + yield Violation("path-is-corpus-relative", "/path", problem) + + +#: One place in a walk: the value, its bounded pointer, whether that pointer +#: has stopped at an ancestor, and the value's nesting level. +_Place = tuple[Any, str, bool, int] + + +def _child(at: str, stopped: bool, token: str) -> tuple[str, bool]: + """A child's pointer, bounded: `at` extended by `token` while the result + is at most `STRING_MAX` characters, and otherwise `at` itself, marked as + STOPPED. A stopped pointer stays stopped for every descendant, so `where` + names the nearest ancestor that fits and never a path that is not there.""" + if not stopped: + child = f"{at}/{_escape(token)}" + if len(child) <= STRING_MAX: + return child, False + return at, True + + +def _walk(root: dict[Any, Any], where: str, field: str) -> Iterator[Violation]: + """Every rule `identity`'s or `evidence`'s content breaks, at any depth, + by an explicit stack and never by recursion. A refused key's value is not + entered, so no pointer carries a key this module refused, and every + pointer is bounded (`_child`).""" + stack: list[_Place] = [(root, where, False, 1)] + while stack: + value, at, stopped, level = stack.pop() + if not isinstance(value, (dict, list)): + yield from _scalar(value, at, field) + elif level > NESTING_MAX: + yield Violation(NESTING_CAP, at, f"nests deeper than the engine's {NESTING_MAX} " + "levels of objects and arrays") + elif isinstance(value, dict): + refused, inner = _members(value, at, stopped, level, field) + yield from refused + stack.extend(reversed(inner)) + else: + stack.extend(reversed([(item, *_child(at, stopped, str(index)), level + 1) + for index, item in enumerate(value)])) + + +def _members(value: dict[Any, Any], at: str, stopped: bool, level: int, field: str + ) -> tuple[list[Violation], list[_Place]]: + """An object's refused keys, and the members to enter: those whose keys + every rule admits.""" + refused: list[Violation] = [] + inner: list[_Place] = [] + for key, item in value.items(): + if not isinstance(key, str): + refused.append(Violation(NOT_JSON, at, f"has a key that is not text ({_type(key)})")) + elif key in FORBIDDEN_KEYS: + refused.append(Violation(f"{field}-names-no-text", at, + f"has a key named {key}, which names document text")) + elif (problem := _text_problem(key)) is not None: + refused.append(Violation(f"{field}-strings-are-short-text", at, + f"has a key that {problem}")) + elif _NUL in key: + refused.append(Violation(NO_NUL, at, f"has a key that {_NUL_DETAIL}")) + else: + inner.append((item, *_child(at, stopped, key), level + 1)) + return refused, inner + + +def _scalar(value: Any, at: str, field: str) -> Iterator[Violation]: + """The rules a value that is no object or array breaks.""" + if isinstance(value, str): + if (problem := _text_problem(value)) is not None: + yield Violation(f"{field}-strings-are-short-text", at, f"is a string that {problem}") + elif _NUL in value: + yield Violation(NO_NUL, at, f"is a string that {_NUL_DETAIL}") + elif value is None or isinstance(value, bool): + return + elif not isinstance(value, (int, float)): + yield Violation(NOT_JSON, at, f"is {_type(value)}") + elif field == "identity": + yield Violation("identity-holds-no-number", at, + "is a number, which an identity never holds") + elif isinstance(value, float) and not math.isfinite(value): + yield Violation(NOT_JSON, at, "is a number JSON cannot write") + elif isinstance(value, int) and not _writable(value): + yield Violation(NOT_JSON, at, "is an integer too long to write as text") + + +def _identity(value: Any, *, path: Any = None, kind: Any = None) -> Iterator[Violation]: + """Every rule the identity breaks: its own, the pathless or collision form + its finding's path and kind call for (when given), and the engine's size + cap, judged last and only over an identity every other rule admits.""" + if not isinstance(value, dict): + yield Violation("identity-is-an-object", _AT_IDENTITY, f"is {_type(value)}, not an " + "object") + return + found = list(_walk(value, _AT_IDENTITY, "identity")) + if kind == IDENTITY_COLLISION: + found += _collision_form(value) + elif path == "": + found += _pathless_form(value) + yield from found + if not found: + size = len(canonical_json(value)) + if size > IDENTITY_MAX_BYTES: + yield Violation(SIZE_CAP, _AT_IDENTITY, f"its canonical JSON is {size} bytes; the " + f"engine's cap is {IDENTITY_MAX_BYTES}") + + +def _pathless_form(identity: dict[Any, Any]) -> Iterator[Violation]: + rule = "pathless-identity-is-category-and-entry" + if set(identity) != {"category", "entry"}: + yield Violation(rule, _AT_IDENTITY, "a pathless finding's identity is exactly " + "{category, entry}, the engine's own") + return + category, entry = identity["category"], identity["entry"] + known = isinstance(category, str) and category in PATHLESS_CATEGORIES + if not known: + yield Violation(rule, "/identity/category", "is not one of the engine's categories " + "or identity-collision") + if not isinstance(entry, str) or not _ENTRY.fullmatch(entry): + yield Violation(rule, _AT_ENTRY, "is neither a manifest entry's id nor the " + "empty string") + elif known and _entry_breaks_form(category, entry): + yield Violation(rule, _AT_ENTRY, _ENTRY_FORM_DETAIL) + + +def _entry_breaks_form(category: str, entry: str) -> bool: + """Whether a pathless identity's entry breaks the form its category calls + for (the holder's `6073087924`, on lane 3's MINOR at `c620dacd`, refining + `6072197564` (e)), so `check_finding()` admits exactly what + `engine_finding()` builds. A category against a pack (`ENTRY_CATEGORIES`) + names that pack's entry, which is that pack's id: never empty, never the + reserved `opendox` (contracts/health-packs-manifest.md:24) and a name of 1 + to `NAME_MAX` characters (the holder's `6082100803`; the alphabet is + checked before this). An install-level category and a collision's re-raise + take entry ""; `entry-refused` takes either (`refused_entry()`).""" + if category in ENTRY_CATEGORIES: + return entry in ("", OPENDOX) or len(entry) > NAME_MAX + if category in INSTALL_CATEGORIES or category == IDENTITY_COLLISION: + return entry != "" + return False + + +def _collision_form(identity: dict[Any, Any]) -> Iterator[Violation]: + rule = "collision-identity-is-the-collided-id" + if set(identity) != {"collided_id"}: + yield Violation(rule, _AT_IDENTITY, "a collision's identity is exactly " + "{collided_id}, the engine's own") + return + yield from _id(identity["collided_id"], "/identity/collided_id", rule) + + +def _writable(value: int) -> bool: + """Whether `json.dumps` can write this integer: Python refuses one longer + than its integer-string conversion limit (4300 digits by default), with a + `ValueError`. So a checked finding always serializes.""" + if value.bit_length() <= 64: + return True + try: + str(value) + except ValueError: + return False + return True + + +def _line(value: Any) -> bool: + """A whole number no less than 1: JSON's integer, which 12.0 is too.""" + if isinstance(value, bool): + return False + if isinstance(value, int): + return value >= 1 + return isinstance(value, float) and value.is_integer() and value >= 1 + + +def _locator(value: Any) -> Iterator[Violation]: + if not isinstance(value, dict): + yield Violation("locator-keys", _AT_LOCATOR, f"is {_type(value)}, not an object") + return + keys = set(value) + if not keys or keys - {"line_start", "line_end", "target"}: + yield Violation("locator-keys", _AT_LOCATOR, "carries line_start and line_end " + "together, target, or both, and no other key") + elif ("line_start" in keys) != ("line_end" in keys): + yield Violation("locator-keys", _AT_LOCATOR, "carries line_start and line_end " + "together or neither") + for key in ("line_start", "line_end"): + if key not in value: + continue + line = value[key] + # a bool is an int Python can always write; `_line()` refuses it next + if isinstance(line, int) and not _writable(line): + yield Violation(NOT_JSON, f"/locator/{key}", "is an integer too long to write " + "as text") + elif not _line(line): + yield Violation("locator-line-is-a-line-number", f"/locator/{key}", + f"is not a whole number no less than 1 ({_type(line)})") + if "target" in value: + yield from _target(value["target"]) + + +def _target(target: Any) -> Iterator[Violation]: + if not isinstance(target, str) or not target or _text_problem(target): + yield Violation("locator-target-is-short-text", _AT_TARGET, + f"is not text of 1 to {STRING_MAX} characters free of lone " + f"surrogates ({_type(target)})") + elif _NUL in target: + yield Violation(NO_NUL, _AT_TARGET, _NUL_DETAIL) + + +def _one_of(value: Any, allowed: tuple[str, ...], where: str, rule: str) -> Iterator[Violation]: + if not isinstance(value, str) or value not in allowed: + yield Violation(rule, where, f"is not one of {', '.join(allowed)}") + + +def _message(value: Any) -> Iterator[Violation]: + if (not isinstance(value, str) or not value or len(value) > STRING_MAX + or _NOT_ONE_LINE.search(value)): + size = f"{len(value)} characters" if isinstance(value, str) else _type(value) + yield Violation("message-is-one-bounded-line", "/message", + f"is not one line of 1 to {STRING_MAX} characters free of control " + f"characters, line separators and lone surrogates ({size})") + + +def _evidence(value: Any) -> Iterator[Violation]: + if not isinstance(value, dict): + yield Violation("evidence-is-an-object", "/evidence", f"is {_type(value)}, not an " + "object") + return + yield from _walk(value, "/evidence", "evidence") + + +def violations(finding: Any) -> list[Violation]: + """Every rule `finding` breaks, the copy's and the engine's, in the + schema's field order, then the reference rules. Empty when the finding + is admitted.""" + if not isinstance(finding, dict): + return [Violation("finding-keys", "", f"a finding is an object, not {_type(finding)}")] + found: list[Violation] = [] + if any(not isinstance(key, str) for key in finding): + found.append(Violation(NOT_JSON, "", "has a field name that is not text")) + missing = [field for field in REQUIRED_FIELDS if field not in finding] + unknown = [key for key in finding if isinstance(key, str) and key not in FIELDS] + if missing: + found.append(Violation("finding-keys", "", f"lacks {', '.join(missing)}")) + if unknown: + found.append(Violation("finding-keys", "", f"carries {len(unknown)} field(s) the " + f"finding does not name; it names {', '.join(FIELDS)}")) + checks: dict[str, Any] = { + "id": lambda v: _id(v, "/id", "id-is-well-formed"), + "kind": lambda v: _name(v, "/kind", "kind-is-a-family-name"), + "pack_id": lambda v: _name(v, "/pack_id", "pack-id-is-a-name"), + "pack_version": _pack_version, + "path": _path, + "identity": lambda v: _identity(v, path=finding.get("path"), + kind=finding.get("kind")), + "locator": _locator, + "severity": lambda v: _one_of(v, SEVERITIES, "/severity", "severity-is-known"), + "resolution_class": lambda v: _one_of(v, RESOLUTION_CLASSES, _AT_RESOLUTION_CLASS, + "resolution-class-is-one-of-three"), + "message": _message, + "evidence": _evidence, + "baseline_class": lambda v: _one_of(v, BASELINE_CLASSES, "/baseline_class", + "baseline-class-is-known"), + } + broken: set[str] = set() + for field in FIELDS: + if field in finding: + mine = list(checks[field](finding[field])) + if mine: + broken.add(field) + found += mine + found += _conditional(finding, broken) + found += _references(finding, broken) + return found + + +def _conditional(finding: Mapping[str, Any], broken: set[str]) -> Iterator[Violation]: + """The class a pathless or collision finding must have. (The identity's + forms are judged with the identity.) Judged over a known class only, so a + class outside the three is reported once, by its own rule.""" + known = "resolution_class" in finding and "resolution_class" not in broken + if not known or finding["resolution_class"] == HUMAN_ONLY: + return + if finding.get("path") == "": + yield Violation("pathless-finding-is-human-only", _AT_RESOLUTION_CLASS, + "a pathless finding names no document a repair could edit, so it " + "is human-only") + if finding.get("kind") == IDENTITY_COLLISION: + yield Violation("identity-collision-is-human-only", _AT_RESOLUTION_CLASS, + "an identity collision is human-only") + + +def _references(finding: Mapping[str, Any], broken: set[str]) -> Iterator[Violation]: + """The three cross-field rules, each judged only over parts every rule of + their own admits.""" + def admitted(*fields: str) -> bool: + return all(field in finding and field not in broken for field in fields) + + if admitted("id", "kind", "pack_id"): + named_pack, named_kind, _h16 = finding["id"].split(".") + if (named_pack, named_kind) != (finding["pack_id"], finding["kind"]): + yield Violation("id-names-its-pack-and-kind", "/id", + f"names {named_pack} and {named_kind}, and the finding is " + f"{finding['pack_id']}'s {finding['kind']}") + if admitted("id", "kind", "pack_id", "path", "identity"): + # finding_id() is the ONE hash path (the holder's `6069507373`, T046 + # item 2), looked up at call time: a test that patches + # `health_contract.finding_id` patches this check and make_finding() alike. + want = finding_id(identity=finding["identity"], kind=finding["kind"], + pack_id=finding["pack_id"], path=finding["path"]).rsplit(".", 1)[1] + have = finding["id"].rsplit(".", 1)[1] + if have != want: + yield Violation("id-is-the-hash-of-its-key", "/id", + f"the hash is {have}, and the finding's key hashes to {want}") + locator = finding.get("locator") if "locator" not in broken else None + if isinstance(locator, dict) and "line_start" in locator and "line_end" in locator: + start, end = locator["line_start"], locator["line_end"] + if end < start: + # The lines are not quoted: a refusal names no value of the finding, + # and a line may be any integer JSON can write. + yield Violation("locator-span-is-ordered", "/locator/line_end", + "line_end is less than line_start, so the span ends before " + "it starts") + + +def check_finding(finding: Any) -> None: + """Refuse `finding`, with `FindingRefused` naming its first violation, + unless it breaks no rule.""" + found = violations(finding) + if found: + raise FindingRefused(found[0], len(found) - 1) + + +# --------------------------------------------------------------------------- +# the engine's door, and the module's own serialization +# --------------------------------------------------------------------------- + +def make_finding(*, kind: str, pack_id: str, pack_version: str, path: str, + identity: dict[str, Any], severity: str, resolution_class: str, + message: str, evidence: dict[str, Any] | None = None, + locator: dict[str, Any] | None = None, + baseline_class: str | None = None) -> dict[str, Any]: + """One finding, its id SET by the id rule and checked whole. + + A family or pack supplies the fields it owns; the engine passes the + `pack_id` and `pack_version` it stamps (15.7) and any `baseline_class` it + sets. There is no `id` parameter: the id is the engine's, and a pack's + own value never reaches a finding. `evidence` defaults to `{}`, so every + finding carries it (14.5; FR-011). The result is the module's own copy, + in the schema's field order; `FindingRefused` names the first rule broken.""" + fid = finding_id(identity=identity, kind=kind, pack_id=pack_id, path=path) + finding: dict[str, Any] = {"id": fid, "kind": kind, "pack_id": pack_id, + "pack_version": pack_version, "path": path, + "identity": identity} + if locator is not None: + finding["locator"] = locator + finding.update(severity=severity, resolution_class=resolution_class, message=message, + evidence={} if evidence is None else evidence) + if baseline_class is not None: + finding["baseline_class"] = baseline_class + check_finding(finding) + # Copied after the check, which bounds the nesting the copy recurses into. + return copy.deepcopy(finding) + + +def engine_finding(category: str, entry: str = "", *, pack_version: str, severity: str, + message: str, evidence: dict[str, Any] | None = None) -> dict[str, Any]: + """An install-level or pre-run finding, as the engine raises it (the + holder's `6069024023` item 1). Its `kind` is its category, its `path` is + empty, its identity is `{category, entry}`, and it is human-only. Its + `pack_id` is the one its category fixes (`6072086385` item 1): for + `ENTRY_CATEGORIES`, `entry`, the manifest entry's id, which is never empty + and never `opendox`; for every other category, `opendox`. An + install-level category (`INSTALL_CATEGORIES`) takes entry "", and a + non-empty one is refused (`6072197564` (e)). `pack_version` is that pack's: the + entry's version, or the installed version for `opendox`. The reasons ride + in `evidence`, so a run raises one such finding per category and entry, + and it keeps one id across runs.""" + identity = pathless_identity(category, entry) + pack_id = entry if category in ENTRY_CATEGORIES else OPENDOX + return make_finding(kind=category, pack_id=pack_id, pack_version=pack_version, path="", + identity=identity, severity=severity, resolution_class=HUMAN_ONLY, + message=message, evidence=evidence) + + +def to_json(finding: dict[str, Any]) -> str: + """The finding as this module serializes it: checked whole, then canonical + JSON (sorted keys, no whitespace, each character as itself). Nothing the + contract refuses is ever written.""" + check_finding(finding) + return canonical_json(finding).decode("utf-8") diff --git a/tests/test_health_contract.py b/tests/test_health_contract.py new file mode 100644 index 00000000..fef92d94 --- /dev/null +++ b/tests/test_health_contract.py @@ -0,0 +1,1819 @@ +"""openDox's health contract module, `opendox.health_contract` (plan 038 T041, +U-0; decisions N-3, N-13, N-15 and OQ-H15-19; R2Q10 (a), R2Q18 (a), R2Q22 (a) +and R2Q25 (a); the holder's rulings `openxFactory#656` `6018624750` and +`6027706377` item 1). + +THE FALSIFIER, SPLIT BY LAYER (`6027706377` item 1). T041's falsifier asserts +the bound on THIS MODULE'S OWN serialized finding shape: what `to_json()` +writes, the one serialization the contract module owns. What `health list +--json` emits is T046's falsifier, and what the HTTP Health response emits is +T057's. Neither surface exists here, and nothing below pretends to test it. +The bound, on a finding's `identity`, is openDox-spec's finding schema's (T040, +landed as openDox-spec `7db9438b`): + +* no key named `excerpt`, `text`, `content` or `quote`, at any depth; +* no number, at any depth; +* every string, key or value, at most 200 characters; +* `{category, entry}` for a pathless finding, and `{collided_id}` for a + collision. + +The falsifier is section 1: every identity the contract names, built through +the module and serialized, holds the bound, and every identity outside it is +refused before it can be serialized. + +WHAT ELSE IT HOLDS. + +2. THE VOCABULARY IS THE PACKAGED COPY'S. The classes, the severities, the + baseline classes, the fields, the forbidden keys, the string bound and the + rule catalog are read from openDox-code's digest-checked copy of the + finding schema and compared with the module's constants. The module + mirrors the copy; it never reads it (it may import the standard library + alone, R2Q18 (a)). +3. THE ID RULE (N-13, refined by ADV-07). The key is exactly the four fields, + in canonical sorted-key JSON, UTF-8, each character as itself. Every id in + openDox-spec's five finding examples is the id the module computes, each + part of the key moves the id, and nothing outside the key does. +4. NAMES OF 1 TO 40 CHARACTERS. `pack_id` and `kind` are capped at 40, so a + fix branch's name stays bounded. 40 is admitted, 41 refused, and the + schema itself admits 41, so the cap is the engine's, stricter. +5. THE IDENTITY'S SERIALIZED-SIZE CAP, the engine's, stricter than the schema + (`IDENTITY_MAX_BYTES`, which T042 stores and T046 enforces): admitted at + its bound, refused one byte over, and an identity the schema admits is + refused by it. The nesting bound beside it. +6. THE BOUNDS ON `message` AND `evidence` (R2Q25 (a); ADV-27). +7. EVERY RULE THE COPY CATALOGUES IS ENFORCED. Each of its 27 rules has one + negative case, openDox-spec's own negative example rebuilt, refused for + that rule and no other; so are the module's five engine rules. +8. THE ENGINE'S OWN FINDINGS (the holder's `6069024023`): the eleven ruled + categories, each one finding per category and entry with one id across + runs and the `pack_id` its category takes (`engine_finding()`; `6072086385` + item 1); the disappearance re-raise in both forms, option (D); and + openDox-spec's five examples rebuilt through `make_finding()` and + `engine_finding()`, id included. +9. THE CLOSURE: the module imports the standard library and nothing else. Its + imports are read from its source, it is imported in a fresh interpreter + that blocks openXdox and openxFactory, and it runs from a directory that + holds it alone, under `python -I -S`, with no site-packages: what the + sandbox binds (R2Q18 (a), T048). +10. A REFUSAL QUOTES NOTHING OF THE FINDING: no refused string, key or value + reaches a refusal's text. + +`--noconftest` SAFE. A CREATED file: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import ast +import copy +import datetime +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any, Iterator + +import pytest +import yaml + +from opendox import contracts +from opendox import health_contract as hc + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" +MODULE = SRC / "opendox" / "health_contract.py" + +#: The packaged copy of openDox-spec's finding schema (T040), which the +#: module mirrors (N-15: a copy the engine reads, not a validator kind). +FINDING_COPY = "opendox-health-finding" + +#: The bound, as T040's schema and the holder's `6018624750` state it, written +#: here apart from the module so that a changed constant there fails here. +FORBIDDEN = ("excerpt", "text", "content", "quote") +STRING_MAX = 200 +NAME_MAX = 40 +IDENTITY_MAX_BYTES = 2048 + +#: openDox-spec's five finding examples at `7db9438b` +#: (`examples/health/opendox-health-finding-*.example.yaml`), as values. Each +#: id is the one openDox-spec's own test computes by the id rule. +EXAMPLES: dict[str, dict[str, Any]] = { + "broken-link": { + "id": "opendox.broken-link.546cd2aacb1a6738", + "kind": "broken-link", + "pack_id": "opendox", + "pack_version": "0.2.0", + "path": "notes/plan.md", + "identity": {"target": "../old/brief.md"}, + "locator": {"line_start": 12, "line_end": 12}, + "severity": "warning", + "resolution_class": "auto-fix", + "message": "link target does not exist; a unique file of that name exists elsewhere", + "evidence": {"candidate": "archive/brief.md", "family_version": "1"}, + "baseline_class": "new", + }, + "identity-collision": { + "id": "house-style.identity-collision.b722027596ad8c96", + "kind": "identity-collision", + "pack_id": "house-style", + "pack_version": "1.4.0", + "path": "notes/plan.md", + "identity": {"collided_id": "house-style.heading-case.0f1e2d3c4b5a6978"}, + "severity": "error", + "resolution_class": "human-only", + "message": ("two findings of one run share one id; the pack must give them " + "identity keys that differ"), + "evidence": {"collided_id": "house-style.heading-case.0f1e2d3c4b5a6978", + "count": 2}, + "baseline_class": "new", + }, + "no-sandbox": { + "id": "opendox.no-sandbox.5ccbb79891acb2e8", + "kind": "no-sandbox", + "pack_id": "opendox", + "pack_version": "0.2.0", + "path": "", + "identity": {"category": "no-sandbox", "entry": ""}, + "severity": "warning", + "resolution_class": "human-only", + "message": "no live sandbox on this install, so no check pack ran", + "evidence": {"probe": "bwrap", "verdict": "absent"}, + }, + "orphan": { + "id": "opendox.orphan.516a58a1fcc2c37f", + "kind": "orphan", + "pack_id": "opendox", + "pack_version": "0.2.0", + "path": "notes/appendix.md", + "identity": {}, + "severity": "info", + "resolution_class": "human-only", + "message": "nothing links to this document", + "evidence": {}, + "baseline_class": "persistent", + }, + "pack-upgrade": { + "id": "house-style.heading-case.a5db1a71f8b8340d", + "kind": "heading-case", + "pack_id": "house-style", + "pack_version": "1.4.0", + "path": "notes/caf\u00e9-menu.md", + "identity": {"heading": "seasonal-specials"}, + "locator": {"line_start": 3, "line_end": 3}, + "severity": "info", + "resolution_class": "assisted", + "message": "a heading is not in the house case", + "evidence": {"family_version": "2", "lines": [3, 3]}, + "baseline_class": "pack-upgrade", + }, +} + +#: openDox-spec's 201-character passage, from its negative examples. +PASSAGE = ("The old brief said the launch would wait for the second review, and that " + "the budget would be settled by the steering group before any vendor was " + "asked to quote; this sentence is document text, two hund") + + +def _example(name: str) -> dict[str, Any]: + return copy.deepcopy(EXAMPLES[name]) + + +def _rules(finding: Any) -> list[str]: + return [v.rule for v in hc.violations(finding)] + + +def _independent_id(pack_id: str, kind: str, path: str, identity: Any) -> str: + """The id rule as openDox-spec's own test spells it, apart from the + module.""" + key = json.dumps({"identity": identity, "kind": kind, "pack_id": pack_id, "path": path}, + sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return f"{pack_id}.{kind}.{hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]}" + + +def _walk(value: Any) -> Iterator[tuple[str, Any]]: + """Every (role, item) under `value`: role is "key" for an object's key, + "value" for anything else, at any depth.""" + stack = [value] + while stack: + item = stack.pop() + yield "value", item + if isinstance(item, dict): + for key, inner in item.items(): + yield "key", key + stack.append(inner) + elif isinstance(item, list): + stack.extend(item) + + +def _fields(finding: dict[str, Any]) -> dict[str, Any]: + """`make_finding()`'s arguments for an example: every field but the + engine's id.""" + return {k: v for k, v in finding.items() if k != "id"} + + +# --------------------------------------------------------------------------- +# 1. the falsifier: the module's own serialized identity holds the bound +# --------------------------------------------------------------------------- + +#: A pair of corpus paths, each at the 200-character string bound. +_PATH_A = "notes/" + "a" * 191 + ".md" +_PATH_B = "notes/" + "b" * 191 + ".md" + +#: Each identity the contract names, as (path, kind, identity, class). +_IDENTITIES: dict[str, tuple[str, str, Any, str]] = { + "a link target as written": ("notes/plan.md", "broken-link", + {"target": "../old/brief.md"}, "auto-fix"), + "a pair of paths": ("notes/plan.md", "near-duplicate", + {"first": _PATH_A, "second": _PATH_B}, "assisted"), + "a heading key": ("notes/caf\u00e9-menu.md", "heading-case", + {"heading": "seasonal-specials"}, "assisted"), + "every value an identity admits": ( + "notes/plan.md", "house-rule", + {"flags": [True, False, None], "nested": {"k": ["a", {"b": "c"}]}, + "long": "x" * 200, "y" * 200: "short", "empty": [], "none": None}, + "human-only"), + "an empty identity": ("notes/appendix.md", "orphan", {}, "human-only"), + "a pathless finding": ("", "no-sandbox", hc.pathless_identity("no-sandbox"), + "human-only"), + "a pathless finding about an entry": ( + "", "fetch-failed", hc.pathless_identity("fetch-failed", "house-style"), + "human-only"), + "a collision": ("notes/plan.md", "identity-collision", + hc.collision_identity("house-style.heading-case.0f1e2d3c4b5a6978"), + "human-only"), + "a refused manifest, the whole file": ( + "", "manifest-refused", hc.pathless_identity("manifest-refused"), "human-only"), + "a refused dispositions file": ( + "", "dispositions-refused", hc.pathless_identity("dispositions-refused"), "human-only"), + "the disappearance of a pathed finding": ( + "notes/plan.md", "uncited-disappearance", + hc.disappearance_identity(EXAMPLES["broken-link"]), "human-only"), + "the disappearance of a pathless finding": ( + "", "uncited-disappearance", + hc.disappearance_identity(EXAMPLES["no-sandbox"]), "human-only"), + "a pathless collision": ("", "identity-collision", + hc.collision_identity("opendox.no-sandbox.5ccbb79891acb2e8"), + "human-only"), +} + + +def _build(name: str) -> dict[str, Any]: + path, kind, identity, resolution = _IDENTITIES[name] + return hc.make_finding(kind=kind, pack_id="opendox", pack_version="0.2.0", path=path, + identity=identity, severity="warning", + resolution_class=resolution, message="a finding") + + +@pytest.mark.parametrize("name", list(_IDENTITIES)) +def test_the_serialized_identity_holds_the_schemas_bound(name: str) -> None: + """THE FALSIFIER (T041, split by layer, `6027706377` item 1): the + identity this module writes holds exactly T040's bound. It is stored and + emitted with the finding, and the id is its hash (`6018624750`).""" + finding = _build(name) + emitted = json.loads(hc.to_json(finding)) + identity = emitted["identity"] + assert isinstance(identity, dict) + keys = [item for role, item in _walk(identity) if role == "key"] + values = [item for role, item in _walk(identity) if role == "value"] + # no key named excerpt, text, content or quote, at any depth + assert not set(keys) & set(FORBIDDEN), keys + # no number, at any depth (true and false are not numbers) + assert not [v for v in values + if isinstance(v, (int, float)) and not isinstance(v, bool)], values + # every string, key or value, at most 200 characters + strings = keys + [v for v in values if isinstance(v, str)] + assert all(len(s) <= STRING_MAX for s in strings), [len(s) for s in strings] + # the engine's own keys + if emitted["kind"] == "identity-collision": + assert sorted(identity) == ["collided_id"] + elif emitted["path"] == "": + assert sorted(identity) == ["category", "entry"] + # stored and emitted, and hashed into the id + assert identity == _IDENTITIES[name][2] + assert emitted["id"] == _independent_id(emitted["pack_id"], emitted["kind"], + emitted["path"], identity) + assert hc.identity_json(identity) == json.dumps( + identity, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +_REFUSED_IDENTITIES = [ + *[({word: "a"}, "notes/plan.md", "broken-link", "identity-names-no-text") + for word in FORBIDDEN], + *[({"a": {word: "b"}}, "notes/plan.md", "broken-link", "identity-names-no-text") + for word in FORBIDDEN], + *[({"a": [{word: "b"}]}, "notes/plan.md", "broken-link", "identity-names-no-text") + for word in FORBIDDEN], + ({"target": "../old/brief.md", "line": 12}, "notes/plan.md", "broken-link", + "identity-holds-no-number"), + ({"a": 1.5}, "notes/plan.md", "broken-link", "identity-holds-no-number"), + ({"a": 0}, "notes/plan.md", "broken-link", "identity-holds-no-number"), + ({"a": -3}, "notes/plan.md", "broken-link", "identity-holds-no-number"), + ({"a": [True, 1]}, "notes/plan.md", "broken-link", "identity-holds-no-number"), + ({"a": {"b": 1e21}}, "notes/plan.md", "broken-link", "identity-holds-no-number"), + ({"target": PASSAGE}, "notes/plan.md", "broken-link", + "identity-strings-are-short-text"), + ({"a" * 201: "b"}, "notes/plan.md", "broken-link", "identity-strings-are-short-text"), + ({"a": ["x" * 201]}, "notes/plan.md", "broken-link", "identity-strings-are-short-text"), + ({"a": {"b": "x" * 201}}, "notes/plan.md", "broken-link", + "identity-strings-are-short-text"), + ({"a": {"b" * 201: "x"}}, "notes/plan.md", "broken-link", + "identity-strings-are-short-text"), + (["../old/brief.md"], "notes/plan.md", "broken-link", "identity-is-an-object"), + ("../old/brief.md", "notes/plan.md", "broken-link", "identity-is-an-object"), + ({}, "", "no-sandbox", "pathless-identity-is-category-and-entry"), + ({"category": "no-sandbox"}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + ({"entry": ""}, "", "no-sandbox", "pathless-identity-is-category-and-entry"), + ({"category": "no-sandbox", "entry": "", "detail": "x"}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + ({"category": "No_Sandbox", "entry": ""}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + ({"category": "no-sandbox", "entry": "House"}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + # the holder's 6072197564: (b) a closed set of categories; (e) one + # install-level finding per run, so its entry is "" + ({"category": "orphan", "entry": ""}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + ({"category": "no-sandbox", "entry": "house-style"}, "", "no-sandbox", + "pathless-identity-is-category-and-entry"), + ({"target": "../old/brief.md"}, "", "broken-link", + "pathless-identity-is-category-and-entry"), + ({"id": "house-style.heading-case.0f1e2d3c4b5a6978"}, "notes/plan.md", + "identity-collision", "collision-identity-is-the-collided-id"), + ({"collided_id": "house-style.heading-case.0f1e2d3c4b5a6978", "count": "2"}, + "notes/plan.md", "identity-collision", "collision-identity-is-the-collided-id"), + ({"collided_id": "not an id"}, "notes/plan.md", "identity-collision", + "collision-identity-is-the-collided-id"), + # U+0000, which jsonb cannot store (the holder's 6072086385 item 4) + ({"target": "../old/brief\x00.md"}, "notes/plan.md", "broken-link", "text-holds-no-nul"), + ({"tar\x00get": "../old/brief.md"}, "notes/plan.md", "broken-link", "text-holds-no-nul"), + ({"a": ["b", "\x00"]}, "notes/plan.md", "broken-link", "text-holds-no-nul"), + ({"a": {"\x00": "b"}}, "notes/plan.md", "broken-link", "text-holds-no-nul"), + ({"category": "no-sandbox", "entry": ""}, "", "identity-collision", + "collision-identity-is-the-collided-id"), +] + + +@pytest.mark.parametrize("identity, path, kind, rule", _REFUSED_IDENTITIES, + ids=[f"{case[3]}-{index}" for index, case + in enumerate(_REFUSED_IDENTITIES)]) +def test_an_identity_outside_the_bound_is_never_serialized( + identity: Any, path: str, kind: str, rule: str) -> None: + """The other half of the falsifier: an identity that breaks the bound is + refused by `make_finding()`, and a finding assembled by hand around it, + with the right id, is refused by `to_json()`. So no serialization this + module writes can carry it.""" + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(kind=kind, pack_id="opendox", pack_version="0.2.0", path=path, + identity=identity, severity="info", resolution_class="human-only", + message="a finding") + assert refused.value.rule == rule, refused.value + by_hand = {"id": _independent_id("opendox", kind, path, identity), "kind": kind, + "pack_id": "opendox", "pack_version": "0.2.0", "path": path, + "identity": identity, "severity": "info", "resolution_class": "human-only", + "message": "a finding", "evidence": {}} + with pytest.raises(hc.FindingRefused) as again: + hc.to_json(by_hand) + assert again.value.rule == rule, again.value + assert rule in _rules(by_hand) + + +# --------------------------------------------------------------------------- +# 2. the vocabulary is the packaged copy's +# --------------------------------------------------------------------------- + +def test_the_vocabulary_is_the_packaged_copys() -> None: + """The module mirrors the digest-checked copy of T040's schema, constant + for constant, and 14.6's three classes are spelled exactly.""" + schema = contracts.load(FINDING_COPY) + props = schema["properties"] + defs = schema["$defs"] + assert hc.RESOLUTION_CLASSES == tuple(props["resolution_class"]["enum"]) == ( + "auto-fix", "assisted", "human-only") + assert hc.RESOLUTION_CLASSES == (hc.AUTO_FIX, hc.ASSISTED, hc.HUMAN_ONLY) + assert hc.SEVERITIES == tuple(props["severity"]["enum"]) == ("error", "warning", "info") + assert hc.BASELINE_CLASSES == tuple(props["baseline_class"]["enum"]) == ( + "new", "pack-upgrade", "persistent") + assert hc.REQUIRED_FIELDS == tuple(schema["required"]) + assert hc.REQUIRED_FIELDS + hc.OPTIONAL_FIELDS == tuple( + field for field in props if field in schema["required"]) + tuple( + field for field in props if field not in schema["required"]) + assert hc.FIELDS == tuple(props) + # the holder's 6072197564 (d): a pack's id is ignored; the rest are the engine's + assert hc.IGNORED_FIELDS == ("id",) + assert hc.STAMPED_FIELDS == ("pack_id", "pack_version", "baseline_class") + assert set(hc.IGNORED_FIELDS + hc.STAMPED_FIELDS) < set(hc.FIELDS) + for name in ("identity_key", "evidence_key"): + assert tuple(defs[name]["allOf"][0]["not"]["enum"]) == hc.FORBIDDEN_KEYS == FORBIDDEN + assert defs[name]["allOf"][1]["maxLength"] == hc.STRING_MAX == STRING_MAX + assert defs["identity_value"]["allOf"][1]["maxLength"] == STRING_MAX + assert defs["evidence_value"]["maxLength"] == STRING_MAX + assert props["message"]["maxLength"] == STRING_MAX + assert props["locator"]["properties"]["target"]["maxLength"] == STRING_MAX + assert hc.OPENDOX == "opendox" + assert hc.IDENTITY_COLLISION == "identity-collision" + assert hc.UNCITED_DISAPPEARANCE == "uncited-disappearance" + + +def test_the_rule_catalog_is_the_copys_plus_the_engines_own() -> None: + schema = contracts.load(FINDING_COPY) + catalog = {rule["id"]: rule["class"] for rule in schema["x-rules"]} + assert len(catalog) == 27 + assert set(hc.SHAPE_RULES) == {r for r, c in catalog.items() if c == "shape"} + assert set(hc.REFERENCE_RULES) == {r for r, c in catalog.items() if c == "reference"} == { + "id-is-the-hash-of-its-key", "id-names-its-pack-and-kind", "locator-span-is-ordered"} + assert len(hc.SHAPE_RULES) == len(set(hc.SHAPE_RULES)) == 24 + assert set(hc.ENGINE_RULES) == {"name-is-at-most-40-characters", + "identity-is-within-the-size-cap", + "nesting-is-within-the-cap", "finding-is-json", + "text-holds-no-nul"} + assert not set(hc.ENGINE_RULES) & set(catalog) + + +def test_the_id_rule_is_the_one_the_copys_header_spells() -> None: + """The copy's header gives the canonical JSON in Python; the module's key + is those bytes.""" + header = contracts.verified_bytes(FINDING_COPY).decode("utf-8").split("{\n", 1)[0] + assert 'json.dumps(key, sort_keys=True, separators=(",", ":"),' in header + assert 'ensure_ascii=False).encode("utf-8")' in header + finding = _example("broken-link") + key = {"identity": finding["identity"], "kind": finding["kind"], + "pack_id": finding["pack_id"], "path": finding["path"]} + assert hc.id_key(identity=finding["identity"], kind=finding["kind"], + pack_id=finding["pack_id"], path=finding["path"]) == json.dumps( + key, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +# --------------------------------------------------------------------------- +# 3. the id rule +# --------------------------------------------------------------------------- + +def test_the_key_is_exactly_the_contracts() -> None: + finding = _example("broken-link") + key = hc.id_key(identity=finding["identity"], kind=finding["kind"], + pack_id=finding["pack_id"], path=finding["path"]) + assert key == (b'{"identity":{"target":"../old/brief.md"},"kind":"broken-link",' + b'"pack_id":"opendox","path":"notes/plan.md"}') + assert hashlib.sha256(key).hexdigest()[:16] == "546cd2aacb1a6738" + + +@pytest.mark.parametrize("name", sorted(EXAMPLES)) +def test_every_example_id_is_the_id_the_module_computes(name: str) -> None: + """openDox-spec's five examples, each id computed by its own test, are the + ids this module computes, and each example breaks no rule here.""" + finding = _example(name) + assert hc.finding_id(identity=finding["identity"], kind=finding["kind"], + pack_id=finding["pack_id"], path=finding["path"]) == finding["id"] + assert hc.violations(finding) == [] + hc.check_finding(finding) + + +@pytest.mark.parametrize("field, value", [ + ("identity", {"target": "../old/brief-2.md"}), + ("kind", "orphan"), + ("pack_id", "house-style"), + ("path", "notes/plan-2.md"), +]) +def test_each_part_of_the_key_moves_the_id(field: str, value: Any) -> None: + """A hash that ignored any one of the four parts would leave the id + unmoved here.""" + finding = _example("broken-link") + fields = {"identity": finding["identity"], "kind": finding["kind"], + "pack_id": finding["pack_id"], "path": finding["path"]} + moved = hc.finding_id(**{**fields, field: value}) + assert moved != finding["id"] + assert moved.rsplit(".", 1)[1] != finding["id"].rsplit(".", 1)[1] + assert moved == _independent_id(**{**fields, field: value}) + changed = {**finding, field: value} + assert set(_rules(changed)) & {"id-is-the-hash-of-its-key", "id-names-its-pack-and-kind"} + + +@pytest.mark.parametrize("field, value", [ + ("locator", {"line_start": 40, "line_end": 41}), + ("locator", None), + ("severity", "error"), + ("message", "another message"), + ("evidence", {}), + ("pack_version", "9.9.9"), + ("baseline_class", "persistent"), + ("resolution_class", "assisted"), +]) +def test_nothing_outside_the_key_moves_the_id(field: str, value: Any) -> None: + """An edit above a finding moves its locator and keeps its id, so an + exception keyed by that id keeps suppressing it (N-13, ADV-07).""" + fields = _fields(_example("broken-link")) + if value is None: + del fields[field] + else: + fields[field] = value + assert hc.make_finding(**fields)["id"] == EXAMPLES["broken-link"]["id"] + + +def test_the_id_hashes_each_character_as_itself() -> None: + """UTF-8, never a \\u escape: the pack-upgrade example's path holds a + character outside ASCII.""" + finding = _example("pack-upgrade") + assert not finding["path"].isascii() + key = {"identity": finding["identity"], "kind": finding["kind"], + "pack_id": finding["pack_id"], "path": finding["path"]} + escaped = json.dumps(key, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + assert hc.finding_id(**key) == finding["id"] + assert not finding["id"].endswith(hashlib.sha256(escaped.encode()).hexdigest()[:16]) + + +def test_the_order_of_an_identitys_keys_does_not_move_the_id() -> None: + one = {"b": "2", "a": {"d": "4", "c": "3"}} + two = {"a": {"c": "3", "d": "4"}, "b": "2"} + assert list(one) != list(two) + ids = {hc.finding_id(identity=identity, kind="k", pack_id="p", path="x.md") + for identity in (one, two)} + assert len(ids) == 1 + + +def test_finding_id_is_the_one_hash_path(monkeypatch: pytest.MonkeyPatch) -> None: + """The module-level `finding_id()` is the SINGLE hash path (the holder's + `6069507373`, T046 item 2): a test that patches it patches both the id + `make_finding()` sets and the id the reference rule checks.""" + real = hc.finding_id + seen: list[dict[str, Any]] = [] + + def patched(**key: Any) -> str: + seen.append(key) + return real(**key)[:-16] + "0" * 16 + + monkeypatch.setattr(hc, "finding_id", patched) + built = hc.make_finding(**_fields(_example("broken-link"))) + assert built["id"] == "opendox.broken-link.0000000000000000" + assert len(seen) == 2, "the id it set, and the id its own check recomputed" + assert hc.violations(built) == [] + assert _rules(_example("broken-link")) == ["id-is-the-hash-of-its-key"] + assert len(seen) == 4 + + +def test_a_pack_cannot_set_the_engines_fields() -> None: + """The id, and nothing a family supplies, is the engine's: `make_finding()` + takes no id.""" + fields = _fields(_example("broken-link")) + with pytest.raises(TypeError): + hc.make_finding(id="opendox.broken-link.0000000000000000", **fields) + + +def test_every_id_is_a_git_branch_name() -> None: + """R2Q10 (a): `health-fix-` is a branch name, and the longest id, two + 40-character names and the hash, is 98 characters.""" + git = shutil.which("git") + assert git, "git is required here, as on CI" + longest = hc.finding_id(identity={}, kind="k" * 40, pack_id="p" * 40, path="a.md") + assert len(longest) == 98 == hc.ID_MAX + ids = [finding["id"] for finding in EXAMPLES.values()] + [longest] + for fid in ids: + done = subprocess.run([git, "check-ref-format", "--branch", f"health-fix-{fid}"], + capture_output=True, text=True, timeout=60) + assert done.returncode == 0, (fid, done.stderr) + + +# --------------------------------------------------------------------------- +# 4. names of 1 to 40 characters +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("field", ["pack_id", "kind"]) +def test_a_name_of_40_characters_is_admitted_and_41_is_refused(field: str) -> None: + fields = _fields(_example("orphan")) + forty, forty_one = "a-" * 20, "a-" * 20 + "b" + assert (len(forty), len(forty_one)) == (40, 41) + admitted = hc.make_finding(**{**fields, field: forty}) + assert admitted[field] == forty + assert len(admitted["id"]) <= hc.ID_MAX + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(**{**fields, field: forty_one}) + assert refused.value.rule == "name-is-at-most-40-characters" + assert refused.value.where == f"/{field}" + by_hand = {**fields, field: forty_one} + by_hand["id"] = _independent_id(by_hand["pack_id"], by_hand["kind"], by_hand["path"], + by_hand["identity"]) + assert set(_rules(by_hand)) == {"name-is-at-most-40-characters"} + assert [v.where for v in hc.violations(by_hand)] == ["/id", f"/{field}"] + + +@pytest.mark.parametrize("field", ["pack_id", "kind"]) +def test_the_schema_itself_admits_41_so_the_cap_is_the_engines(field: str) -> None: + pattern = contracts.load(FINDING_COPY)["properties"][field]["pattern"] + assert re.search(pattern, "a" * 41) + assert re.search(pattern, "a" * 41 + "\n") is None + + +@pytest.mark.parametrize("field, rule", [("pack_id", "pack-id-is-a-name"), + ("kind", "kind-is-a-family-name")]) +@pytest.mark.parametrize("value", ["", "House", "house_style", "house.style", "h\u00e9", + "house\n", 7, None]) +def test_a_name_outside_the_alphabet_is_refused(field: str, rule: str, value: Any) -> None: + finding = {**_example("orphan"), field: value} + assert rule in _rules(finding) + + +def test_a_collision_naming_a_41_character_part_is_refused() -> None: + assert hc.collision_identity("p" * 40 + "." + "k" * 40 + "." + "0" * 16) + with pytest.raises(hc.FindingRefused) as refused: + hc.collision_identity("p" * 41 + ".k." + "0" * 16) + assert refused.value.rule == "name-is-at-most-40-characters" + + +# --------------------------------------------------------------------------- +# 5. the identity's serialized-size cap, and the nesting bound +# --------------------------------------------------------------------------- + +def _canonical(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + + +def _identity_of(size: int, char: str = "x") -> dict[str, str]: + """An identity whose canonical JSON is exactly `size` bytes, every string + in it at most 200 characters. Fillers of `char` close the gap until one + last ASCII value can close it exactly; each filler leaves 20 bytes of + margin, more than the next key costs, so the gap never goes negative.""" + identity: dict[str, str] = {} + width = len(char.encode("utf-8")) + for index in range(1000): + key = f"k{index:03d}" + gap = size - len(_canonical({**identity, key: ""})) + assert gap >= 0, f"no identity of {size} bytes" + if gap <= STRING_MAX: + identity[key] = "x" * gap + assert len(_canonical(identity)) == size + return identity + identity[key] = char * min(STRING_MAX, (gap - 20) // width) + raise AssertionError(f"no identity of {size} bytes") + + +def test_the_cap_is_2048_bytes_of_canonical_utf8() -> None: + assert hc.IDENTITY_MAX_BYTES == IDENTITY_MAX_BYTES + + +@pytest.mark.parametrize("char", ["x", "\u00e9", "\u20ac", "\U0001f50e"]) +def test_the_identity_cap_admits_its_bound_and_refuses_one_byte_over(char: str) -> None: + at = _identity_of(IDENTITY_MAX_BYTES, char) + over = _identity_of(IDENTITY_MAX_BYTES + 1, char) + assert len(_canonical(at)) == IDENTITY_MAX_BYTES + assert len(_canonical(over)) == IDENTITY_MAX_BYTES + 1 + fields = _fields(_example("orphan")) + admitted = hc.make_finding(**{**fields, "identity": at}) + assert len(hc.identity_json(admitted["identity"]).encode("utf-8")) == IDENTITY_MAX_BYTES + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(**{**fields, "identity": over}) + assert (refused.value.rule, refused.value.where) == ( + "identity-is-within-the-size-cap", "/identity") + with pytest.raises(hc.FindingRefused): + hc.identity_json(over) + with pytest.raises(hc.FindingRefused): + hc.finding_id(identity=over, kind="orphan", pack_id="opendox", path="a.md") + + +def test_the_cap_is_stricter_than_the_schema() -> None: + """An identity the schema admits (every string at most 200 characters, no + number, no forbidden key) is refused by the cap alone.""" + over = _identity_of(IDENTITY_MAX_BYTES + 1) + assert all(len(k) <= STRING_MAX and len(v) <= STRING_MAX for k, v in over.items()) + assert not set(over) & set(FORBIDDEN) + finding = {**_example("orphan"), "identity": over} + finding["id"] = _independent_id("opendox", "orphan", finding["path"], over) + assert _rules(finding) == ["identity-is-within-the-size-cap"] + + +def test_the_plans_largest_identity_fits_in_every_utf8_width() -> None: + """A pair of paths, each at the 200-character bound in four-byte + characters, under two 40-character keys, fits the cap.""" + widest = {"a" * 40: "\U0001f50e" * 200, "b" * 40: "\U0001f50e" * 200} + assert len(_canonical(widest)) < IDENTITY_MAX_BYTES + hc.make_finding(**{**_fields(_example("orphan")), "identity": widest}) + + +def _nested(levels: int, leaf: Any = "x") -> dict[str, Any]: + """An object `levels` containers deep, counting itself: lists inside.""" + value: Any = leaf + for _ in range(levels - 1): + value = [value] + return {"a": value} + + +@pytest.mark.parametrize("field", ["identity", "evidence"]) +def test_the_nesting_bound_admits_its_bound_and_refuses_one_level_over(field: str) -> None: + assert hc.NESTING_MAX == 16 + fields = _fields(_example("orphan")) + hc.make_finding(**{**fields, field: _nested(16)}) + over = {**fields, field: _nested(17)} + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(**over) + assert refused.value.rule == "nesting-is-within-the-cap" + + +@pytest.mark.parametrize("field", ["identity", "evidence"]) +def test_a_deep_value_is_refused_never_a_recursion_error(field: str) -> None: + """A value nested past Python's recursion limit is judged, and refused, + by the bound: the walk does not recurse, and nothing deep reaches + `json.dumps`.""" + deep = _nested(sys.getrecursionlimit() * 5) + finding = {**_example("orphan"), field: deep} + assert "nesting-is-within-the-cap" in _rules(finding) + with pytest.raises(hc.FindingRefused): + hc.to_json(finding) + fields = {**_fields(_example("orphan")), field: deep} + with pytest.raises(hc.FindingRefused): + hc.make_finding(**fields) + + +# --------------------------------------------------------------------------- +# 6. the bounds on message and evidence (R2Q25 (a); ADV-27) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("message, admitted", [ + ("x" * 200, True), + ("caf\u00e9 \u2713 \U0001f50e", True), + ("x" * 201, False), + ("", False), + *[(f"a{c}b", False) for c in ("\n", "\r", "\t", "\x00", "\x1f", "\x7f", "\x85", "\x9f", + "\u2028", "\u2029", "\ud800", "\udfff")], + ("\u00a0 is admitted", True), + (None, False), + (["a line"], False), +]) +def test_the_message_is_one_bounded_line(message: Any, admitted: bool) -> None: + finding = {**_example("broken-link"), "message": message} + rules = _rules(finding) + assert rules == ([] if admitted else ["message-is-one-bounded-line"]) + + +@pytest.mark.parametrize("evidence, where, rule", [ + ({}, None, None), + ({"span": [3, 4], "ok": True, "none": None, "digest": "ab" * 32, "ratio": 0.5}, None, None), + ({"a": "x" * 200}, None, None), + ({"x" * 200: "a"}, None, None), + ({"a": "\U0001f50e caf\u00e9"}, None, None), + ({"count": 2, "paths": ["a.md", "b.md"]}, None, None), + ({"a": "x" * 201}, "/evidence/a", "evidence-strings-are-short-text"), + ({"a": {"b": ["x" * 201]}}, "/evidence/a/b/0", "evidence-strings-are-short-text"), + ({"x" * 201: "a"}, "/evidence", "evidence-strings-are-short-text"), + ({"a": {"x" * 201: 1}}, "/evidence/a", "evidence-strings-are-short-text"), + ({"a": "\udc80"}, "/evidence/a", "evidence-strings-are-short-text"), + *[({word: "a"}, "/evidence", "evidence-names-no-text") for word in FORBIDDEN], + *[({"a": [{"b": {word: "c"}}]}, "/evidence/a/0/b", "evidence-names-no-text") + for word in FORBIDDEN], + ({"a/b": {"c~d": "x" * 201}}, "/evidence/a~1b/c~0d", "evidence-strings-are-short-text"), + ("archive/brief.md", "/evidence", "evidence-is-an-object"), + (["archive/brief.md"], "/evidence", "evidence-is-an-object"), + ({"a": float("nan")}, "/evidence/a", "finding-is-json"), + ({"a": float("inf")}, "/evidence/a", "finding-is-json"), + ({"a": ("a", "b")}, "/evidence/a", "finding-is-json"), + ({"a": b"bytes"}, "/evidence/a", "finding-is-json"), + ({1: "a"}, "/evidence", "finding-is-json"), + ({"a": 10 ** 5000}, "/evidence/a", "finding-is-json"), + ({"a": "b\x00"}, "/evidence/a", "text-holds-no-nul"), + ({"a\x00": "b"}, "/evidence", "text-holds-no-nul"), + ({"a": [{"b": "\x00"}]}, "/evidence/a/0/b", "text-holds-no-nul"), +]) +def test_evidence_holds_locators_only(evidence: Any, where: str | None, + rule: str | None) -> None: + finding = {**_example("broken-link"), "evidence": evidence} + found = hc.violations(finding) + if rule is None: + assert found == [] + assert json.loads(hc.to_json(finding))["evidence"] == evidence + else: + assert [(v.rule, v.where) for v in found] == [(rule, where)] + + +@pytest.mark.parametrize("field, value, rule, where", [ + ("identity", {"target": "../old/brief.md\x00"}, "text-holds-no-nul", "/identity/target"), + ("identity", {"tar\x00get": "../old/brief.md"}, "text-holds-no-nul", "/identity"), + ("identity", {"a": [{"b": "\x00"}]}, "text-holds-no-nul", "/identity/a/0/b"), + ("identity", {"k" * 189: {"x": "\x00"}}, "text-holds-no-nul", "/identity/" + "k" * 189), + ("evidence", {"a": "\x00"}, "text-holds-no-nul", "/evidence/a"), + ("evidence", {"\x00": "a"}, "text-holds-no-nul", "/evidence"), + ("locator", {"target": "\x00"}, "text-holds-no-nul", "/locator/target"), + ("message", "a\x00b", "message-is-one-bounded-line", "/message"), + ("path", "notes/pl\x00an.md", "path-is-corpus-relative", "/path"), + ("pack_version", "0.2.0\x00", "pack-version-is-text", "/pack_version"), +], ids=["identity value", "identity key", "identity, deep", "identity, past the bound", + "evidence value", "evidence key", "locator target", "message", "path", + "pack_version"]) +def test_u0000_is_refused_in_every_string_a_finding_carries(field: str, value: Any, + rule: str, where: str) -> None: + """The holder's `6072086385` item 4: Postgres `jsonb` cannot store U+0000 + (SQLSTATE 22P05), so no string a finding carries holds it, keys and values + alike. The schema admits it in `identity`, `evidence` and `locator.target`, + where the engine's rule refuses it; in `message`, `path` and + `pack_version`, the schema's own rules do. Each refusal is a + `FindingRefused` with a bounded `where`, never a raw error, and quotes + nothing it refused.""" + finding = {**_example("broken-link"), field: value} + assert [(v.rule, v.where) for v in hc.violations(finding)] == [(rule, where)] + assert len(where) <= STRING_MAX + with pytest.raises(hc.FindingRefused) as refused: + hc.check_finding(finding) + assert refused.value.rule == rule + assert refused.value.where == where + assert "\x00" not in str(refused.value) + with pytest.raises(hc.FindingRefused): + hc.to_json(finding) + fields = {**_fields(_example("broken-link")), field: value} + with pytest.raises(hc.FindingRefused) as made: + hc.make_finding(**fields) + assert made.value.rule == rule + + +def test_evidence_is_an_empty_object_when_a_family_supplies_none() -> None: + """14.5, FR-011: every emitted finding carries `evidence`.""" + fields = _fields(_example("orphan")) + del fields["evidence"] + finding = hc.make_finding(**fields) + assert finding["evidence"] == {} + assert json.loads(hc.to_json(finding))["evidence"] == {} + assert "locator" not in finding + + +@pytest.mark.parametrize("identity, rule", [ + ({"a": float("nan")}, "identity-holds-no-number"), + ({"a": ("b",)}, "finding-is-json"), + ({1: "a"}, "finding-is-json"), + ({"a": "\ud800"}, "identity-strings-are-short-text"), + ({"\udfff": "a"}, "identity-strings-are-short-text"), +]) +def test_an_identity_that_is_not_text_utf8_can_encode_is_refused(identity: Any, + rule: str) -> None: + with pytest.raises(hc.FindingRefused) as refused: + hc.finding_id(identity=identity, kind="orphan", pack_id="opendox", path="a.md") + assert refused.value.rule == rule + + +@pytest.mark.parametrize("locator, rules", [ + ({"line_start": 1, "line_end": 1}, []), + ({"target": "../old/brief.md"}, []), + ({"line_start": 3, "line_end": 9, "target": "x" * 200}, []), + ({"line_start": 12.0, "line_end": 12}, []), + ({}, ["locator-keys"]), + ({"line_end": 12}, ["locator-keys"]), + ({"line_start": 1, "line_end": 2, "column": 3}, ["locator-keys"]), + ("12", ["locator-keys"]), + ({"line_start": 0, "line_end": 1}, ["locator-line-is-a-line-number"]), + ({"line_start": 1, "line_end": True}, ["locator-line-is-a-line-number"]), + ({"line_start": 1.5, "line_end": 2}, ["locator-line-is-a-line-number"]), + ({"line_start": "1", "line_end": 2}, ["locator-line-is-a-line-number"]), + ({"line_start": float("nan"), "line_end": 2}, ["locator-line-is-a-line-number"]), + ({"line_start": 5, "line_end": 4}, ["locator-span-is-ordered"]), + ({"line_start": 0, "line_end": -1}, ["locator-line-is-a-line-number", + "locator-line-is-a-line-number"]), + ({"target": ""}, ["locator-target-is-short-text"]), + ({"target": "x" * 201}, ["locator-target-is-short-text"]), + ({"target": "\ud800"}, ["locator-target-is-short-text"]), + ({"target": "../old/\x00brief.md"}, ["text-holds-no-nul"]), + ({"line_start": 1, "line_end": 1, "target": "\x00"}, ["text-holds-no-nul"]), +]) +def test_the_locator_is_display_only_and_bounded(locator: Any, rules: list[str]) -> None: + finding = {**_example("broken-link"), "locator": locator} + assert _rules(finding) == rules + + +#: Integers JSON admits that Python's encoder cannot write: past the +#: integer-string conversion limit (4300 digits by default). +_UNWRITABLE = 10 ** 5000 + + +@pytest.mark.parametrize("locator, rules", [ + ({"line_start": _UNWRITABLE, "line_end": _UNWRITABLE}, + ["finding-is-json", "finding-is-json"]), + ({"line_start": 1, "line_end": _UNWRITABLE}, ["finding-is-json"]), + # openDox-code#97, Codex r4223401860: a 401-digit line is writable, and an + # out-of-order span of it is refused by its rule, never an OverflowError + ({"line_start": 10 ** 400, "line_end": 1}, ["locator-span-is-ordered"]), + # lane 3's REVIEW-W1 MAJOR probe: a reversed span of 401-digit integers + ({"line_start": 10 ** 400, "line_end": 10 ** 399}, ["locator-span-is-ordered"]), + ({"line_start": 1, "line_end": 10 ** 400}, []), + ({"line_start": 1e308, "line_end": 1}, ["locator-span-is-ordered"]), + ({"line_start": 1, "line_end": 1e308}, []), +], ids=["both unwritable", "line_end unwritable", "401 digits out of order", + "lane 3's reversed 401-digit span", "401 digits in order", "1e308 out of order", + "1e308 in order"]) +def test_a_locator_of_any_size_is_judged_never_raised(locator: Any, rules: list[str]) -> None: + """Copilot r4223399258 and Codex r4223401860 on openDox-code#97: a checked + finding always serializes, and judging one never raises.""" + finding = {**_example("broken-link"), "locator": locator} + assert _rules(finding) == rules + if rules: + with pytest.raises(hc.FindingRefused): + hc.to_json(finding) + fields = {**_fields(_example("broken-link")), "locator": locator} + with pytest.raises(hc.FindingRefused): + hc.make_finding(**fields) + else: + assert json.loads(hc.to_json(finding))["locator"] == locator + + +def test_evidence_of_any_size_is_judged_never_raised() -> None: + finding = {**_example("broken-link"), "evidence": {"a": [_UNWRITABLE], "b": 10 ** 400}} + assert [(v.rule, v.where) for v in hc.violations(finding)] == [ + ("finding-is-json", "/evidence/a/0")] + with pytest.raises(hc.FindingRefused): + hc.to_json(finding) + finding["evidence"] = {"b": 10 ** 400} + assert json.loads(hc.to_json(finding))["evidence"] == {"b": 10 ** 400} + + +@pytest.mark.parametrize("path, admitted", [ + ("notes/plan.md", True), ("", True), ("a/..b/c..", True), ("caf\u00e9.md", True), + ("/etc/passwd", False), ("C:notes.md", False), ("c:/notes.md", False), + ("notes\\plan.md", False), ("../plan.md", False), ("notes/../plan.md", False), + ("notes/..", False), ("..", False), ("a\nb", False), ("a\x7fb", False), + ("a\udc80b", False), ("a\x00b", False), (None, False), (3, False), +]) +def test_the_path_is_corpus_relative(path: Any, admitted: bool) -> None: + finding = _example("orphan") + finding["path"] = path + if path == "": + finding["identity"] = {"category": "no-sandbox", "entry": ""} + rules = [r for r in _rules(finding) if r != "id-is-the-hash-of-its-key"] + assert rules == ([] if admitted else ["path-is-corpus-relative"]) + + +@pytest.mark.parametrize("version, admitted", [ + ("0.2.0", True), ("1.4.0+local build", True), ("", False), ("1\n", False), + ("1\x85", False), ("1\ud800", False), (1, False)]) +def test_the_pack_version_is_text(version: Any, admitted: bool) -> None: + finding = {**_example("broken-link"), "pack_version": version} + assert _rules(finding) == ([] if admitted else ["pack-version-is-text"]) + + +@pytest.mark.parametrize("field, value, rule", [ + ("severity", "critical", "severity-is-known"), + ("severity", None, "severity-is-known"), + ("resolution_class", "auto_fix", "resolution-class-is-one-of-three"), + ("resolution_class", "Auto-Fix", "resolution-class-is-one-of-three"), + ("resolution_class", ["auto-fix"], "resolution-class-is-one-of-three"), + ("baseline_class", "gone", "baseline-class-is-known"), + ("baseline_class", None, "baseline-class-is-known"), +]) +def test_the_closed_values_are_closed(field: str, value: Any, rule: str) -> None: + finding = {**_example("broken-link"), field: value} + assert _rules(finding) == [rule] + + +# --------------------------------------------------------------------------- +# 7. every rule the copy catalogues is enforced, and the engine's own +# --------------------------------------------------------------------------- + +def _with(name: str, **changes: Any) -> dict[str, Any]: + finding = _example(name) + for key, value in changes.items(): + if value is _DROP: + del finding[key] + else: + finding[key] = value + return finding + + +_DROP = object() + +#: openDox-spec's negative example for each catalogued rule, rebuilt as the +#: positive example it names with its one change, then the engine's own. +NEGATIVES: dict[str, dict[str, Any]] = { + "finding-keys": _with("broken-link", patch="--- a/notes/plan.md\n+++ b/notes/plan.md\n"), + "id-is-well-formed": _with("broken-link", id="opendox.broken-link.546CD2AACB1A6738"), + "kind-is-a-family-name": _with("broken-link", kind="Broken_Link"), + "pack-id-is-a-name": _with("broken-link", pack_id="house_style"), + "pack-version-is-text": _with("broken-link", pack_version=""), + "path-is-corpus-relative": _with("broken-link", path="../outside/plan.md"), + "identity-is-an-object": _with("broken-link", identity=["../old/brief.md"]), + "identity-holds-no-number": _with("broken-link", + identity={"target": "../old/brief.md", "line": 12}), + "identity-strings-are-short-text": _with("broken-link", identity={"target": PASSAGE}), + "identity-names-no-text": _with("broken-link", identity={"target": "../old/brief.md", + "text": "see the old brief"}), + "locator-keys": _with("broken-link", locator={"line_start": 12}), + "locator-line-is-a-line-number": _with("broken-link", + locator={"line_start": 0, "line_end": 12}), + "locator-target-is-short-text": _with("broken-link", locator={ + "line_start": 12, "line_end": 12, "target": PASSAGE}), + "severity-is-known": _with("broken-link", severity="critical"), + "resolution-class-is-one-of-three": _with("broken-link", resolution_class="auto_fix"), + "message-is-one-bounded-line": _with("broken-link", message=( + "link target does not exist;\na unique file of that name exists elsewhere")), + "evidence-is-an-object": _with("broken-link", evidence="archive/brief.md"), + "evidence-strings-are-short-text": _with("broken-link", evidence={ + "candidate": "archive/brief.md", "family_version": "1", "context": PASSAGE}), + "evidence-names-no-text": _with("broken-link", evidence={ + "candidate": "archive/brief.md", "family_version": "1", + "excerpt": "see the old brief"}), + "baseline-class-is-known": _with("broken-link", baseline_class="unclassed"), + "pathless-finding-is-human-only": _with("no-sandbox", resolution_class="assisted"), + "pathless-identity-is-category-and-entry": _with("no-sandbox", identity={}), + "identity-collision-is-human-only": _with("identity-collision", + resolution_class="assisted"), + "collision-identity-is-the-collided-id": _with("identity-collision", identity={ + "id": "house-style.heading-case.0f1e2d3c4b5a6978"}), + "id-names-its-pack-and-kind": _with("broken-link", + id="house-style.broken-link.546cd2aacb1a6738"), + "id-is-the-hash-of-its-key": _with("broken-link", + identity={"target": "../old/brief-2.md"}), + "locator-span-is-ordered": _with("broken-link", + locator={"line_start": 14, "line_end": 12}), + # the engine's own + # a 41-character kind under the orphan's own id: the id is well formed, + # and the reference rules judge no part their own rules refuse + "name-is-at-most-40-characters": _with("orphan", kind="o" * 41), + "identity-is-within-the-size-cap": _with( + "orphan", identity=_identity_of(IDENTITY_MAX_BYTES + 1), + id=_independent_id("opendox", "orphan", "notes/appendix.md", + _identity_of(IDENTITY_MAX_BYTES + 1))), + "nesting-is-within-the-cap": _with("orphan", evidence=_nested(17)), + "finding-is-json": _with("orphan", evidence={"a": ("b",)}), + "text-holds-no-nul": _with("orphan", evidence={"a": "b\x00c"}), +} + + +def test_every_catalogued_rule_and_every_engine_rule_has_a_negative() -> None: + assert set(NEGATIVES) == set(hc.SHAPE_RULES) | set(hc.REFERENCE_RULES) | set( + hc.ENGINE_RULES) + + +@pytest.mark.parametrize("rule", sorted(NEGATIVES)) +def test_each_negative_breaks_its_rule_and_no_other(rule: str) -> None: + finding = NEGATIVES[rule] + assert _rules(finding) == [rule], hc.violations(finding) + with pytest.raises(hc.FindingRefused) as refused: + hc.check_finding(finding) + assert refused.value.rule == rule + assert refused.value.violation == hc.violations(finding)[0] + + +@pytest.mark.parametrize("missing", hc.REQUIRED_FIELDS) +def test_a_finding_without_a_required_field_is_refused(missing: str) -> None: + finding = _with("broken-link", **{missing: _DROP}) + assert "finding-keys" in _rules(finding) + + +def test_a_field_name_that_is_not_text_is_refused() -> None: + """Lane 3's REVIEW-W1 MINOR: with this refusal gone, the finding was + admitted and `to_json()` raised `TypeError`.""" + finding = {**_example("broken-link"), 1: "x"} + assert [(v.rule, v.where) for v in hc.violations(finding)] == [("finding-is-json", "")] + with pytest.raises(hc.FindingRefused): + hc.to_json(finding) + + +@pytest.mark.parametrize("finding", [[], "a finding", None, 3]) +def test_a_finding_that_is_not_an_object_is_refused(finding: Any) -> None: + assert [(v.rule, v.where) for v in hc.violations(finding)] == [("finding-keys", "")] + + +def test_a_pathless_finding_breaks_the_human_only_rule_only_over_a_known_class() -> None: + """A class outside the three is that rule's refusal, and not also the + pathless rule's: one cause, one violation.""" + assert _rules(_with("no-sandbox", resolution_class="later")) == [ + "resolution-class-is-one-of-three"] + assert _rules(_with("identity-collision", resolution_class="later")) == [ + "resolution-class-is-one-of-three"] + + +@pytest.mark.parametrize("fid", [ + "house-style.broken-link.546cd2aacb1a6738", + "opendox.orphan.546cd2aacb1a6738", + "house-style.orphan.546cd2aacb1a6738", +], ids=["another pack", "another kind", "both"]) +def test_the_id_names_the_findings_own_pack_and_its_own_kind(fid: str) -> None: + """Each of the id's two names is judged: its hash is still the hash of + the finding's own key, so this rule alone is broken.""" + assert _rules(_with("broken-link", id=fid)) == ["id-names-its-pack-and-kind"] + + +def test_the_reference_rules_judge_only_admitted_parts() -> None: + """A malformed part is reported once, by its own rule: the hash is not + judged over a key it could not be computed from, as in openDox-spec's own + test.""" + assert _rules(_with("broken-link", kind="Broken_Link")) == ["kind-is-a-family-name"] + assert _rules(_with("no-sandbox", identity={})) == [ + "pathless-identity-is-category-and-entry"] + assert _rules(_with("broken-link", identity={"target": "\ud800"})) == [ + "identity-strings-are-short-text"] + + +def test_a_pathless_collision_keeps_the_collisions_identity() -> None: + finding = hc.make_finding( + kind="identity-collision", pack_id="opendox", pack_version="0.2.0", path="", + identity=hc.collision_identity("opendox.no-sandbox.5ccbb79891acb2e8"), + severity="error", resolution_class="human-only", message="m") + assert hc.violations(finding) == [] + assert _rules({**finding, "resolution_class": "assisted"}) == [ + "pathless-finding-is-human-only", "identity-collision-is-human-only"] + + +# --------------------------------------------------------------------------- +# 8. the engine's own identities and categories; the examples rebuilt +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name", sorted(EXAMPLES)) +def test_make_finding_rebuilds_each_example_id_and_all(name: str) -> None: + """The engine's door: given a family's or pack's fields and the engine's + pack_id and pack_version, `make_finding()` stamps the example's own id + and writes nothing else.""" + example = _example(name) + built = hc.make_finding(**_fields(example)) + assert built == example + assert list(built) == [field for field in hc.FIELDS if field in example] + assert json.loads(hc.to_json(built)) == example + + +def test_make_finding_returns_its_own_copy() -> None: + fields = _fields(_example("broken-link")) + built = hc.make_finding(**fields) + fields["identity"]["target"] = "elsewhere" + fields["evidence"]["candidate"] = "elsewhere" + fields["locator"]["line_start"] = 99 + assert built == EXAMPLES["broken-link"] + + +def test_to_json_is_the_canonical_serialization() -> None: + built = hc.make_finding(**_fields(_example("pack-upgrade"))) + text = hc.to_json(built) + assert text == json.dumps(built, sort_keys=True, separators=(",", ":"), + ensure_ascii=False) + assert "caf\u00e9" in text + + +#: The categories as the holder ruled them (`6069024023` item 1): the nine +#: T041 drafted, accepted as written, and the two added. +RULED_CATEGORIES = ("no-sandbox", "entry-refused", "fetch-failed", "digest-mismatch", + "declaration-refused", "pack-crashed", "pack-timed-out", + "pack-bound-hit", "pack-output-refused", "manifest-refused", + "dispositions-refused") + +#: The categories whose finding is against the product itself; the rest are +#: against the pack their manifest entry launched. +AGAINST_OPENDOX = {"no-sandbox", "entry-refused", "manifest-refused", "dispositions-refused"} + +#: The install-level categories: a run raises ONE such finding, so its entry +#: is "" (the holder's `6072197564` (e); data-model.md:382). +INSTALL_LEVEL = {"no-sandbox", "manifest-refused", "dispositions-refused"} + +#: The categories against the pack a valid manifest entry launched, whose +#: entry is that pack's id, never "" (`6072086385` item 1). +AGAINST_A_PACK = set(RULED_CATEGORIES) - AGAINST_OPENDOX + + +def test_the_categories_are_the_ruled_ones_and_names() -> None: + assert hc.CATEGORIES == RULED_CATEGORIES + assert "no-sandbox" in hc.CATEGORIES # openDox-spec's own released example + for category in hc.CATEGORIES: + assert re.fullmatch(r"[a-z0-9-]{1,40}", category), category + assert set(hc.CATEGORIES) - hc.ENTRY_CATEGORIES == AGAINST_OPENDOX + assert hc.ENTRY_CATEGORIES < set(hc.CATEGORIES) + assert hc.ENGINE_KINDS == set(RULED_CATEGORIES) | {"identity-collision", + "uncited-disappearance", + "refused-patch"} + assert hc.INSTALL_CATEGORIES == INSTALL_LEVEL + assert hc.PATHLESS_CATEGORIES == set(RULED_CATEGORIES) | {"identity-collision"} + + +@pytest.mark.parametrize("category", [c for c in RULED_CATEGORIES if c not in INSTALL_LEVEL]) +def test_each_category_makes_one_engine_finding_per_entry_with_one_id(category: str) -> None: + """A category is its finding's kind, the path is empty, it is human-only, + its `pack_id` is the one its category takes (`6072086385` item 1), and one + id per category and entry across runs (the holder's `6069024023` item 1).""" + entry = "house-style" + + def build(entry: str = entry, message: str = "the pack did not run") -> dict[str, Any]: + return hc.engine_finding(category, entry, pack_version="1.4.0", severity="warning", + message=message, evidence={"reasons": ["one", "two"]}) + + first = build() + assert first["id"] == build(message="another run, other words")["id"] + assert first["id"] != build(entry="other-pack")["id"] + assert (first["kind"], first["path"], first["resolution_class"]) == ( + category, "", "human-only") + assert first["identity"] == {"category": category, "entry": entry} + assert first["pack_id"] == ("opendox" if category in AGAINST_OPENDOX else entry) + assert first["evidence"] == {"reasons": ["one", "two"]} + assert hc.violations(first) == [] + + +@pytest.mark.parametrize("category", sorted(AGAINST_OPENDOX)) +def test_a_finding_against_the_product_may_name_no_entry(category: str) -> None: + built = hc.engine_finding(category, pack_version="0.2.0", severity="warning", + message="the run could not use it") + assert (built["pack_id"], built["identity"]) == ("opendox", {"category": category, + "entry": ""}) + + +@pytest.mark.parametrize("entry", ["", "opendox"]) +@pytest.mark.parametrize("category", sorted(set(RULED_CATEGORIES) - AGAINST_OPENDOX)) +def test_a_finding_against_a_pack_names_that_packs_entry(category: str, entry: str) -> None: + with pytest.raises(hc.FindingRefused) as refused: + hc.engine_finding(category, entry, pack_version="0.2.0", severity="warning", + message="the pack did not run") + assert refused.value.rule == "pathless-identity-is-category-and-entry" + + +@pytest.mark.parametrize("category", sorted(INSTALL_LEVEL)) +def test_an_install_level_finding_takes_no_entry(category: str) -> None: + """Lane 3's REVIEW-W1 MINOR at `f9cc4b0c` (`health_contract.py:865`) and the + holder's `6072197564` (e): a run raises ONE install-level finding, never one + per entry (data-model.md:382), so its entry is "", and a non-empty one is + refused by `engine_finding()`, `pathless_identity()` and `check_finding()` + alike. Otherwise one whole-file refusal could split per entry.""" + with pytest.raises(hc.FindingRefused) as refused: + hc.engine_finding(category, "house-style", pack_version="0.2.0", severity="warning", + message="the run could not use it") + assert refused.value.rule == "pathless-identity-is-category-and-entry" + assert refused.value.where == "/identity/entry" + with pytest.raises(hc.FindingRefused) as built: + hc.pathless_identity(category, "house-style") + assert built.value.where == "/identity/entry" + identity = {"category": category, "entry": "house-style"} + finding = {**_example("no-sandbox"), "kind": category, "identity": identity, + "id": _independent_id("opendox", category, "", identity)} + assert [(v.rule, v.where) for v in hc.violations(finding)] == [ + ("pathless-identity-is-category-and-entry", "/identity/entry")] + + +@pytest.mark.parametrize("category, admitted", [ + *[(category, True) for category in RULED_CATEGORIES], + ("identity-collision", True), + ("uncited-disappearance", False), ("broken-link", False), ("orphan", False), +]) +def test_a_pathless_identity_names_an_engine_category_or_a_collision(category: str, + admitted: bool) -> None: + """The holder's `6072197564` (b): the set is CLOSED. Only the engine raises + a pathless finding, so its category is one of the engine's, or + `identity-collision` in the re-raise of a pathless collision's + disappearance (option (D)).""" + entry = "house-style" if category in AGAINST_A_PACK else "" + identity = {"category": category, "entry": entry} + finding = {**_example("no-sandbox"), "kind": "uncited-disappearance", "identity": identity, + "id": _independent_id("opendox", "uncited-disappearance", "", identity)} + found = [(v.rule, v.where) for v in hc.violations(finding)] + if admitted: + assert found == [] + else: + assert found == [("pathless-identity-is-category-and-entry", "/identity/category")] + + +@pytest.mark.parametrize("category, entry, admitted", [ + *[(category, "", False) for category in sorted(AGAINST_A_PACK)], + *[(category, "house-style", True) for category in sorted(AGAINST_A_PACK)], + # the holder's 6082100803: the reserved `opendox`, and the 40-character name + *[(category, "opendox", False) for category in sorted(AGAINST_A_PACK)], + *[(category, "p" * 41, False) for category in sorted(AGAINST_A_PACK)], + *[(category, "p" * 40, True) for category in sorted(AGAINST_A_PACK)], + ("entry-refused", "opendox", True), + ("entry-refused", "p" * 200, True), + ("identity-collision", "house-style", False), + ("identity-collision", "", True), + *[(category, "house-style", False) for category in sorted(INSTALL_LEVEL)], + *[(category, "", True) for category in sorted(INSTALL_LEVEL)], + ("entry-refused", "", True), + ("entry-refused", "house-style", True), +]) +def test_a_pathless_entry_takes_its_categorys_form(category: str, entry: str, + admitted: bool) -> None: + """Lane 3's REVIEW-W1 MINOR at `c620dacd` (`health_contract.py:693`), as the + holder ruled it: a category against a pack names that pack's entry, never + "", and an install-level category and a collision's re-raise take ""; + `entry-refused` takes either. Before, `check_finding()` admitted + `{identity-collision, }` and `{pack-crashed, ""}`, two forms + the engine never builds.""" + identity = {"category": category, "entry": entry} + finding = {**_example("no-sandbox"), "kind": "uncited-disappearance", "identity": identity, + "id": _independent_id("opendox", "uncited-disappearance", "", identity)} + found = [(v.rule, v.where) for v in hc.violations(finding)] + if admitted: + assert found == [] + else: + assert found == [("pathless-identity-is-category-and-entry", "/identity/entry")] + + +#: Entries the exactness probe tries for every category. +_PROBE_ENTRIES = ["", "opendox", "house-style", "p" * 40, "p" * 41, "p" * 200, "p" * 201, + "House", "house style", "-", "a\x00b"] + + +def test_check_finding_admits_exactly_what_engine_finding_builds() -> None: + """Lane 3's exactness probe (REVIEW-W1 MINOR at `4cc065ad`; the holder's + `6082100803`): for every category and every probed entry, `engine_finding()` + builds the finding exactly when `check_finding()` admits its identity in a + re-raise, whose `pack_id` is `opendox`. It found 21 mismatches at + `4cc065ad`; it reads 0 now.""" + mismatches = [] + for category in hc.CATEGORIES: + for entry in _PROBE_ENTRIES: + try: + hc.engine_finding(category, entry, pack_version="1.0.0", severity="error", + message="the probe") + built = True + except hc.FindingRefused: + built = False + identity = {"category": category, "entry": entry} + reraise = {**_example("no-sandbox"), "kind": "uncited-disappearance", + "identity": identity, + "id": _independent_id("opendox", "uncited-disappearance", "", identity)} + admitted = not hc.violations(reraise) + if built != admitted: + mismatches.append((category, entry[:12], built, admitted)) + assert mismatches == [] + + +def test_refused_patch_is_a_pathed_engine_kind() -> None: + """The holder's `6086098003` item 1: only the engine raises `refused-patch`, + so T045 refuses a pack declaring it. It is PATHED, like + `uncited-disappearance`: no category, and no pathless identity names it.""" + assert hc.REFUSED_PATCH == "refused-patch" + assert hc.REFUSED_PATCH in hc.ENGINE_KINDS + assert hc.REFUSED_PATCH not in hc.CATEGORIES + assert hc.REFUSED_PATCH not in hc.PATHLESS_CATEGORIES + finding = hc.make_finding( + kind=hc.REFUSED_PATCH, pack_id="house-style", pack_version="1.4.0", + path="notes/plan.md", identity={"refused_patch": "house-style.heading-case.1"}, + severity="error", resolution_class="human-only", + message="the engine refused the pack's patch", + evidence={"reason": "it reaches outside its path"}) + assert hc.violations(finding) == [] + identity = {"category": "refused-patch", "entry": ""} + pathless = {**_example("no-sandbox"), "kind": "uncited-disappearance", + "identity": identity, + "id": _independent_id("opendox", "uncited-disappearance", "", identity)} + assert [(v.rule, v.where) for v in hc.violations(pathless)] == [ + ("pathless-identity-is-category-and-entry", "/identity/category")] + + +def _default(value: Any) -> Any: + """`6090537166` item 8 (b): a set by its members in `ascii()` order.""" + return sorted(value, key=ascii) if isinstance(value, (set, frozenset)) else str(value) + + +def _canonical_digest(entry_id: Any) -> str: + """The holder's `6088484643`, spelled independently of the module.""" + try: + text = json.dumps(entry_id, sort_keys=True, separators=(",", ":"), ensure_ascii=True, + allow_nan=True, default=_default) + except (TypeError, ValueError): + text = ascii(entry_id) + return "sha256-" + hashlib.sha256(text.encode("ascii")).hexdigest() + + +#: The one text an id takes when neither spelling can write it (item 8 (a)). +_PAST_THE_LIMITS = "sha256-" + hashlib.sha256( + b"").hexdigest() + + +_SELF: list[Any] = [] +_SELF.append(_SELF) + +#: Each refused id, and the entry `refused_entry()` gives it. +_REFUSED_IDS = { + "an absent id": (None, ""), + "a conforming id": ("house-style", "house-style"), + "a conforming id of 200 characters": ("p" * 200, "p" * 200), + "an uppercase id": ("House-Style", _canonical_digest("House-Style")), + "a spaced id": ("house style", _canonical_digest("house style")), + "an id with U+0000": ("house\x00style", _canonical_digest("house\x00style")), + "an empty string": ("", _canonical_digest("")), + "a non-string id": (5, _canonical_digest(5)), + "a boolean id": (True, _canonical_digest(True)), + "a float id, NaN": (float("nan"), "sha256-" + hashlib.sha256(b"NaN").hexdigest()), + "a date id": (datetime.date(2026, 10, 9), _canonical_digest("2026-10-09")), + "a list id": (["a", 1], _canonical_digest(["a", 1])), + "a 201-character id": ("p" * 201, _canonical_digest("p" * 201)), + "a mapping with mixed-type keys": ( + {1: "a", "b": 2}, "sha256-" + hashlib.sha256(repr({1: "a", "b": 2}).encode()).hexdigest()), + "mixed-type keys with non-ASCII text": ( + {1: "caf\u00e9", "b": 2}, + "sha256-" + hashlib.sha256(b"{1: 'caf\\xe9', 'b': 2}").hexdigest()), + "a self-referencing list": (_SELF, "sha256-" + hashlib.sha256(b"[[...]]").hexdigest()), + # 6090537166 item 8 (b): a YAML !!set, its members in ascii() order + "a set id": (yaml.safe_load("id: !!set {beta: null, alpha: null}")["id"], + "sha256-" + hashlib.sha256(b'["alpha","beta"]').hexdigest()), + # the holder's 6088732352 (b): the fallback is ascii(), so a non-ASCII key + # in a mixed-type mapping digests its escaped spelling + "a non-ASCII key among mixed-type keys": ( + {"cl\u00e9": 1, 2: "b"}, + "sha256-" + hashlib.sha256(b"{'cl\\xe9': 1, 2: 'b'}").hexdigest()), +} + + +@pytest.mark.parametrize("name", list(_REFUSED_IDS)) +def test_refused_entry_builds_entry_refuseds_entry(name: str) -> None: + """The holder's `6086098003` item 4 and `6088484643`: "" for no id, the id + as written when it is `[a-z0-9-]+` of at most 200 characters, and otherwise + `sha256-` and the SHA-256 of its canonical text, its JSON with sorted keys, + no whitespace and ASCII escapes (`ascii()` when that raises, `6088732352` + (b)). The entry is + one `pathless_identity()` admits, never truncated, and never a refusal.""" + entry_id, entry = _REFUSED_IDS[name] + assert hc.refused_entry(entry_id) == entry + assert hc.refused_entry(entry_id) == entry # deterministic + assert hc.pathless_identity("entry-refused", entry) == {"category": "entry-refused", + "entry": entry} + + +def test_refused_entry_keeps_distinct_ids_apart_and_never_raises() -> None: + entries = [hc.refused_entry(entry_id) for entry_id, _ in _REFUSED_IDS.values()] + assert len(set(entries)) == len(entries) + deep: Any = "x" + for _ in range(50_000): # past the C recursion limit json.dumps and repr() share + deep = [deep] + assert hc.refused_entry(deep) == _PAST_THE_LIMITS + assert hc.refused_entry({"b": 1, "a": [2, 1]}) == hc.refused_entry({"a": [2, 1], "b": 1}) + + +#: The four ids lane 3 reproduced at `471b7231`: integers YAML yields past +#: Python's 4300-digit conversion limit, which neither `json.dumps` nor +#: `ascii()` can write. +_PAST_THE_DIGIT_LIMIT = { + "a long 0x literal": "id: 0x" + "f" * 4000, + "a long 0b literal": "id: 0b" + "1" * 15000, + "a long 0x literal in a list": "id: [0x" + "f" * 4000 + "]", + "a long 0x literal in a mixed-key mapping": "id: {1: 0x" + "f" * 4000 + ", b: 2}", +} + + +@pytest.mark.parametrize("name", list(_PAST_THE_DIGIT_LIMIT)) +def test_refused_entry_never_raises_past_the_int_digit_limit(name: str) -> None: + """Lane 3's MAJOR at `471b7231`, as the holder ruled it (`6090537166` item + 8 (a)): an id neither spelling can write takes ONE fixed text, so it is a + `sha256-` entry and never a `ValueError`. Two such ids share it, an + accepted limit that `identity-collision` reports.""" + entry_id = yaml.safe_load(_PAST_THE_DIGIT_LIMIT[name])["id"] + assert hc.refused_entry(entry_id) == _PAST_THE_LIMITS + + +_SET_UNDER_A_SEED = """ +import json +from opendox import health_contract as hc +members = {members!r} +print(json.dumps([hc.refused_entry(set(members)), str(set(members))])) +""" + + +def test_a_set_id_is_one_entry_under_every_hash_seed() -> None: + """Lane 3's MINOR at `471b7231`, as the holder ruled it (`6090537166` item + 8 (b)): a set's `str()` follows hash order, which `PYTHONHASHSEED` moves, + so the default hook spells a set by its members in `ascii()` order. The + same set id, digested in four interpreters with four seeds, is one entry; + its `str()` there is not one spelling, so the row bites.""" + members = ["delta", "alpha", "kilo", "echo", "bravo", "juliet", "golf", "india", + "charlie", "hotel", "foxtrot", "lima"] + entries, spellings = set(), set() + for seed in ("0", "1", "2", "3"): + env = {**os.environ, "PYTHONHASHSEED": seed, + "PYTHONPATH": os.pathsep.join([str(SRC), os.environ.get("PYTHONPATH", "")])} + done = subprocess.run( + [sys.executable, "-c", _SET_UNDER_A_SEED.format(members=members)], + env=env, capture_output=True, text=True, timeout=60, check=True) + entry, spelling = json.loads(done.stdout) + entries.add(entry) + spellings.add(spelling) + assert len(spellings) > 1 + assert entries == {_canonical_digest(set(members))} + + +def test_an_engine_finding_against_a_41_character_pack_is_refused() -> None: + """The holder's `6082100803`: a per-entry category's entry is a pack id of + 1 to 40 characters, judged at the identity, so `check_finding()` and + `engine_finding()` refuse the same entries.""" + hc.engine_finding("pack-crashed", "p" * 40, pack_version="1", severity="error", + message="the pack crashed") + with pytest.raises(hc.FindingRefused) as refused: + hc.engine_finding("pack-crashed", "p" * 41, pack_version="1", severity="error", + message="the pack crashed") + assert refused.value.rule == "pathless-identity-is-category-and-entry" + assert refused.value.where == "/identity/entry" + + +def test_engine_finding_rebuilds_openDox_specs_no_sandbox_example() -> None: + example = _example("no-sandbox") + built = hc.engine_finding("no-sandbox", pack_version=example["pack_version"], + severity=example["severity"], message=example["message"], + evidence=example["evidence"]) + assert built == example + + +@pytest.mark.parametrize("category", ["manifest-refused", "dispositions-refused"]) +def test_the_two_added_categories_are_admitted(category: str) -> None: + assert hc.pathless_identity(category) == {"category": category, "entry": ""} + + +@pytest.mark.parametrize("category, entry", [ + ("not-a-category", ""), ("No-Sandbox", ""), ("", ""), (None, ""), + ("entry-refused", "House"), ("entry-refused", "a" * 201), ("entry-refused", None), + ("no-sandbox", "house-style"), ("manifest-refused", "house-style"), + ("dispositions-refused", "house-style"), ("pack-crashed", ""), ("fetch-failed", "")]) +def test_the_pathless_identity_refuses_what_is_not_the_engines(category: Any, + entry: Any) -> None: + with pytest.raises(hc.FindingRefused) as refused: + hc.pathless_identity(category, entry) + assert refused.value.rule == "pathless-identity-is-category-and-entry" + + +@pytest.mark.parametrize("value", ["", "opendox.broken-link", "opendox.broken-link.XYZ", + "opendox.broken-link.546cd2aacb1a673", + "opendox.broken-link.546cd2aacb1a6738\n", None]) +def test_a_collision_names_a_well_formed_id(value: Any) -> None: + with pytest.raises(hc.FindingRefused) as refused: + hc.collision_identity(value) + assert refused.value.rule == "collision-identity-is-the-collided-id" + + +def test_the_engines_identities_are_their_keys() -> None: + fid = "opendox.broken-link.546cd2aacb1a6738" + assert hc.collision_identity(fid) == {"collided_id": fid} + assert hc.pathless_identity("no-sandbox") == {"category": "no-sandbox", "entry": ""} + + +def _pathless_collision() -> dict[str, Any]: + return hc.make_finding(kind="identity-collision", pack_id="house-style", + pack_version="1.4.0", path="", + identity=hc.collision_identity( + "house-style.pack-crashed.0f1e2d3c4b5a6978"), + severity="error", resolution_class="human-only", + message="two findings of one run share one id") + + +#: Each original, and the identity its re-raise takes (option (D)). +_DISAPPEARED = { + "a pathed finding": (lambda: _example("broken-link"), + {"disappeared_id": "opendox.broken-link.546cd2aacb1a6738"}), + "a pathless finding": (lambda: _example("no-sandbox"), + {"category": "no-sandbox", "entry": ""}), + "a pathless finding about an entry": ( + lambda: hc.engine_finding("pack-crashed", "house-style", pack_version="1.4.0", + severity="error", message="the pack crashed"), + {"category": "pack-crashed", "entry": "house-style"}), + "a pathless collision": (_pathless_collision, + {"category": "identity-collision", "entry": ""}), +} + + +@pytest.mark.parametrize("name", list(_DISAPPEARED)) +def test_the_disappearance_takes_the_form_its_originals_path_calls_for(name: str) -> None: + """Option (D) (the holder's `6069024023` item 2): a pathed original's + re-raise keeps `{disappeared_id}` and the original's path; a pathless + original's takes the pathless form the schema requires. Either re-raise + is a finding the contract admits, under its own id.""" + make_original, identity = _DISAPPEARED[name] + original = make_original() + assert hc.disappearance_identity(original) == identity + reraise = hc.make_finding( + kind="uncited-disappearance", pack_id="opendox", pack_version="0.2.0", + path=original["path"], identity=hc.disappearance_identity(original), + severity="warning", resolution_class="human-only", + message="a finding disappeared with no landed repair citing it", + evidence={"disappeared_id": original["id"], "baseline_run": "run-1"}) + assert hc.violations(reraise) == [] + assert reraise["id"] != original["id"] + assert reraise["path"] == original["path"] + + +def test_a_pathless_reraise_takes_its_originals_kind_not_its_category() -> None: + """Lane 3's REVIEW-W1 NIT at `f9cc4b0c`: an original whose kind is not its + identity's category, the engine's own re-raise of a pathless + disappearance. Its disappearance's identity names the original's KIND + (option (D)). Under the closed set (`6072197564` (b)) no pathless finding + names that kind, so the contract admits no re-raise of a pathless + re-raise's disappearance.""" + original = hc.make_finding( + kind="uncited-disappearance", pack_id="opendox", pack_version="0.2.0", path="", + identity={"category": "no-sandbox", "entry": ""}, severity="warning", + resolution_class="human-only", + message="a finding disappeared with no landed repair citing it") + identity = hc.disappearance_identity(original) + assert identity == {"category": "uncited-disappearance", "entry": ""} + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(kind="uncited-disappearance", pack_id="opendox", pack_version="0.2.0", + path="", identity=identity, severity="warning", + resolution_class="human-only", message="it disappeared again") + assert refused.value.where == "/identity/category" + + +def test_the_disappeared_id_alone_is_refused_for_a_pathless_original() -> None: + """Why the pathless form exists: `{disappeared_id}` on an empty path breaks + the schema's `pathless-identity-is-category-and-entry`.""" + with pytest.raises(hc.FindingRefused) as refused: + hc.make_finding(kind="uncited-disappearance", pack_id="opendox", + pack_version="0.2.0", path="", + identity={"disappeared_id": EXAMPLES["no-sandbox"]["id"]}, + severity="warning", resolution_class="human-only", message="m") + assert refused.value.rule == "pathless-identity-is-category-and-entry" + + +def test_the_disappearance_of_a_malformed_original_is_refused() -> None: + original = _with("broken-link", id="opendox.broken-link.0000000000000000") + with pytest.raises(hc.FindingRefused) as refused: + hc.disappearance_identity(original) + assert refused.value.rule == "id-is-the-hash-of-its-key" + + +# --------------------------------------------------------------------------- +# 9. the closure: the standard library, and nothing else +# --------------------------------------------------------------------------- + +def test_the_module_names_only_the_standard_library() -> None: + """Read from the source: every import is the standard library's, and none + is relative or of openDox (R2Q18 (a); N-3).""" + tree = ast.parse(MODULE.read_text(encoding="utf-8")) + names: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names += [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + assert node.level == 0, "a relative import" + names.append(node.module or "") + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + assert node.func.id not in ("__import__", "exec", "eval"), node.func.id + assert names + assert sorted({n for n in names if n.split(".")[0] not in sys.stdlib_module_names + and n != "__future__"}) == [] + + +#: openXdox, and openxFactory's packages, blocked at the finder. +_BLOCKED = ("openxdox", "doc_health", "ideation_dashboard", + "corpus_adapter_openxfactory", "scripts", "yaml") + +_IMPORT_WEIGHT = """ +import json, sys +for name in {blocked!r}: + sys.modules[name] = None +before = set(sys.modules) +import opendox.health_contract +added = set(sys.modules) - before +print(json.dumps({{ + "foreign": sorted(m for m in added + if m.split(".")[0] not in sys.stdlib_module_names + and m.split(".")[0] != "opendox"), + "own": sorted(m for m in added if m.split(".")[0] == "opendox"), +}})) +""" + + +def test_the_module_loads_the_standard_library_and_nothing_else_of_opendox() -> None: + env = {**os.environ, + "PYTHONPATH": os.pathsep.join([str(SRC), os.environ.get("PYTHONPATH", "")])} + done = subprocess.run( + [sys.executable, "-c", textwrap.dedent(_IMPORT_WEIGHT.format(blocked=_BLOCKED))], + capture_output=True, text=True, cwd=str(SRC), env=env, timeout=120) + assert done.returncode == 0, done.stderr + found = json.loads(done.stdout.strip().splitlines()[-1]) + assert found == {"foreign": [], "own": ["opendox", "opendox.health_contract"]}, found + + +_ALONE = """ +import json, sys +sys.path.insert(0, {root!r}) +import opendox.health_contract as hc +finding = hc.make_finding(kind="broken-link", pack_id="opendox", pack_version="0.2.0", + path="notes/plan.md", identity={{"target": "../old/brief.md"}}, + locator={{"line_start": 12, "line_end": 12}}, severity="warning", + resolution_class="auto-fix", message="m", evidence={{}}) +print(json.dumps({{ + "json": hc.to_json(finding), + "site": "site" in sys.modules, + "paths": [p for p in sys.path if "site-packages" in p or "dist-packages" in p], + "file": hc.__file__, +}})) +""" + + +def test_the_module_runs_alone_with_no_site_packages(tmp_path: Path) -> None: + """What the sandbox binds (R2Q18 (a)): the interpreter, its standard + library and this one module. It is copied into a directory that holds it + and an empty package marker, and run under `-I -S`.""" + package = tmp_path / "opendox" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + shutil.copyfile(MODULE, package / "health_contract.py") + assert sorted(p.name for p in package.iterdir()) == ["__init__.py", "health_contract.py"] + done = subprocess.run( + [sys.executable, "-I", "-S", "-c", textwrap.dedent(_ALONE.format(root=str(tmp_path)))], + capture_output=True, text=True, cwd=str(tmp_path), timeout=120, + env={"PATH": os.environ.get("PATH", ""), "LANG": "C.UTF-8"}) + assert done.returncode == 0, done.stderr + found = json.loads(done.stdout.strip().splitlines()[-1]) + assert found["site"] is False + assert found["paths"] == [] + assert Path(found["file"]).parent == package + here = hc.make_finding(kind="broken-link", pack_id="opendox", pack_version="0.2.0", + path="notes/plan.md", identity={"target": "../old/brief.md"}, + locator={"line_start": 12, "line_end": 12}, severity="warning", + resolution_class="auto-fix", message="m", evidence={}) + assert found["json"] == hc.to_json(here) + assert json.loads(found["json"])["id"] == EXAMPLES["broken-link"]["id"] + + +# --------------------------------------------------------------------------- +# 10. a refusal quotes nothing of the finding +# --------------------------------------------------------------------------- + +_SECRET = "SECRET-PASSAGE-" + "q" * 300 + + +@pytest.mark.parametrize("change", [ + {"identity": {"target": _SECRET}}, + {"identity": {_SECRET: "a"}}, + {"identity": {"a": [_SECRET]}}, + {"evidence": {"a": _SECRET}}, + {"evidence": {_SECRET: "a"}}, + {"message": _SECRET}, + {"locator": {"target": _SECRET}}, + {"pack_version": _SECRET + "\n"}, + {"path": "../" + _SECRET}, + {"kind": _SECRET}, + {_SECRET: "an unknown field"}, +]) +def test_a_refusal_quotes_nothing_of_the_finding(change: dict[str, Any]) -> None: + finding = {**_example("broken-link"), **change} + found = hc.violations(finding) + assert found + for violation in found: + assert "SECRET" not in violation.line(), violation + with pytest.raises(hc.FindingRefused) as refused: + hc.check_finding(finding) + assert "SECRET" not in str(refused.value) + + +def test_where_names_an_admitted_key_and_detail_names_none() -> None: + """`where` is a pointer built from keys the module ADMITTED, and names them + verbatim within its bound (below); `detail` names no key.""" + key = "ADMITTED-HEADING-KEY" + violation = hc.violations({**_example("broken-link"), "identity": {key: 5}})[0] + assert violation.rule == "identity-holds-no-number" + assert violation.where == f"/identity/{key}" + assert key not in violation.detail + + +def _nested_keys(levels: int, key: str, leaf: Any) -> dict[str, Any]: + """`levels` objects, each holding the next under `key`, and `leaf` last.""" + value: Any = leaf + for _ in range(levels): + value = {key: value} + return value + + +@pytest.mark.parametrize("identity, where", [ + ({"k" * 171: 5}, "/identity/" + "k" * 171), + ({"k" * 190: 5}, "/identity/" + "k" * 190), + ({"k" * 191: 5}, "/identity"), + (_nested_keys(15, "/" * 200, 5), "/identity"), + ({"a": {"b" * 195: {"c": 5}}}, "/identity/a"), + ({"~" * 95: 5}, "/identity/" + "~0" * 95), + ({"~" * 96: 5}, "/identity"), + ({"a": [["x"], [True, 1]]}, "/identity/a/1/1"), + ({"k" * 189: [5]}, "/identity/" + "k" * 189), + ({"k" * 189: [[[[5]]]]}, "/identity/" + "k" * 189), + ({"k" * 195: [{"a": 5}]}, "/identity"), +], ids=["a 171-character key, named", "the bound, 200 characters", + "one past it, the ancestor", "lane 3's fifteen nested 200-character keys", + "a stopped pointer stays stopped", "escapes count, at the bound", + "escapes count, one past it", "array indices", + "an index one past the bound, 201 characters", "indices to 207 characters", + "a stopped pointer stays stopped through an index"]) +def test_where_is_bounded_at_the_nearest_ancestor_that_fits(identity: Any, where: str) -> None: + """Lane 3's REVIEW-W1 MINOR, bounded as the holder preferred: `where` is at + most 200 characters, the place's own pointer or its nearest ancestor's that + fits, so an engine may carry it as evidence. Unbounded, fifteen nested + 200-character keys of `/` made a 6026-character pointer. A pointer that + stops stays stopped, so it never names a path that is not there.""" + found = hc.violations({**_example("broken-link"), "identity": identity}) + assert [(v.rule, v.where) for v in found] == [("identity-holds-no-number", where)] + assert len(where) <= STRING_MAX + + +def test_where_is_bounded_in_evidence_too() -> None: + evidence = {"a": {"b" * 195: {"text": "c"}}} + found = hc.violations({**_example("broken-link"), "evidence": evidence}) + assert [(v.rule, v.where) for v in found] == [("evidence-names-no-text", "/evidence/a")] + + +def test_a_violation_reads_as_its_rule_its_place_and_its_detail() -> None: + violation = hc.violations(NEGATIVES["locator-span-is-ordered"])[0] + assert violation.rule == "locator-span-is-ordered" + assert violation.where == "/locator/line_end" + assert violation.line() == f"[locator-span-is-ordered] /locator/line_end: {violation.detail}" + # it names no value of the finding, the lines included + assert "12" not in violation.detail + assert "14" not in violation.detail + refused = hc.FindingRefused(violation) + assert isinstance(refused, ValueError) + assert (refused.rule, refused.where) == (violation.rule, violation.where) + assert violation.line() in str(refused) + + +def test_no_number_check_admits_booleans_and_refuses_every_number() -> None: + """`True` is an `int` in Python and a boolean in JSON: the identity admits + it and refuses `1`.""" + assert isinstance(True, int) + hc.finding_id(identity={"a": True, "b": False}, kind="k", pack_id="p", path="a.md") + for number in (1, 0, -1, 1.0, 0.5, math.pi, 10 ** 30): + with pytest.raises(hc.FindingRefused) as refused: + hc.finding_id(identity={"a": number}, kind="k", pack_id="p", path="a.md") + assert refused.value.rule == "identity-holds-no-number" diff --git a/tests/test_validator_input_set.py b/tests/test_validator_input_set.py index 3ba756db..85e92a07 100644 --- a/tests/test_validator_input_set.py +++ b/tests/test_validator_input_set.py @@ -1,4 +1,5 @@ -"""openDox's validator's input set, held to plan 034's T057. +"""openDox's validator's input set, held to plan 034's T057 and plan 038's +T041. T057 realizes #1144's 7.1, 7.1a, 7.1b and 7.2, with 7.1 as T007's batch G amends it (R1Q11 (a) and R1Q12 (a), `openxFactory#656` comment `5850003126`): @@ -9,6 +10,16 @@ openDox root pins (R1Q12 (a)) ... A test asserts that `gate-intent` and `ideation-possibles-register` are NOT in the set (7.1b). +T005's batch Q adds to batch G's text (R2Q22 (a), `6003486656`; N-15, +`6013547504`): openDox-spec's three health schemas travel as the four do, and +the finding's neutral shape is *"a copy the engine reads and NOT a validator +kind: a finding's `kind` field is its family, so no `kind` constant can name +the schema (N-15). openDox-code's `tests/test_validator_input_set.py` +therefore reads 'the validator's kinds plus the finding shape'."* Plan 038's +T041 makes the first of those copies: the finding shape, at the spec-leg +commit T060 pinned (openDox-spec `7db9438b`, `dox-v1.2`). T047 and T054 add +the two file kinds after it. + Its falsifier is *"the packaged-copy digest test and the 7.1b test"*. They are the first two cases below: @@ -18,9 +29,10 @@ WHAT ELSE IT HOLDS. -1. THE SET IS EXACTLY FOUR: the record, the validator's kinds and the files on - disk all name the same four copies, and none of the six schemas outside - the set is carried. +1. THE SET IS THE VALIDATOR'S KINDS PLUS THE FINDING SHAPE (N-15): the + validator's kinds read the same copies the record names, less the finding + shape, which the record and the files on disk carry and no kind reads. + None of the six schemas outside the set is carried. 2. PRESENCE IS NOT IDENTITY: a copy that differs from its digest, is absent, or has no digest recorded is refused before a byte of it is parsed, and a validator is never built over it. A record that cannot hold every copy to a @@ -55,25 +67,37 @@ ROOT = Path(__file__).resolve().parents[1] PACKAGE = ROOT / "src" / "opendox" / "contracts" -#: openDox's own spec leg's four schemas (7.1, as batch G amends it). -THE_FOUR = ("ideation-workbench", "opendox-snapshot", "xfactory-workbench-chat-turn", - "xfactory-workbench-model-catalog") +#: The copies the validator's kinds read: openDox's own spec leg's four +#: validated schemas (7.1, as batch G amends it). +VALIDATOR_COPIES = ("ideation-workbench", "opendox-snapshot", + "xfactory-workbench-chat-turn", "xfactory-workbench-model-catalog") + +#: The finding's neutral shape (plan 038 T040 and T041): a copy the health +#: engine reads, and no validator kind (N-15). +FINDING_SHAPE = "opendox-health-finding" + +#: Every copy the record names, in its order: the validator's plus the finding +#: shape. +THE_COPIES = tuple(sorted(VALIDATOR_COPIES + (FINDING_SHAPE,))) -#: The spec-leg commit the openDox root pins, which the copies are held to: T053 -#: as landed, openDox-spec#16's squash (opensoft/openDox#14 moved the root's spec -#: pin to it). It is the one commit that carries all four. It moved from #16's -#: head, cd49eb25, with the record, in lockstep. The two commits have one tree. -SPEC_COMMIT = "f7ee3c763b3af4581daf1cd54406e5111e9358e6" +#: The spec-leg commit the openDox root pins, which the copies are held to: +#: T040 as landed, openDox-spec#17's squash, which plan 038's T060 pinned +#: (opensoft/openDox#20, `6a9f4902`, tagged `dox-v1.2`). It is the one commit +#: that carries all five. It moved from T053's f7ee3c76 with the record, in +#: lockstep. #17 only added files, so the four earlier copies are unchanged. +SPEC_COMMIT = "7db9438b4cc4446ab4e6ab5b552c220312deccc9" #: WHAT THE OPENDOX ROOT PINS, stated here apart from the record, so that a copy -#: and its recorded digest cannot move together unseen. All four are the +#: and its recorded digest cannot move together unseen. All five are the #: digests the root's `contracts/manifest.yaml` records for them at its spec -#: pin, `SPEC_COMMIT` (the root's `main` at 52005213, opensoft/openDox#14). The -#: first three are unchanged from the root's previous spec pin, 8fe8c4c7. The -#: fourth is T053's new `opendox-snapshot` entry. +#: pin, `SPEC_COMMIT` (the root's `main` at 6a9f4902, opensoft/openDox#20). The +#: first four are unchanged from the root's previous spec pin, f7ee3c76. The +#: fifth is T040's new `opendox-health-finding` entry. PINNED_BY_THE_ROOT = { "ideation-workbench": "d30438491119c20928fbe4e85088fc33682829eeb6558d87dafce651000faafc", + "opendox-health-finding": + "6fb9b29f23a4270fa0310624d9e59b5d82e9888a5168600671e90ed30f781016", "opendox-snapshot": "f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a", "xfactory-workbench-chat-turn": @@ -104,7 +128,7 @@ def test_each_packaged_copy_is_the_spec_legs_file_at_the_pinned_commit() -> None f"the copies are recorded at {record.commit}, and this test holds them at " f"{SPEC_COMMIT}. They move together, in one commit: copy the spec leg's " "files at the commit the openDox root pins, and move both") - assert record.ids == THE_FOUR + assert record.ids == THE_COPIES for copy in record.copies: on_disk = PACKAGE / "schemas" / f"{copy.id}.schema.yaml" digest = hashlib.sha256(on_disk.read_bytes()).hexdigest() @@ -142,29 +166,49 @@ def test_gate_intent_and_the_possibles_register_are_not_in_the_set() -> None: # --------------------------------------------------------------------------- -# the set is exactly four +# the set: the validator's kinds plus the finding shape # --------------------------------------------------------------------------- -def test_the_set_is_the_spec_legs_four_and_nothing_else() -> None: - """7.1: openDox validates its own spec leg's kinds. The record, the kinds' - entries and the files on disk name the same four copies. None of the - consumer's three and none of openxFactory's four is carried.""" +def test_the_set_is_the_validators_kinds_plus_the_finding_shape() -> None: + """7.1, as batch Q amends it (N-15): openDox validates its own spec leg's + kinds, and its engine reads the finding shape. The kinds' entries name the + validator's copies; the record and the files on disk name those plus the + finding shape. None of the consumer's three and none of openxFactory's + four is carried.""" entries = {copy for copy, _pointer in validator.KIND_ENTRIES.values()} on_disk = {path.name.removesuffix(".schema.yaml") for path in (PACKAGE / "schemas").iterdir()} - assert set(THE_FOUR) == entries == on_disk == set(contracts.record().ids) - assert contracts.COPY_IDS == set(THE_FOUR) + assert set(VALIDATOR_COPIES) == entries + assert entries | {FINDING_SHAPE} == on_disk == set(contracts.record().ids) + assert contracts.COPY_IDS == set(THE_COPIES) + assert FINDING_SHAPE not in entries assert not (set(OPENXDOX_SPECS) | set(OPENXFACTORYS)) & on_disk assert sorted(p.name for p in PACKAGE.iterdir() if p.name != "__pycache__") == [ "__init__.py", "copies.yaml", "schemas"] +def test_the_finding_shape_is_a_copy_and_no_validator_kind() -> None: + """N-15: a finding's `kind` is its family, so the shape declares no `kind` + const, no kind of the validator reads it, and no validator is built over + it. Its copy is still proved before it is read.""" + shape = contracts.load(FINDING_SHAPE) + assert "const" not in shape["properties"]["kind"] + assert "schema_version" not in shape["properties"] + assert FINDING_SHAPE not in validator.KIND_ENTRIES + with pytest.raises(validator.UnknownKind): + validator.validator_for(FINDING_SHAPE) + assert contracts.verified_bytes(FINDING_SHAPE) == ( + PACKAGE / "schemas" / f"{FINDING_SHAPE}.schema.yaml").read_bytes() + + def test_each_kind_is_the_const_its_entry_declares() -> None: """The validator's map of kinds is the copies' own: each kind's entry declares that kind as its `kind` const. So a kind the map names and no - copy declares, or a copy's kind the map leaves out, fails here.""" + copy declares, or a copy's kind the map leaves out, fails here. It reads + the VALIDATOR's copies only: the finding shape declares no `kind` const + (N-15), and the set test above holds the two lists apart.""" derived = {} - for copy_id in THE_FOUR: + for copy_id in VALIDATOR_COPIES: document = contracts.load(copy_id) envelopes = [document] if "oneOf" not in document else [ validator._at_pointer(document, branch["$ref"][1:]) @@ -313,15 +357,17 @@ def test_an_absent_copy_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: ({"kind": "pinned_contract_manifest"}, "kind is"), ({"schema_version": True}, "schema_version is"), ({"unread": 1}, "its keys are"), - # 7.1b at run time: an edited record cannot let a fifth schema in, even a - # well-formed entry whose file sits beside the four, nor leave one out. + # 7.1b at run time: an edited record cannot let another schema in, even a + # well-formed entry whose file sits beside the copies, nor leave one out. ({"copies": _SHIPPED + [{"id": "gate-intent", "path": "contracts/schemas/gate-intent.schema.yaml", - "sha256": "0" * 64}]}, "not openDox's four"), - ({"copies": _SHIPPED[:3]}, "not openDox's four"), + "sha256": "0" * 64}]}, "not the copies openDox carries"), + ({"copies": _SHIPPED[:-1]}, "not the copies openDox carries"), + ({"copies": [entry for entry in _SHIPPED if entry["id"] != "opendox-health-finding"]}, + "not the copies openDox carries"), ], ids=["empty digest", "no digest", "wrong path", "repeated id", "no copies", "short commit", "another leg", "another kind", "boolean version", "unknown key", - "a fifth copy", "three copies"]) + "a copy outside the set", "a copy left out", "the finding shape left out"]) def test_a_record_that_cannot_hold_every_copy_is_refused( monkeypatch: pytest.MonkeyPatch, changes: dict, says: str) -> None: """An empty or absent digest is drift and never a pass, and so is a record @@ -403,7 +449,7 @@ def test_the_record_as_shipped_is_accepted() -> None: """The negative cases above change one field each of the shipped record, so this is their control.""" record = contracts.record() - assert (record.commit, record.ids) == (SPEC_COMMIT, THE_FOUR) + assert (record.commit, record.ids) == (SPEC_COMMIT, THE_COPIES) # ---------------------------------------------------------------------------