diff --git a/.agents/context/schema-spec.md b/.agents/context/schema-spec.md index b8ce26a5..38a0efa2 100644 --- a/.agents/context/schema-spec.md +++ b/.agents/context/schema-spec.md @@ -124,8 +124,8 @@ Meta-schema encodes this via `allOf` with 4 `if/then` branches keyed on `type`. - **Per-type constraints:** `allOf` of `if/then` branches (better error messages than `oneOf` in ajv / python-jsonschema) - **Unknown keys:** `unevaluatedProperties: false` at every object level (not `additionalProperties: false` — the latter breaks composition with `allOf`) - **Extensions:** `patternProperties: { "^x-": {} }` at every object level -- **Composition:** `$defs` + `$ref` internal; absolute-URL `$ref` across files -- **Source of truth:** split YAML files in `schemas/v0.1.0/` (OpenAPI-style), bundled to a single JSON artifact on release (AsyncAPI-style). Bundling script in `scripts/`. +- **Composition:** `$defs` + `$ref` internal; no cross-file `$ref` (revised from the original "split + bundle" plan after the format proved small enough that splitting cost more than it saved). +- **Source of truth:** two single-file meta-schemas under `schemas/v0.1.0/` — `decree-schema.yaml` (validates `*.decree.schema.yaml`) and `decree-config.yaml` (validates `*.decree.config.yaml`). YAML is the human-edited source; JSON copies are generated by `scripts/yaml-to-json.py` and committed alongside for tooling consumers (schemastore.org, IDE language servers) that prefer JSON. ### Breaking vs additive (for future minor/patch bumps) @@ -186,8 +186,8 @@ Meta-schema encodes this via `allOf` with 4 `if/then` branches keyed on `type`. ### Phase B — Meta-schema -- #123 — Author split-file YAML meta-schema under `schemas/v0.1.0/` + bundling script -- #124 — CI: `check-jsonschema` validates every checked-in schema YAML + known-invalid fixtures +- #123 — Author meta-schemas under `schemas/v0.1.0/` (`decree-schema.yaml` + `decree-config.yaml`, single-file each, JSON copies generated) +- #124 — CI: validate every checked-in schema YAML + known-invalid fixtures via `make validate-meta-schemas` ### Phase C — Publishing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0001d301..048de743 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,12 +475,42 @@ jobs: go-version-file: go.mod work-dir: . + # Validates every checked-in *.decree.schema.yaml and *.decree.config.yaml + # against the v0.1.0 meta-schemas, and asserts every fixture under + # schemas/v0.1.0/testdata/invalid/ FAILS validation. Closes #124. + # + # Runs unconditionally — no needs.changes gate — because the script also + # cross-checks the meta-schema sources themselves and is fast enough that + # path-filtering would add more complexity than it saves. + meta-schemas: + name: Meta-schemas check + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install jsonschema + run: pip install --no-cache-dir 'jsonschema>=4.21' 'PyYAML>=6' + + - name: Validate meta-schemas + run: make validate-meta-schemas + # Aggregates all job results for branch protection. A single required check # that passes iff every listed job passed or was legitimately skipped. check: name: CI check if: always() - needs: [lint, test, sdk-compat, docs, e2e, examples, govulncheck, deps-review] + needs: [lint, test, sdk-compat, docs, e2e, examples, govulncheck, deps-review, meta-schemas] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 777b9205..49b867af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,7 @@ modify specs → generate code → test → lint → build → deploy → e2e te | `make docs` | Generate all documentation (API + CLI + man pages) | Mixed | | `make image` | Build the Docker image | Docker | | `make migrate` | Run database migrations | Docker | +| `make validate-meta-schemas` | Validate canonical `*.decree.schema.yaml` and `*.decree.config.yaml` files against the v0.1.0 meta-schemas under `schemas/v0.1.0/` (requires `pip install jsonschema PyYAML`) | Local | | `make bench` | Run unit benchmarks | Local | | `make bench-e2e` | Run e2e benchmarks against docker stack | Docker | | `make clean` | Remove build artifacts and generated code | Mixed | diff --git a/Makefile b/Makefile index 253020d3..3247aef9 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ CLI_LDFLAGS := -X main.cliVersion=$(GIT_VERSION) -X main.cliCommit=$(GIT_COMMIT) # Module list for multi-module operations. SDK_MODULES := sdk/configclient sdk/adminclient sdk/configwatcher sdk/tools -.PHONY: all generate generate-proto generate-sqlc test lint build image migrate e2e examples bench bench-e2e docs docs-api docs-cli docs-man docs-serve docs-deploy pre-commit clean tools help demo-gif +.PHONY: all generate generate-proto generate-sqlc test lint build image migrate e2e examples bench bench-e2e docs docs-api docs-cli docs-man docs-serve docs-deploy pre-commit clean tools help demo-gif validate-meta-schemas all: generate lint test build @@ -81,6 +81,10 @@ test: go test ./... -race -count=1 @for mod in $(SDK_MODULES) cmd/decree; do (cd $$mod && go test ./... -race -count=1) || exit 1; done +## validate-meta-schemas: Validate canonical schema/config YAMLs against the v0.1.0 meta-schemas +validate-meta-schemas: + python3 scripts/validate-meta-schemas.py + ## pre-commit: Run all before-commit checks (build, vet, format, lint, test, coverage) pre-commit: @echo "=== Build ===" diff --git a/docs/concepts/schemas-and-fields.md b/docs/concepts/schemas-and-fields.md index 4bb4495b..9d84809f 100644 --- a/docs/concepts/schemas-and-fields.md +++ b/docs/concepts/schemas-and-fields.md @@ -26,7 +26,7 @@ Published versions are immutable — you cannot change their fields. To evolve a Schemas are defined in YAML for import/export. The format uses syntax version `v1`: ```yaml -# yaml-language-server: $schema=../../schemas/schema-yaml.json +# yaml-language-server: $schema=../../schemas/v0.1.0/decree-schema.json spec_version: "v1" name: payments description: Payment processing configuration @@ -101,7 +101,7 @@ fields: url: https://docs.example.com/webhooks ``` -A [JSON Schema](../../schemas/schema-yaml.json) is available for editor validation and autocomplete. +JSON Schemas for editor validation and autocomplete are available under [`schemas/v0.1.0/`](../../schemas/v0.1.0/) — `decree-schema.json` validates the schema-definition format documented here, and `decree-config.json` validates the tenant-side config format. ### Required fields diff --git a/schemas/schema-yaml.json b/schemas/schema-yaml.json deleted file mode 100644 index 05a8712a..00000000 --- a/schemas/schema-yaml.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/opendecree/decree/schemas/schema-yaml.json", - "title": "OpenDecree Schema YAML", - "description": "Schema definition format for the OpenDecree (spec_version v1).", - "type": "object", - "required": ["spec_version", "name", "fields"], - "additionalProperties": false, - "properties": { - "spec_version": { - "type": "string", - "const": "v1", - "description": "Decree schema spec version. Must be \"v1\"." - }, - "name": { - "type": "string", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", - "minLength": 1, - "maxLength": 63, - "description": "Unique schema name. Must be a slug: lowercase alphanumeric and hyphens, 1-63 characters." - }, - "description": { - "type": "string", - "description": "Human-readable description of the schema's purpose." - }, - "version": { - "type": "integer", - "minimum": 1, - "description": "Informational — the server assigns the next version number on import." - }, - "version_description": { - "type": "string", - "description": "Description of what changed in this version." - }, - "info": { - "$ref": "#/$defs/info" - }, - "fields": { - "type": "object", - "minProperties": 1, - "description": "Field definitions keyed by dot-separated path (e.g. \"payments.fee\").", - "additionalProperties": { - "$ref": "#/$defs/field" - } - } - }, - "$defs": { - "info": { - "type": "object", - "additionalProperties": false, - "description": "Optional schema-level metadata: ownership, contact, labels.", - "properties": { - "title": { - "type": "string", - "description": "Human-friendly display title for the schema." - }, - "author": { - "type": "string", - "description": "Schema owner identifier (person, team, or service)." - }, - "contact": { - "$ref": "#/$defs/contact" - }, - "labels": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "Key-value labels for filtering and categorization." - } - } - }, - "contact": { - "type": "object", - "additionalProperties": false, - "description": "Contact information for the schema owner.", - "properties": { - "name": { - "type": "string", - "description": "Contact name (person or team)." - }, - "email": { - "type": "string", - "format": "email", - "description": "Contact email address." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Contact URL (e.g. team wiki page)." - } - } - }, - "field": { - "type": "object", - "required": ["type"], - "additionalProperties": false, - "properties": { - "type": { - "type": "string", - "enum": ["integer", "number", "string", "bool", "time", "duration", "url", "json"], - "description": "Field value type." - }, - "description": { - "type": "string", - "description": "Human-readable description of the field's purpose." - }, - "default": { - "type": "string", - "description": "Default value for this field, encoded as a string." - }, - "nullable": { - "type": "boolean", - "default": false, - "description": "Whether this field accepts null values." - }, - "deprecated": { - "type": "boolean", - "default": false, - "description": "Whether this field is deprecated." - }, - "redirect_to": { - "type": "string", - "description": "When deprecated, reads can be redirected to this field path." - }, - "constraints": { - "$ref": "#/$defs/constraints" - }, - "title": { - "type": "string", - "description": "Human-friendly display name (e.g. \"Fee Rate\" for payments.fee_rate)." - }, - "example": { - "type": "string", - "description": "Single example value, encoded as a string." - }, - "examples": { - "type": "object", - "description": "Named examples with value and optional summary.", - "additionalProperties": { - "$ref": "#/$defs/example" - } - }, - "externalDocs": { - "$ref": "#/$defs/externalDocs" - }, - "tags": { - "type": "array", - "items": { "type": "string" }, - "description": "Tags for grouping and categorization." - }, - "format": { - "type": "string", - "description": "Semantic format hint (e.g. \"email\", \"semver\", \"percentage\"). Not enforced." - }, - "readOnly": { - "type": "boolean", - "default": false, - "description": "System-managed field, not user-editable." - }, - "writeOnce": { - "type": "boolean", - "default": false, - "description": "Field can only be set once, immutable after." - }, - "sensitive": { - "type": "boolean", - "default": false, - "description": "Value should be masked in logs and UI." - } - } - }, - "example": { - "type": "object", - "required": ["value"], - "additionalProperties": false, - "properties": { - "value": { - "type": "string", - "description": "The example value." - }, - "summary": { - "type": "string", - "description": "Short description of what this example demonstrates." - } - } - }, - "externalDocs": { - "type": "object", - "required": ["url"], - "additionalProperties": false, - "description": "Link to external documentation.", - "properties": { - "description": { - "type": "string", - "description": "Human-readable description of the external documentation." - }, - "url": { - "type": "string", - "format": "uri", - "description": "URL to the external documentation." - } - } - }, - "constraints": { - "type": "object", - "additionalProperties": false, - "description": "Validation constraints. Which constraints apply depends on the field type.", - "properties": { - "minimum": { - "type": "number", - "description": "For integer/number/duration: minimum allowed value (inclusive, >=)." - }, - "maximum": { - "type": "number", - "description": "For integer/number/duration: maximum allowed value (inclusive, <=)." - }, - "exclusiveMinimum": { - "type": "number", - "description": "For integer/number/duration: exclusive minimum (strict, >)." - }, - "exclusiveMaximum": { - "type": "number", - "description": "For integer/number/duration: exclusive maximum (strict, <)." - }, - "minLength": { - "type": "integer", - "minimum": 0, - "description": "For string: minimum allowed length." - }, - "maxLength": { - "type": "integer", - "minimum": 0, - "description": "For string: maximum allowed length." - }, - "pattern": { - "type": "string", - "description": "Regular expression the value must match. Applies to string fields. RE2 syntax." - }, - "enum": { - "type": "array", - "items": { "type": "string" }, - "minItems": 1, - "description": "Allowed values. The value must be one of these. Applies to any field type." - }, - "json_schema": { - "type": "string", - "description": "JSON Schema document for structural validation. Applies to json fields." - } - } - } - } -} diff --git a/schemas/v0.1.0/decree-config.json b/schemas/v0.1.0/decree-config.json new file mode 100644 index 00000000..09290a01 --- /dev/null +++ b/schemas/v0.1.0/decree-config.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.opendecree.io/schema/v0.1.0/decree-config.json", + "title": "OpenDecree Config Format (v0.1.0)", + "description": "Tenant-side import format for OpenDecree config values. A decree.config.yaml file declares the values to apply against a previously imported schema; the server enforces type compatibility and cross-field rules at write time.", + "type": "object", + "required": [ + "spec_version", + "values" + ], + "properties": { + "spec_version": { + "description": "Decree config-format spec version. Must be \"v1\".", + "type": "string", + "const": "v1" + }, + "$schema": { + "description": "Optional pointer to this meta-schema. HTTPS URL.", + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "description": { + "description": "Human-readable description of the config (e.g. release notes).", + "type": "string" + }, + "version": { + "description": "Informational config version. The server assigns the next version on import; this field is round-tripped through ImportConfig / ExportConfig for documentation purposes.", + "type": "integer", + "minimum": 1 + }, + "values": { + "description": "Map of field path to value entry. Field paths must reference real fields in the bound schema; the server enforces this at import. The value's wire shape depends on the schema's field type (integer/number/string/bool/time/duration/url/json) \u2014 meta-schema does not type-check the value itself.", + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/fieldPath" + }, + "additionalProperties": { + "$ref": "#/$defs/configValue" + } + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false, + "$defs": { + "fieldPath": { + "description": "Field path. ASCII letter/underscore start; alphanumeric, underscore, dot, hyphen.", + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + }, + "configValue": { + "description": "One value entry. The actual value's type is checked server-side against the schema.", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "The value to apply. Type must match the bound schema's field type \u2014 checked server-side. Null indicates the field is being cleared." + }, + "description": { + "description": "Human-readable description of this specific value.", + "type": "string" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + } + } +} diff --git a/schemas/v0.1.0/decree-config.yaml b/schemas/v0.1.0/decree-config.yaml new file mode 100644 index 00000000..200a57bb --- /dev/null +++ b/schemas/v0.1.0/decree-config.yaml @@ -0,0 +1,87 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +# +# OpenDecree config-format meta-schema (v0.1.0). +# +# Validates `decree.config.yaml` files — the values format that tenants +# apply against a schema previously imported via decree.schema.yaml. +# Layer-1 structural validation only: per-field type checking, constraint +# enforcement, and cross-field rules (dependentRequired / validations) all +# stay server-side, since they need the bound schema as context. +# +# This document is authored in YAML for readability and converted to JSON +# at publish time. Both forms are committed alongside each other; tooling +# may consume either. + +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://schemas.opendecree.io/schema/v0.1.0/decree-config.json" +title: OpenDecree Config Format (v0.1.0) +description: >- + Tenant-side import format for OpenDecree config values. A + decree.config.yaml file declares the values to apply against a + previously imported schema; the server enforces type compatibility and + cross-field rules at write time. + +type: object +required: [spec_version, values] + +properties: + spec_version: + description: Decree config-format spec version. Must be "v1". + type: string + const: v1 + $schema: + description: Optional pointer to this meta-schema. HTTPS URL. + type: string + format: uri + pattern: "^https://" + description: + description: Human-readable description of the config (e.g. release notes). + type: string + version: + description: >- + Informational config version. The server assigns the next version + on import; this field is round-tripped through ImportConfig / + ExportConfig for documentation purposes. + type: integer + minimum: 1 + values: + description: >- + Map of field path to value entry. Field paths must reference real + fields in the bound schema; the server enforces this at import. + The value's wire shape depends on the schema's field type + (integer/number/string/bool/time/duration/url/json) — meta-schema + does not type-check the value itself. + type: object + minProperties: 1 + propertyNames: + $ref: "#/$defs/fieldPath" + additionalProperties: + $ref: "#/$defs/configValue" + +patternProperties: + "^x-": true + +unevaluatedProperties: false + +$defs: + fieldPath: + description: Field path. ASCII letter/underscore start; alphanumeric, underscore, dot, hyphen. + type: string + pattern: "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + + configValue: + description: One value entry. The actual value's type is checked server-side against the schema. + type: object + required: [value] + properties: + value: + description: >- + The value to apply. Type must match the bound schema's field + type — checked server-side. Null indicates the field is being + cleared. + description: + description: Human-readable description of this specific value. + type: string + patternProperties: + "^x-": true + unevaluatedProperties: false diff --git a/schemas/v0.1.0/decree-schema.json b/schemas/v0.1.0/decree-schema.json new file mode 100644 index 00000000..c899ad53 --- /dev/null +++ b/schemas/v0.1.0/decree-schema.json @@ -0,0 +1,466 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.opendecree.io/schema/v0.1.0/decree-schema.json", + "title": "OpenDecree Schema Format (v0.1.0)", + "description": "Schema definition format for OpenDecree. A decree.schema.yaml file declares the fields, types, constraints, and cross-field rules of a schema that tenants can then bind to and apply config values against.", + "type": "object", + "required": [ + "spec_version", + "name", + "fields" + ], + "properties": { + "spec_version": { + "description": "Decree schema-format spec version. Must be \"v1\".", + "type": "string", + "const": "v1" + }, + "$schema": { + "description": "Optional pointer to this meta-schema. HTTPS URL.", + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "$id": { + "description": "Optional URN identifying this schema document. Format urn:decree:schema:(:)*.", + "type": "string", + "pattern": "^urn:decree:schema:[a-zA-Z0-9][a-zA-Z0-9._-]*(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)*$" + }, + "name": { + "description": "Schema name. Slug \u2014 lowercase alphanumeric + hyphens.", + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 63 + }, + "description": { + "description": "Human-readable description of the schema.", + "type": "string" + }, + "version": { + "description": "Informational schema version number. The server assigns the next version on import; this field is round-tripped through ImportSchema / GetSchema for documentation purposes.", + "type": "integer", + "minimum": 1 + }, + "version_description": { + "description": "Description of what changed in this schema version.", + "type": "string" + }, + "info": { + "$ref": "#/$defs/info" + }, + "fields": { + "description": "The fields defined in this schema version. Map keys are field paths matching the field-path regex; at least one entry is required.", + "type": "object", + "minProperties": 1, + "propertyNames": { + "$ref": "#/$defs/fieldPath" + }, + "additionalProperties": { + "$ref": "#/$defs/field" + } + }, + "dependentRequired": { + "description": "Cross-field \"B required when A present\" rules. Each key is a trigger field path; each value is the list of dependent paths that must be non-null when the trigger is non-null. Field-existence checking is performed by the Go parser, not the meta-schema.", + "type": "object", + "propertyNames": { + "$ref": "#/$defs/fieldPath" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + }, + "uniqueItems": true + } + }, + "validations": { + "description": "Cross-field rules expressed in CEL. Reserved in v0.1.0 \u2014 the parser persists rules; the runtime engine ships in Phase 2 (issue #76).", + "type": "array", + "items": { + "$ref": "#/$defs/validation" + } + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false, + "$defs": { + "fieldPath": { + "description": "Field path. ASCII letter/underscore start; alphanumeric, underscore, dot, hyphen.", + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + }, + "field": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "integer", + "number", + "string", + "bool", + "time", + "duration", + "url", + "json" + ] + }, + "description": { + "type": "string" + }, + "default": { + "type": "string" + }, + "nullable": { + "type": "boolean" + }, + "deprecated": { + "type": "boolean" + }, + "redirect_to": { + "description": "Target field path for deprecated-field reads. Existence checked Go-side.", + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + }, + "title": { + "type": "string" + }, + "example": { + "type": "string" + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example" + } + }, + "externalDocs": { + "$ref": "#/$defs/externalDocs" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "format": { + "description": "Free-form semantic format hint (e.g. \"email\", \"semver\", \"percentage\"). Informational only \u2014 not enforced by validation.", + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "writeOnce": { + "type": "boolean" + }, + "sensitive": { + "type": "boolean" + }, + "constraints": { + "type": "object" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "integer", + "number", + "duration" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "constraints": { + "$ref": "#/$defs/constraintsNumeric" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "constraints": { + "$ref": "#/$defs/constraintsString" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "json" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "constraints": { + "$ref": "#/$defs/constraintsJSON" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "bool", + "time", + "url" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "constraints": { + "$ref": "#/$defs/constraintsOther" + } + } + } + } + ] + }, + "constraintsNumeric": { + "description": "Constraints valid for integer / number / duration fields.", + "type": "object", + "properties": { + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "enum": { + "type": "array", + "items": true + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "constraintsString": { + "description": "Constraints valid for string fields.", + "type": "object", + "properties": { + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "pattern": { + "description": "RE2 regex the value must match.", + "type": "string" + }, + "enum": { + "type": "array", + "items": true + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "constraintsJSON": { + "description": "Constraints valid for json fields.", + "type": "object", + "properties": { + "json_schema": { + "description": "Embedded JSON Schema (as a JSON-encoded string) for structural validation.", + "type": "string" + }, + "enum": { + "type": "array", + "items": true + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "constraintsOther": { + "description": "Constraints valid for bool / time / url fields.", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": true + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "info": { + "description": "Optional schema-level metadata. OAS Info Object.", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "author": { + "type": "string" + }, + "contact": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "example": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string" + }, + "summary": { + "type": "string" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "externalDocs": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + }, + "validation": { + "description": "One CEL validation rule. See cel-validation.md.", + "type": "object", + "required": [ + "rule", + "message" + ], + "properties": { + "path": { + "description": "Optional path prefix scoping the rule. Empty == schema-wide.", + "type": "string" + }, + "rule": { + "description": "CEL expression source. Compilation deferred to Phase 2.", + "type": "string", + "minLength": 1 + }, + "message": { + "description": "Human-readable failure message shown to clients.", + "type": "string", + "minLength": 1 + }, + "severity": { + "description": "error (default) rejects writes; warning is non-blocking (reserved for Phase 2). Empty string permitted to match the Go parser's default-to-error behavior.", + "type": "string", + "enum": [ + "", + "error", + "warning" + ] + }, + "reason": { + "description": "Optional machine-readable failure code.", + "type": "string" + } + }, + "patternProperties": { + "^x-": true + }, + "unevaluatedProperties": false + } + } +} diff --git a/schemas/v0.1.0/decree-schema.yaml b/schemas/v0.1.0/decree-schema.yaml new file mode 100644 index 00000000..1fa06752 --- /dev/null +++ b/schemas/v0.1.0/decree-schema.yaml @@ -0,0 +1,325 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +# +# OpenDecree schema-format meta-schema (v0.1.0). +# +# Validates `decree.schema.yaml` files — the format authors use to declare +# the fields, types, and constraints of a schema. Layer-1 structural +# validation only: per the design brief, semantic rules that JSON Schema +# cannot express (e.g. referential integrity between redirect_to / +# dependentRequired / validations and the field set) stay in the Go +# parser at internal/schema/validate_constraints.go. +# +# This document is authored in YAML for readability and converted to JSON +# at publish time. Both forms are committed alongside each other; tooling +# may consume either. + +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://schemas.opendecree.io/schema/v0.1.0/decree-schema.json" +title: OpenDecree Schema Format (v0.1.0) +description: >- + Schema definition format for OpenDecree. A decree.schema.yaml file + declares the fields, types, constraints, and cross-field rules of a + schema that tenants can then bind to and apply config values against. + +type: object +required: [spec_version, name, fields] + +properties: + spec_version: + description: Decree schema-format spec version. Must be "v1". + type: string + const: v1 + $schema: + description: Optional pointer to this meta-schema. HTTPS URL. + type: string + format: uri + pattern: "^https://" + $id: + description: >- + Optional URN identifying this schema document. Format + urn:decree:schema:(:)*. + type: string + pattern: "^urn:decree:schema:[a-zA-Z0-9][a-zA-Z0-9._-]*(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)*$" + name: + description: Schema name. Slug — lowercase alphanumeric + hyphens. + type: string + pattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + minLength: 1 + maxLength: 63 + description: + description: Human-readable description of the schema. + type: string + version: + description: >- + Informational schema version number. The server assigns the next + version on import; this field is round-tripped through ImportSchema / + GetSchema for documentation purposes. + type: integer + minimum: 1 + version_description: + description: Description of what changed in this schema version. + type: string + info: + $ref: "#/$defs/info" + fields: + description: >- + The fields defined in this schema version. Map keys are field paths + matching the field-path regex; at least one entry is required. + type: object + minProperties: 1 + propertyNames: + $ref: "#/$defs/fieldPath" + additionalProperties: + $ref: "#/$defs/field" + dependentRequired: + description: >- + Cross-field "B required when A present" rules. Each key is a + trigger field path; each value is the list of dependent paths that + must be non-null when the trigger is non-null. Field-existence + checking is performed by the Go parser, not the meta-schema. + type: object + propertyNames: + $ref: "#/$defs/fieldPath" + additionalProperties: + type: array + items: + type: string + pattern: "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + uniqueItems: true + validations: + description: >- + Cross-field rules expressed in CEL. Reserved in v0.1.0 — the parser + persists rules; the runtime engine ships in Phase 2 (issue #76). + type: array + items: + $ref: "#/$defs/validation" + +patternProperties: + "^x-": true + +unevaluatedProperties: false + +$defs: + fieldPath: + description: Field path. ASCII letter/underscore start; alphanumeric, underscore, dot, hyphen. + type: string + pattern: "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + + field: + type: object + required: [type] + properties: + type: + type: string + enum: [integer, number, string, bool, time, duration, url, json] + description: { type: string } + default: { type: string } + nullable: { type: boolean } + deprecated: { type: boolean } + redirect_to: + description: Target field path for deprecated-field reads. Existence checked Go-side. + type: string + pattern: "^[a-zA-Z_][a-zA-Z0-9_.-]*$" + title: { type: string } + example: { type: string } + examples: + type: object + additionalProperties: + $ref: "#/$defs/example" + externalDocs: + $ref: "#/$defs/externalDocs" + tags: + type: array + items: { type: string } + format: + description: >- + Free-form semantic format hint (e.g. "email", "semver", + "percentage"). Informational only — not enforced by validation. + type: string + readOnly: { type: boolean } + writeOnce: { type: boolean } + sensitive: { type: boolean } + constraints: + type: object + patternProperties: + "^x-": true + unevaluatedProperties: false + # Per-type constraint compatibility. Use allOf [if/then] over four + # type buckets — better error messages than oneOf in ajv and + # python-jsonschema. Each branch sets the shape of `constraints` so + # incompatible constraint keys fail at meta-schema level. + allOf: + - if: + properties: + type: + enum: [integer, number, duration] + required: [type] + then: + properties: + constraints: + $ref: "#/$defs/constraintsNumeric" + - if: + properties: + type: + const: string + required: [type] + then: + properties: + constraints: + $ref: "#/$defs/constraintsString" + - if: + properties: + type: + const: json + required: [type] + then: + properties: + constraints: + $ref: "#/$defs/constraintsJSON" + - if: + properties: + type: + enum: [bool, time, url] + required: [type] + then: + properties: + constraints: + $ref: "#/$defs/constraintsOther" + + constraintsNumeric: + description: Constraints valid for integer / number / duration fields. + type: object + properties: + minimum: { type: number } + maximum: { type: number } + exclusiveMinimum: { type: number } + exclusiveMaximum: { type: number } + enum: + type: array + items: true + patternProperties: + "^x-": true + unevaluatedProperties: false + + constraintsString: + description: Constraints valid for string fields. + type: object + properties: + minLength: { type: integer, minimum: 0 } + maxLength: { type: integer, minimum: 0 } + pattern: + description: RE2 regex the value must match. + type: string + enum: + # Items are unrestricted — proto stores enum values as strings, + # but YAML authors typically write native primitives (numbers, + # bools) that the Go parser coerces to strings on import. Don't + # force authors to quote. + type: array + items: true + patternProperties: + "^x-": true + unevaluatedProperties: false + + constraintsJSON: + description: Constraints valid for json fields. + type: object + properties: + json_schema: + description: Embedded JSON Schema (as a JSON-encoded string) for structural validation. + type: string + enum: + type: array + items: true + patternProperties: + "^x-": true + unevaluatedProperties: false + + constraintsOther: + description: Constraints valid for bool / time / url fields. + type: object + properties: + enum: + type: array + items: true + patternProperties: + "^x-": true + unevaluatedProperties: false + + info: + description: Optional schema-level metadata. OAS Info Object. + type: object + properties: + title: { type: string } + author: { type: string } + contact: + type: object + properties: + name: { type: string } + email: + type: string + format: email + url: + type: string + format: uri + patternProperties: + "^x-": true + unevaluatedProperties: false + labels: + type: object + additionalProperties: { type: string } + patternProperties: + "^x-": true + unevaluatedProperties: false + + example: + type: object + required: [value] + properties: + value: { type: string } + summary: { type: string } + patternProperties: + "^x-": true + unevaluatedProperties: false + + externalDocs: + type: object + required: [url] + properties: + url: + type: string + format: uri + description: { type: string } + patternProperties: + "^x-": true + unevaluatedProperties: false + + validation: + description: One CEL validation rule. See cel-validation.md. + type: object + required: [rule, message] + properties: + path: + description: Optional path prefix scoping the rule. Empty == schema-wide. + type: string + rule: + description: CEL expression source. Compilation deferred to Phase 2. + type: string + minLength: 1 + message: + description: Human-readable failure message shown to clients. + type: string + minLength: 1 + severity: + description: >- + error (default) rejects writes; warning is non-blocking + (reserved for Phase 2). Empty string permitted to match the + Go parser's default-to-error behavior. + type: string + enum: ["", "error", "warning"] + reason: + description: Optional machine-readable failure code. + type: string + patternProperties: + "^x-": true + unevaluatedProperties: false diff --git a/schemas/v0.1.0/testdata/invalid/config/bad-field-path.decree.config.yaml b/schemas/v0.1.0/testdata/invalid/config/bad-field-path.decree.config.yaml new file mode 100644 index 00000000..af124902 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/config/bad-field-path.decree.config.yaml @@ -0,0 +1,5 @@ +# Asserts: values map keys must match the field-path regex. +spec_version: v1 +values: + "1bad-path": + value: 5 diff --git a/schemas/v0.1.0/testdata/invalid/config/empty-values.decree.config.yaml b/schemas/v0.1.0/testdata/invalid/config/empty-values.decree.config.yaml new file mode 100644 index 00000000..e8e00444 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/config/empty-values.decree.config.yaml @@ -0,0 +1,3 @@ +# Asserts: `values` must contain at least one entry. +spec_version: v1 +values: {} diff --git a/schemas/v0.1.0/testdata/invalid/config/missing-values.decree.config.yaml b/schemas/v0.1.0/testdata/invalid/config/missing-values.decree.config.yaml new file mode 100644 index 00000000..0c9690b7 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/config/missing-values.decree.config.yaml @@ -0,0 +1,3 @@ +# Asserts: top-level `values` is required. +spec_version: v1 +description: missing values diff --git a/schemas/v0.1.0/testdata/invalid/config/unknown-top-level-key.decree.config.yaml b/schemas/v0.1.0/testdata/invalid/config/unknown-top-level-key.decree.config.yaml new file mode 100644 index 00000000..6a0c7c81 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/config/unknown-top-level-key.decree.config.yaml @@ -0,0 +1,7 @@ +# Asserts: unknown top-level keys are rejected (only x-* extensions +# allowed at the top level). +spec_version: v1 +values: + payments.fee: + value: "0.5" +unknown_key: bogus diff --git a/schemas/v0.1.0/testdata/invalid/config/value-entry-missing-value.decree.config.yaml b/schemas/v0.1.0/testdata/invalid/config/value-entry-missing-value.decree.config.yaml new file mode 100644 index 00000000..00a1af86 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/config/value-entry-missing-value.decree.config.yaml @@ -0,0 +1,5 @@ +# Asserts: each entry in values must contain a `value` key. +spec_version: v1 +values: + payments.fee: + description: "no value provided" diff --git a/schemas/v0.1.0/testdata/invalid/schema/bad-field-path-leading-digit.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/bad-field-path-leading-digit.decree.schema.yaml new file mode 100644 index 00000000..7ffd7b34 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/bad-field-path-leading-digit.decree.schema.yaml @@ -0,0 +1,7 @@ +# Asserts: field paths must start with a letter or underscore. Leading +# digit rejected. +spec_version: v1 +name: payments +fields: + 1starts_with_digit: + type: string diff --git a/schemas/v0.1.0/testdata/invalid/schema/bad-name-slug.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/bad-name-slug.decree.schema.yaml new file mode 100644 index 00000000..df70c677 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/bad-name-slug.decree.schema.yaml @@ -0,0 +1,7 @@ +# Asserts: name must be a slug (lowercase alphanumeric + hyphens, 1-63 +# chars). Underscores and uppercase rejected. +spec_version: v1 +name: Bad_Name +fields: + payments.fee: + type: string diff --git a/schemas/v0.1.0/testdata/invalid/schema/bad-spec-version.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/bad-spec-version.decree.schema.yaml new file mode 100644 index 00000000..d4822dc8 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/bad-spec-version.decree.schema.yaml @@ -0,0 +1,6 @@ +# Asserts: spec_version must be the literal "v1". +spec_version: v2 +name: payments +fields: + payments.fee: + type: string diff --git a/schemas/v0.1.0/testdata/invalid/schema/dependent-required-bad-path.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/dependent-required-bad-path.decree.schema.yaml new file mode 100644 index 00000000..d40db545 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/dependent-required-bad-path.decree.schema.yaml @@ -0,0 +1,10 @@ +# Asserts: dependentRequired keys must match the field-path regex +# (note: existence-against-fields is checked Go-side, not by meta-schema). +spec_version: v1 +name: payments +fields: + payments.a: + type: string +dependentRequired: + "1bad-path": + - payments.a diff --git a/schemas/v0.1.0/testdata/invalid/schema/empty-fields.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/empty-fields.decree.schema.yaml new file mode 100644 index 00000000..b0d12802 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/empty-fields.decree.schema.yaml @@ -0,0 +1,4 @@ +# Asserts: `fields` must contain at least one entry. +spec_version: v1 +name: payments +fields: {} diff --git a/schemas/v0.1.0/testdata/invalid/schema/missing-required-fields-key.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/missing-required-fields-key.decree.schema.yaml new file mode 100644 index 00000000..803bb4be --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/missing-required-fields-key.decree.schema.yaml @@ -0,0 +1,3 @@ +# Asserts: top-level `fields` is required. +spec_version: v1 +name: payments diff --git a/schemas/v0.1.0/testdata/invalid/schema/unknown-field-type.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/unknown-field-type.decree.schema.yaml new file mode 100644 index 00000000..0a5de850 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/unknown-field-type.decree.schema.yaml @@ -0,0 +1,6 @@ +# Asserts: field type must be one of the 8 documented values. +spec_version: v1 +name: payments +fields: + payments.fee: + type: float64 diff --git a/schemas/v0.1.0/testdata/invalid/schema/unknown-top-level-key.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/unknown-top-level-key.decree.schema.yaml new file mode 100644 index 00000000..69252417 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/unknown-top-level-key.decree.schema.yaml @@ -0,0 +1,8 @@ +# Asserts: unknown top-level keys are rejected (only x-* extensions +# allowed at the top level). +spec_version: v1 +name: payments +fields: + payments.fee: + type: string +unknown_top_level_key: bogus diff --git a/schemas/v0.1.0/testdata/invalid/schema/validation-bad-severity.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/validation-bad-severity.decree.schema.yaml new file mode 100644 index 00000000..1e463cc7 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/validation-bad-severity.decree.schema.yaml @@ -0,0 +1,10 @@ +# Asserts: validations[*].severity must be one of "error" / "warning". +spec_version: v1 +name: payments +fields: + payments.x: + type: string +validations: + - rule: "self.payments.x != ''" + message: "non-empty" + severity: critical diff --git a/schemas/v0.1.0/testdata/invalid/schema/validation-empty-rule.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/validation-empty-rule.decree.schema.yaml new file mode 100644 index 00000000..599b6f58 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/validation-empty-rule.decree.schema.yaml @@ -0,0 +1,11 @@ +# Asserts: validations[*].rule must be a non-empty string. +spec_version: v1 +name: payments +fields: + payments.min: + type: integer + payments.max: + type: integer +validations: + - rule: "" + message: "rule cannot be empty" diff --git a/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-min-on-string.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-min-on-string.decree.schema.yaml new file mode 100644 index 00000000..49b83123 --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-min-on-string.decree.schema.yaml @@ -0,0 +1,9 @@ +# Asserts: per-type constraint matrix rejects `minimum` on a string +# field (numeric-only constraint). +spec_version: v1 +name: payments +fields: + payments.label: + type: string + constraints: + minimum: 0 diff --git a/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-pattern-on-bool.decree.schema.yaml b/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-pattern-on-bool.decree.schema.yaml new file mode 100644 index 00000000..473e128f --- /dev/null +++ b/schemas/v0.1.0/testdata/invalid/schema/wrong-constraint-pattern-on-bool.decree.schema.yaml @@ -0,0 +1,9 @@ +# Asserts: per-type constraint matrix rejects `pattern` on a bool field +# (string-only constraint). +spec_version: v1 +name: payments +fields: + payments.enabled: + type: bool + constraints: + pattern: "^true$" diff --git a/scripts/validate-meta-schemas.py b/scripts/validate-meta-schemas.py new file mode 100644 index 00000000..e6d73b24 --- /dev/null +++ b/scripts/validate-meta-schemas.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""validate-meta-schemas: assert that meta-schemas accept what they should +and reject what they should. + +For every canonical schema/config YAML in the repo, the matching meta-schema +must accept it. For every fixture under schemas/v0.1.0/testdata/invalid/, +the meta-schema must REJECT it (proves the meta-schema isn't permissive +enough to leak malformed input through). + +Used by CI (issue #124) and by `make validate-meta-schemas` for local +sanity checks. Exits 0 on success; nonzero with a printed report listing +each failed assertion otherwise. + +Vanilla — depends only on PyYAML and jsonschema, both already available +in the project's tools image (and used by scripts/patch-openapi.py). +""" + +from __future__ import annotations + +import glob +import json +import sys +from pathlib import Path + +import jsonschema +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCHEMAS_DIR = REPO_ROOT / "schemas" / "v0.1.0" + +# Globs of canonical files that must validate cleanly against the named +# meta-schema. Fixtures under schemas/v0.1.0/testdata/invalid/ are handled +# separately as negative-cases. +_ROOTS = ("examples", "e2e", "docs") +# Match both the canonical bare names ("decree.schema.yaml", +# "decree.config.yaml") and the prefixed-glob form authors use when a +# repo holds multiple schemas (`payments.decree.schema.yaml`). +SCHEMA_GOOD_GLOBS = [f"{r}/**/decree.schema.yaml" for r in _ROOTS] + [ + f"{r}/**/*.decree.schema.yaml" for r in _ROOTS +] +CONFIG_GOOD_GLOBS = [f"{r}/**/decree.config.yaml" for r in _ROOTS] + [ + f"{r}/**/*.decree.config.yaml" for r in _ROOTS +] + + +def load_meta(name: str) -> dict: + with open(SCHEMAS_DIR / name) as f: + return json.load(f) + + +def expand(globs: list[str]) -> list[Path]: + out: list[Path] = [] + for g in globs: + out.extend(Path(p) for p in glob.glob(str(REPO_ROOT / g), recursive=True)) + return sorted(out) + + +def validate_one(meta: dict, path: Path) -> list[str]: + with open(path) as f: + doc = yaml.safe_load(f) + return [e.message for e in jsonschema.Draft202012Validator(meta).iter_errors(doc)] + + +def main() -> int: + schema_meta = load_meta("decree-schema.json") + config_meta = load_meta("decree-config.json") + failures: list[str] = [] + + # Positive cases: canonical files must validate cleanly. + schema_files = expand(SCHEMA_GOOD_GLOBS) + print(f"Validating {len(schema_files)} *.decree.schema.yaml against decree-schema.json") + for path in schema_files: + errs = validate_one(schema_meta, path) + if errs: + failures.append(f"{path.relative_to(REPO_ROOT)} expected pass but got {len(errs)} errors: {errs[0]}") + + config_files = expand(CONFIG_GOOD_GLOBS) + print(f"Validating {len(config_files)} *.decree.config.yaml against decree-config.json") + for path in config_files: + # Skip files explicitly named "invalid.*" — those are intentionally + # bad payloads used by the example to demonstrate validation errors; + # they validate at the meta-schema layer (file shape is fine), but + # the in-app validator rejects the values themselves. + if path.name.startswith("invalid"): + continue + errs = validate_one(config_meta, path) + if errs: + failures.append(f"{path.relative_to(REPO_ROOT)} expected pass but got {len(errs)} errors: {errs[0]}") + + # Negative cases: fixtures under testdata/invalid/ must FAIL. + for path in sorted((SCHEMAS_DIR / "testdata" / "invalid" / "schema").glob("*.yaml")): + errs = validate_one(schema_meta, path) + if not errs: + failures.append(f"{path.relative_to(REPO_ROOT)} expected fail but passed validation") + + for path in sorted((SCHEMAS_DIR / "testdata" / "invalid" / "config").glob("*.yaml")): + errs = validate_one(config_meta, path) + if not errs: + failures.append(f"{path.relative_to(REPO_ROOT)} expected fail but passed validation") + + if failures: + print() + print(f"FAIL: {len(failures)} assertion(s) failed:") + for f in failures: + print(f" - {f}") + return 1 + + print("OK — all canonical files pass and all known-invalid fixtures fail.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/yaml-to-json.py b/scripts/yaml-to-json.py new file mode 100755 index 00000000..75bcf01a --- /dev/null +++ b/scripts/yaml-to-json.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""yaml-to-json: convert a YAML file to pretty-printed JSON. + +Used to publish JSON copies of the meta-schemas under schemas/v0.1.0/ +alongside their YAML sources. Consumers (schemastore.org, IDE +language servers, CI validators) typically prefer JSON; the YAML form +is the human-edited source of truth. + +Usage: + yaml-to-json.py + +Vanilla — only depends on PyYAML, which is already in the project's +existing scripts/ tooling. +""" + +import json +import sys + +import yaml + + +def main() -> int: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + return 2 + src, dst = sys.argv[1], sys.argv[2] + with open(src) as f: + doc = yaml.safe_load(f) + with open(dst, "w") as f: + json.dump(doc, f, indent=2, sort_keys=False) + f.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())