diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30b822b..6be6b58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,8 +152,13 @@ jobs: with: python-version: '3.11' + # httprunner pins pydantic <1.9, which does not build on modern Python, so + # it is installed with --no-deps and its transitive deps come from the + # spec's requirements.txt (same procedure as .gts-spec/tests/Dockerfile). - name: Install pytest and dependencies - run: pip install pytest requests httprunner + run: | + pip install -r .gts-spec/tests/requirements.txt + pip install --no-deps 'httprunner>=4,<5' - name: Run e2e tests run: | diff --git a/.gts-spec b/.gts-spec index e088287..caecc27 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit e0882879577e7427f759677e9cf2eac7031d978c +Subproject commit caecc273aad0aff47d77e05b87ed4b944af85e99 diff --git a/.gts-spec-version b/.gts-spec-version new file mode 100644 index 0000000..b561134 --- /dev/null +++ b/.gts-spec-version @@ -0,0 +1 @@ +v0.13.1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..966d1e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,166 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.4.0] - 2026-08-10 + +Upgrades the implementation from GTS spec **v0.8** to **[v0.13.1](https://github.com/GlobalTypeSystem/gts-spec/releases/tag/v0.13.1)**. + +Spec 0.12 renamed the core terminology (GTS Type / GTS Type Schema / GTS Instance) and +0.13 issued a correction to the compatibility rules, so this release contains breaking +changes to both the HTTP API and the library API. + +### Breaking - HTTP API + +| Before | After | +| --------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `POST /schemas` with the schema as the body | `POST /type-schemas` with `{ "type_id", "type_schema" }` | +| `POST /validate-schema` with `{ "schema_id" }` | `POST /validate-type-schema` with `{ "type_id" }` | +| `GET /compatibility?old_schema_id=&new_schema_id=` | `GET /compatibility?old_type_id=&new_type_id=` | +| `POST /cast` with `{ "instance_id", "to_schema_id" }` | `POST /cast` with `{ "instance_id", "to_type_id" }` | +| `/extract-id` returned `schema_id`, `selected_schema_id_field`, `is_schema` | returns `type_id`, `selected_type_id_field`, `is_type_schema` | +| `/parse-id` returned `is_schema` | returns `is_type_schema`, plus a new `is_type` field | + +`GET /compatibility` now returns the tri-state verdicts required by §4.3: + +```jsonc +{ + "old": "gts.x.core.events.type.v1.0~", + "new": "gts.x.core.events.type.v1.1~", + "backward_compatibility": "compatible", // compatible | incompatible | unknown + "forward_compatibility": "incompatible", + "full_compatibility": "incompatible", +} +``` + +The previous boolean fields (`is_backward_compatible`, `is_forward_compatible`, +`is_fully_compatible`) are still present for compatibility, but `unknown` collapses to +`false` in them and they cannot express an inconclusive check. Prefer the tri-state fields. + +### Breaking - library API + +- `ExtractResult`: `schema_id` → `type_id`, `selected_schema_id_field` → `selected_type_id_field`, + `is_schema` → `is_type_schema`. +- `ParseResult`: `is_schema` → `is_type_schema`. +- `CompatibilityResult`: gains `backward_compatibility`, `forward_compatibility` and + `full_compatibility`, each a `CompatVerdict` (`'compatible' | 'incompatible' | 'unknown'`). +- `GtsStore.checkCompatibility()` was removed; use `GTS.checkCompatibility()` or + `GtsCompatibility.checkCompatibility(store, old, new)`. +- `GtsStore.validateEntityTraits()` was removed. `/validate-entity` and `/validate-type-schema` + now apply the same type-level checks, so it no longer had separate semantics. +- `CompatibilityResult.added_properties`, `removed_properties` and `changed_properties` are + now **always empty** and are deprecated. The engine decides compatibility by comparing + accepted-instance sets rather than by diffing properties, so it no longer produces a + property diff. The fields remain on the type and in the `GET /compatibility` response so + existing consumers keep parsing, but they carry no information and will be removed. +- **`GtsCast` was removed.** There were two cast implementations - one in the library, one + in the registry. Only the registry implementation resolved `allOf` / `$ref` on the target + and validated the cast result; the library one did neither. Since GTS derived types _are_ + `allOf: [{$ref: parent}, …]`, the library version silently dropped every property when + casting to a derived type. + `GTS.castInstance()`, the CLI and `POST /cast` now share the registry implementation. + The `CastResult` shape returned by the library and the CLI is unchanged; `POST /cast` + returns the registry response (`instance_id`, `to_type_id`, `casted_entity`), which is + what it returned before. +- Casting no longer refuses when the two type schemas are not fully compatible. Casting is + a separate operational contract that the spec requires to be reported separately from + schema compatibility (§4.3, §4.6.3); under 0.13 almost no real schema evolution is + _fully_ compatible, so the old gate rejected ordinary casts. A cast now succeeds only if + its **result** satisfies the target type, including that type's `x-gts-ref` constraints. +- The `direction` field reported `upgrade` / `downgrade` / `same` on `GET /compatibility` + but `up` / `down` / `none` on `POST /cast`, from two separate implementations. Both now + use `upgrade` / `downgrade` / `same` / `unknown`, and consider the MAJOR version as well + as the MINOR. +- Two shape checks on `x-gts-traits-schema` were dropped: it no longer has to declare + `type: "object"`, and it may contain a nested `x-gts-traits` member. Per ADR-0002 the + keyword is an ordinary JSON Schema subschema (object, `true` or `false`), so neither + restriction has a basis in 0.13; the placement rule deliberately does not scan inside it. +- The `mode` parameter on `GTS.checkCompatibility()` / `GtsCompatibility.checkCompatibility()` / + `GET /compatibility?mode=` / the CLI's `-m` flag no longer narrows what gets computed + (spec §9.2, §4.3 require always computing all three verdicts). It is retained only for + call-site and display compatibility; the result always contains + `backward_compatibility`, `forward_compatibility` and `full_compatibility`. +- `GtsStore.register()` now throws synchronously when a schema's `x-gts-final` / + `x-gts-abstract` declaration is malformed (§9.11.1: a non-boolean value, or both keywords + declared `true` on the same schema) instead of registering it uninspected. This changes + the CLI's directory-load path: `loadEntitiesFromDir` (used by `gts load` and every command + that loads a directory of entities) already caught the per-entity `register()` call and + only reports the failure via `console.warn` when `--verbose` is passed - the same pattern + it uses for an unreadable file or an unparsable JSON document in that function. Without + `--verbose`, a directory containing a malformed schema now loads with **fewer entities + registered than files present, and no error**; pass `--verbose` to see which entities were + skipped and why. + +### Changed - compatibility semantics (spec 0.13 §4) + +OP#8 was rewritten around accepted-instance-set inclusion rather than a rule-based diff. +Several verdicts change for inputs that did not change: + +- **Enums.** Adding an enum value is now backward compatible and not forward compatible + (0.12 reported the opposite). +- **Open content models.** Adding an optional property to an open object is forward + compatible, not backward compatible — the old schema already accepted arbitrary values + under that name. +- **`const` fields.** Changing a `const` value is neither backward nor forward compatible. +- Content models are classified from the fully resolved effective schema (after `$ref` + resolution and `allOf` composition), not from `additionalProperties` alone. +- An inconclusive comparison reports `unknown` instead of being conflated with + `incompatible` — for example when the two schemas differ only in a keyword the checker + does not model, or when a type identifier cannot be resolved. + +### Added + +- **`x-gts-final` / `x-gts-abstract` (§9.11).** A final type cannot be extended; an abstract + type cannot be directly instantiated. Enforced at registration (`?validate=true`) and + always on `/validate-type-schema`, `/validate-instance` and `/validate-entity`. Non-boolean + values and the `final + abstract` combination are rejected outright. +- **Document-level keyword placement (§9.7.1, §9.11.5).** `x-gts-final`, `x-gts-abstract`, + `x-gts-traits-schema` and `x-gts-traits` must appear at the schema top level; an occurrence + nested in any subschema is rejected rather than silently ignored. +- **`GtsModifiers`** and `DOCUMENT_LEVEL_KEYWORDS` are exported from the package root. +- Unit tests covering the compatibility rules table (§4.5), the trait merge and completeness + rules, the modifier and placement rules, and wildcard matching. + +### Changed - traits (§9.7.5, ADR-0002/0003/0004) + +- Trait values merge by **JSON Merge Patch (RFC 7396)**: objects merge recursively, arrays + replace wholesale, and `null` deletes a key. +- Trait-schema `default`s are materialized before the completeness check, including defaults + declared on nested object properties. +- **Completeness is keyed on `x-gts-abstract`**: non-abstract types must validate against the + effective trait schema; abstract types are exempt. +- Locking a trait value across descendants is now plain `const` in `x-gts-traits-schema`. + The bespoke immutability / default-override rules were removed. +- `x-gts-traits-schema` accepts the boolean subschema forms: `true` permits arbitrary traits, + `false` prohibits traits on the whole subtree. + +### Fixed + +- **OP#5**: an identifier that already carries a UUID tail (a combined anonymous instance) + returns that UUID instead of deriving a second one from the string. +- **OP#4**: a major-only version wildcard such as `v0.*` no longer matches every major + version — `v0` was indistinguishable from "no version given". +- **OP#4**: a bare chain-suffix wildcard (`type.v1~*`) matches the type it is anchored on, + as well as the identifiers derived from it. +- **OP#2**: a base type schema reports `type_id: null`. The JSON Schema dialect URL in + `$schema` is not a GTS Type Identifier and is no longer returned as one. +- **OP#12**: derivation is validated from the chained `$id` alone, so a derived schema that + restates its parent's fields instead of using `allOf` + `$ref` is checked too (ADR-0001). +- **OP#12**: `additionalProperties: true` or an omitted `additionalProperties` in an `allOf` + overlay is no longer reported as loosening — the base branch keeps applying under `allOf`. + A level that closes itself must still restate the base's properties. + +### Notes for implementers + +`OP#4` and `OP#10` disagree in the gts-spec 0.13 conformance suite over whether a bare +chain-suffix wildcard matches the type it is anchored on. Both verdicts are asserted, so +`matchIDPattern()` is inclusive by default and `GTS.query()` opts into strictly-derived +matching. In gts-spec 0.12 both were exclusive; 0.13 flipped only the OP#4 assertions. + +## [0.3.0] + +- Support for combined anonymous instances and OP#13 schema traits validation. +- Fastify upgrade; `oneOf` / `anyOf` validation fixes. diff --git a/Makefile b/Makefile index 38e0798..86e6f88 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ CI := 1 -.PHONY: help build dev-fmt all check fmt lint typecheck test security update-spec e2e coverage +.PHONY: help build dev-fmt all check fmt lint typecheck test security update-spec update-spec-latest e2e-deps e2e coverage + +# Virtualenv used by the gts-spec conformance suite +VENV := .venv +PYTHON := $(VENV)/bin/python + +# The gts-spec release this implementation targets. The submodule pointer is +# the authoritative pin; this file records it in human-readable form and is +# what `make update-spec` checks out. +GTS_SPEC_VERSION ?= $(strip $(shell cat .gts-spec-version 2>/dev/null)) # Default target - show help .DEFAULT_GOAL := help @@ -44,17 +53,41 @@ security: coverage: npx jest --coverage -# Update gts-spec submodule to latest +# Check out the gts-spec release pinned in .gts-spec-version update-spec: + @test -n "$(GTS_SPEC_VERSION)" || (echo "ERROR: .gts-spec-version is missing or empty"; exit 1) + git submodule update --init .gts-spec + git -C .gts-spec fetch --tags --force origin + git -C .gts-spec checkout --detach $(GTS_SPEC_VERSION) + @echo "gts-spec is at $(GTS_SPEC_VERSION) - commit the submodule pointer to record it" + +# Move gts-spec to the tip of upstream main (unpinned; for evaluating a new release) +update-spec-latest: git submodule update --init --remote .gts-spec + @echo "gts-spec is at upstream main:" + @git -C .gts-spec describe --tags + @echo "Update .gts-spec-version before committing the submodule pointer." + +# NOTE: httprunner pins pydantic <1.9, which does not build on modern Python, so +# it is installed with --no-deps and its transitive deps come from the spec's +# requirements.txt (same procedure as .gts-spec/tests/Dockerfile). +# +# Install the Python dependencies for the gts-spec conformance suite +e2e-deps: $(VENV)/.stamp +$(VENV)/.stamp: .gts-spec/tests/requirements.txt + python3 -m venv $(VENV) + $(VENV)/bin/pip install --quiet --upgrade pip + $(VENV)/bin/pip install --quiet -r .gts-spec/tests/requirements.txt + $(VENV)/bin/pip install --quiet --no-deps 'httprunner>=4,<5' + @touch $@ # Run end-to-end tests against gts-spec -e2e: build +e2e: build e2e-deps @echo "Starting server in background..." @node dist/server/index.js --port 8000 & echo $$! > .server.pid @sleep 2 @echo "Running e2e tests..." - @PYTHONDONTWRITEBYTECODE=1 pytest -p no:cacheprovider --log-file=e2e.log ./.gts-spec/tests || (kill `cat .server.pid` 2>/dev/null; rm -f .server.pid; exit 1) + @PYTHONDONTWRITEBYTECODE=1 $(VENV)/bin/pytest -p no:cacheprovider --log-file=e2e.log ./.gts-spec/tests || (kill `cat .server.pid` 2>/dev/null; rm -f .server.pid; exit 1) @echo "Stopping server..." @kill `cat .server.pid` 2>/dev/null || true @rm -f .server.pid diff --git a/README.md b/README.md index 27c46c9..b1a3021 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A complete TypeScript implementation of the Global Type System (GTS) GTS [Global Type System](https://github.com/globaltypesystem/gts-spec) is a simple, human-readable, globally unique identifier and referencing system for data type definitions (e.g., JSON Schemas) and data instances (e.g., JSON objects). This TypeScript implementation provides type-safe operations for working with GTS identifiers. +**Targets gts-spec [v0.13.1](https://github.com/GlobalTypeSystem/gts-spec/releases/tag/v0.13.1)** — recorded in [`.gts-spec-version`](.gts-spec-version) and pinned by the `.gts-spec` submodule. Run `make update-spec` to check the pinned release out. See the [CHANGELOG](CHANGELOG.md) for the breaking changes in the 0.8 → 0.13 upgrade. + ## Roadmap Featureset: @@ -15,16 +17,17 @@ Featureset: - [x] **OP#3 - ID Parsing**: Decompose identifiers into constituent parts (vendor, package, namespace, type, version, etc.) - [x] **OP#4 - ID Pattern Matching**: Match identifiers against patterns containing wildcards - [x] **OP#5 - ID to UUID Mapping**: Generate deterministic UUIDs from GTS identifiers -- [x] **OP#6 - Instance Validation**: Validate object instances against their corresponding schemas +- [x] **OP#6 - Instance Validation**: Validate object instances against their corresponding Type Schemas - [x] **OP#7 - Relationship Resolution**: Load all schemas and instances, resolve inter-dependencies, and detect broken references -- [x] **OP#8 - Compatibility Checking**: Verify that schemas with different MINOR versions are compatible +- [x] **OP#8 - Type Schema Evolution Compatibility Checking**: Compare two definitions of one type identity and report the tri-state verdict (`compatible` / `incompatible` / `unknown`) for each relation - [x] **OP#8.1 - Backward compatibility checking** - [x] **OP#8.2 - Forward compatibility checking** - [x] **OP#8.3 - Full compatibility checking** -- [x] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions +- [x] **OP#9 - Version Casting**: Transform an instance to another version of its type. Reported separately from compatibility (§4.3, §4.6.3): a cast succeeds when its result satisfies the target type, not when the two schemas are compatible - [x] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language - [x] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- [x] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#12 - Type Derivation Validation**: Validate that a derived type correctly extends its base chain +- [x] **OP#13 - Schema Traits Validation**: Validate `x-gts-traits-schema` / `x-gts-traits` across the `$id` chain Other GTS spec [Reference Implementation](https://github.com/globaltypesystem/gts-spec/blob/main/README.md#9-reference-implementation-recommendations) recommended features support: @@ -32,6 +35,7 @@ Other GTS spec [Reference Implementation](https://github.com/globaltypesystem/gt - [x] **CLI** - command-line interface for all GTS operations - [x] **Web server** - a non-production web-server with REST API for the operations processing and testing - [x] **x-gts-ref** - to support special GTS entity reference annotation in schemas +- [x] **x-gts-final / x-gts-abstract** - GTS Type Schema modifiers controlling inheritance and instantiation - [ ] **YAML support** - to support YAML files (`*.yml`, `*.yaml`) as input files - [ ] **TypeSpec support** - add [typespec.io](https://typespec.io/) files (`*.tsp`) support - [ ] **UUID for instances** - to support UUID as ID in JSON instances @@ -65,7 +69,7 @@ const content = { const extracted = extractID(content); console.log(`ID: ${extracted.id}`); -console.log(`Schema ID: ${extracted.schemaId}`); +console.log(`Type ID: ${extracted.type_id}`); // OP#3 - ID Parsing const parsed = parseGtsID('gts.vendor.pkg.ns.type.v1~'); @@ -113,11 +117,12 @@ const relationships = gts.resolveRelationships('gts.vendor.pkg.ns.type.v1.0'); console.log(`Relationships: ${relationships.relationships}`); console.log(`Broken references: ${relationships.brokenReferences}`); -// OP#8 - Check compatibility -const compatResult = gts.checkCompatibility('gts.vendor.pkg.ns.type.v1~', 'gts.vendor.pkg.ns.type.v2~', 'backward'); -if (compatResult.compatible) { - console.log('Schemas are compatible'); -} +// OP#8 - Check Type Schema evolution compatibility +// Each relation is reported as 'compatible', 'incompatible' or 'unknown' +const compatResult = gts.checkCompatibility('gts.vendor.pkg.ns.type.v1~', 'gts.vendor.pkg.ns.type.v2~'); +console.log(`backward: ${compatResult.backward_compatibility}`); +console.log(`forward: ${compatResult.forward_compatibility}`); +console.log(`full: ${compatResult.full_compatibility}`); // OP#9 - Cast instance to different version const castResult = gts.castInstance('gts.vendor.pkg.ns.type.v1.0', 'gts.vendor.pkg.ns.type.v2~'); @@ -215,7 +220,7 @@ npx gts-server --host 127.0.0.1 --port 8000 --verbose 2 - `GET /entities/:id` - Get specific entity - `POST /entities` - Add new entity - `POST /entities/bulk` - Add multiple entities -- `POST /schemas` - Add new schema +- `POST /type-schemas` - Register a GTS Type Schema under an explicit `type_id` #### GTS Operations @@ -226,12 +231,12 @@ npx gts-server --host 127.0.0.1 --port 8000 --verbose 2 - `GET /uuid?id=` - Generate UUID (OP#5) - `POST /validate-instance` - Validate instance (OP#6) - `GET /resolve-relationships?id=` - Resolve relationships (OP#7) -- `GET /compatibility?old=&new=&mode=` - Check compatibility (OP#8) +- `GET /compatibility?old_type_id=&new_type_id=` - Check Type Schema evolution compatibility (OP#8) - `POST /cast` - Cast instance (OP#9) - `GET /query?expr=&limit=` - Query entities (OP#10) - `GET /attr?path=` - Get attribute value (OP#11) -- `POST /validate-schema` - Validate schema against parent schema (OP#12) -- `POST /validate-entity` - Validate entity (schema or instance) (OP#12) +- `POST /validate-type-schema` - Validate a derived Type Schema against its base chain (OP#12) +- `POST /validate-entity` - Validate entity (type schema or instance) (OP#12/OP#13) #### Other @@ -247,17 +252,20 @@ curl http://127.0.0.1:8000/health # Validate a GTS ID curl "http://127.0.0.1:8000/validate-id?id=gts.vendor.pkg.ns.type.v1~" -# Add a schema -curl -X POST http://127.0.0.1:8000/schemas \ +# Register a GTS Type Schema +curl -X POST http://127.0.0.1:8000/type-schemas \ -H "Content-Type: application/json" \ -d '{ - "$$id": "gts.test.example.ns.person.v1~", - "type": "object", - "properties": { - "name": { "type": "string" }, - "age": { "type": "number" } - }, - "required": ["name"] + "type_id": "gts.test.example.ns.person.v1~", + "type_schema": { + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + }, + "required": ["name"] + } }' # Query entities diff --git a/package-lock.json b/package-lock.json index 212bbe2..3b8f875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@globaltypesystem/gts-ts", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", diff --git a/package.json b/package.json index a336855..881d26f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.3.0", + "version": "0.4.0", "description": "TypeScript library for working with GTS (Global Type System) identifiers and JSON/JSON Schema artifacts", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/cast.ts b/src/cast.ts deleted file mode 100644 index 3040f31..0000000 --- a/src/cast.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { CastResult } from './types'; -import { GtsStore } from './store'; -import { Gts } from './gts'; -import { GtsCompatibility } from './compatibility'; - -export class GtsCast { - static castInstance(store: GtsStore, fromId: string, toSchemaId: string): CastResult { - try { - const fromGtsId = Gts.parseGtsID(fromId); - const fromEntity = store.get(fromGtsId.id); - - if (!fromEntity) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `Instance not found: ${fromId}`, - }; - } - - if (!fromEntity.schemaId) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `No schema found for instance: ${fromId}`, - }; - } - - const toGtsId = Gts.parseGtsID(toSchemaId); - const toSchema = store.get(toGtsId.id); - - if (!toSchema) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `Target schema not found: ${toSchemaId}`, - }; - } - - if (!toSchema.isSchema) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `Target is not a schema: ${toSchemaId}`, - }; - } - - const fromSchemaEntity = store.get(fromEntity.schemaId); - if (!fromSchemaEntity) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `Source schema not found: ${fromEntity.schemaId}`, - }; - } - - const compatCheck = GtsCompatibility.checkCompatibility(store, fromEntity.schemaId, toSchemaId, 'full'); - - if (!compatCheck.is_fully_compatible) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: `Schemas are not compatible: ${compatCheck.incompatibility_reasons.join('; ')}`, - }; - } - - const castedInstance = this.performCast( - fromEntity.content, - fromSchemaEntity.content, - toSchema.content, - toSchemaId - ); - - return { - ok: true, - fromId, - toId: toSchemaId, - result: castedInstance, - }; - } catch (error) { - return { - ok: false, - fromId, - toId: toSchemaId, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - private static performCast(instance: any, fromSchema: any, toSchema: any, toSchemaId: string): any { - const result: any = { ...instance }; - - const fromSegments = Gts.parseID(fromSchema['$$id'] || fromSchema['$id']).segments; - const toSegments = Gts.parseID(toSchemaId).segments; - - if (fromSegments.length > 0 && toSegments.length > 0) { - const fromVersion = `v${fromSegments[0].verMajor}.${fromSegments[0].verMinor ?? 0}`; - const toVersion = `v${toSegments[0].verMajor}.${toSegments[0].verMinor ?? 0}`; - - if ('gtsId' in result) { - result.gtsId = result.gtsId.replace(fromVersion, toVersion); - } - } - - if ('$schema' in result || '$$schema' in result) { - result['$schema'] = toSchemaId; - if ('$$schema' in result) { - result['$$schema'] = toSchemaId; - } - } - - const toProps = toSchema.properties || {}; - const toRequired = new Set(toSchema.required || []); - - const filtered: any = {}; - for (const [key, value] of Object.entries(result)) { - if (key in toProps || key === 'gtsId' || key === '$schema' || key === '$$schema') { - filtered[key] = value; - } - } - - for (const prop of toRequired) { - if (!((prop as string) in filtered)) { - const propSchema = toProps[prop as string]; - if (propSchema) { - filtered[prop as string] = this.getDefaultValue(propSchema); - } - } - } - - // Also add properties with default values that aren't required - for (const [propName, propSchema] of Object.entries(toProps)) { - if (!(propName in filtered) && propSchema && typeof propSchema === 'object' && 'default' in propSchema) { - filtered[propName] = propSchema.default; - } - } - - return filtered; - } - - private static getDefaultValue(schema: any): any { - if ('default' in schema) { - return schema.default; - } - - const type = schema.type; - if (Array.isArray(type)) { - if (type.includes('null')) { - return null; - } - return this.getDefaultForType(type[0]); - } - - return this.getDefaultForType(type); - } - - private static getDefaultForType(type: string): any { - switch (type) { - case 'string': - return ''; - case 'number': - case 'integer': - return 0; - case 'boolean': - return false; - case 'array': - return []; - case 'object': - return {}; - default: - return null; - } - } -} diff --git a/src/cli/index.ts b/src/cli/index.ts index eaf2b07..0614ea2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,13 +3,14 @@ import { Command } from 'commander'; import * as fs from 'fs'; import * as path from 'path'; import { validateGtsID, parseGtsID, matchIDPattern, idToUUID, extractID, GTS, createJsonEntity } from '../index'; +import { PACKAGE_VERSION } from '../version'; const program = new Command(); program .name('gts') .description('GTS CLI - Global Type System command-line interface') - .version('0.1.0') + .version(PACKAGE_VERSION) .option('--path ', 'Path to JSON and schema files', process.env.GTS_PATH) .option('--config ', 'Path to GTS config JSON file', process.env.GTS_CONFIG) .option('-v, --verbose', 'Verbose output', false); @@ -104,23 +105,32 @@ program // OP#8 - Compatibility program .command('compatibility') - .description('Check schema compatibility') - .requiredOption('-o, --old ', 'Old schema ID') - .requiredOption('-n, --new ', 'New schema ID') + .description('Check Type Schema evolution compatibility') + .requiredOption('-o, --old ', 'Old GTS Type ID') + .requiredOption('-n, --new ', 'New GTS Type ID') .option('-m, --mode ', 'Compatibility mode (backward|forward|full)', 'full') .action((options, command) => { const gts = loadStore(command.parent); const result = gts.checkCompatibility(options.old, options.new, options.mode); console.log(JSON.stringify(result, null, 2)); - process.exit(result.is_fully_compatible ? 0 : 1); + + // Exit status reflects the relation the caller asked about. `unknown` is an + // inconclusive check, not a pass, so it is non-zero too. + const verdict = + options.mode === 'backward' + ? result.backward_compatibility + : options.mode === 'forward' + ? result.forward_compatibility + : result.full_compatibility; + process.exit(verdict === 'compatible' ? 0 : 1); }); // OP#9 - Cast program .command('cast') - .description('Cast instance to different schema version') + .description('Cast instance to a different type version') .requiredOption('-f, --from ', 'Source instance ID') - .requiredOption('-t, --to ', 'Target schema ID') + .requiredOption('-t, --to ', 'Target GTS Type ID') .action((options, command) => { const gts = loadStore(command.parent); const result = gts.castInstance(options.from, options.to); @@ -212,7 +222,7 @@ program openapi: '3.0.0', info: { title: 'GTS API', - version: '0.1.0', + version: PACKAGE_VERSION, description: 'Global Type System API', }, servers: [ diff --git a/src/compatibility.ts b/src/compatibility.ts index 1ce0047..bf901d5 100644 --- a/src/compatibility.ts +++ b/src/compatibility.ts @@ -1,219 +1,1038 @@ -import { CompatibilityResult } from './types'; -import { GtsStore } from './store'; +import { + CompatibilityResult, + CompatVerdict, + EntityLookup, + GTS_URI_PREFIX, + MAX_SCHEMA_DEPTH, + MAX_SCHEMA_PATHS, +} from './types'; import { Gts } from './gts'; -export class GtsCompatibility { - static checkCompatibility( - store: GtsStore, - oldId: string, - newId: string, - _mode: 'backward' | 'forward' | 'full' = 'full' - ): CompatibilityResult { - const backwardErrors: string[] = []; - const forwardErrors: string[] = []; +/** + * Type Schema Evolution Compatibility (GTS spec 0.13 §4.2 - §4.5). + * + * Compatibility is defined by accepted-instance-set inclusion (§4.3): + * + * backward: Valid(old) subset-of Valid(new) + * forward: Valid(new) subset-of Valid(old) + * full: Valid(old) == Valid(new) + * + * Both directions are therefore the same question asked twice, so the engine + * implements a single primitive - `subsumes(outer, inner)`, "does `outer` + * accept every instance `inner` accepts" - and runs it in both directions. + * Each relation is reported as the tri-state `compatible` / `incompatible` / + * `unknown`; `unknown` preserves an inconclusive check rather than conflating + * it with incompatibility. + */ - try { - const oldGtsId = Gts.parseGtsID(oldId); - const newGtsId = Gts.parseGtsID(newId); +/** + * How the engine treats each schema keyword. + * + * - `annotation` - documentation only; never changes Valid(S) (§4.3). + * - `composition` - folded into the effective schema before comparison. + * - `modeled` - compared directly by one of the `compare*` methods. + * - `unmodeled` - a real assertion the engine cannot reason about; a + * difference makes the comparison inconclusive (`unknown`). + * + * This is the single source of truth. Everything below - what gets stripped, + * which keywords mean "this level constrains objects", which axis a bound sits + * on - is derived from it, so a keyword cannot end up classified one way in one + * place and another way somewhere else. Anything absent from the table is + * treated as `unmodeled`, which fails closed rather than being ignored. + */ +type KeywordKind = 'annotation' | 'composition' | 'modeled' | 'unmodeled'; + +interface KeywordSpec { + kind: KeywordKind; + /** Set when the keyword constrains the object content model at its level. */ + object?: boolean; + /** Set when the keyword is a numeric bound, naming its axis and whether it excludes the endpoint. */ + bound?: { axis: 'minimum' | 'maximum' | 'length' | 'items'; exclusive: boolean }; + /** + * Shape a `modeled` keyword's value must have for the engine to reason about + * it. Schemas are registered without meta-validation, so a value of the wrong + * shape is possible; when one appears the comparison is inconclusive rather + * than silently treated as "no constraint". + */ + shape?: (value: unknown) => boolean; + /** + * Where subschemas live under this keyword, so that a walker knows which + * values are schemas and which are plain data. + * + * Without this a walker cannot tell `{properties: {title: {...}}}` - where + * `title` is a *property name* - from a schema position where `title` is the + * annotation keyword, and will happily delete user data. + */ + values?: 'schema' | 'schemaMap' | 'schemaList'; +} - const oldEntity = store.get(oldGtsId.id); - const newEntity = store.get(newGtsId.id); +const isObject = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v); +const isSchemaValue = (v: unknown) => typeof v === 'boolean' || isObject(v); +const isNumber = (v: unknown) => typeof v === 'number'; +const isStringOrStringArray = (v: unknown) => + typeof v === 'string' || (Array.isArray(v) && v.every((t) => typeof t === 'string')); - if (!oldEntity) { - backwardErrors.push(`Old schema not found: ${oldId}`); - return this.buildResult(oldId, newId, false, false, false, backwardErrors, forwardErrors); - } +const KEYWORDS: Record = { + // Documentation and identity + title: { kind: 'annotation' }, + description: { kind: 'annotation' }, + examples: { kind: 'annotation' }, + default: { kind: 'annotation' }, + deprecated: { kind: 'annotation' }, + readOnly: { kind: 'annotation' }, + writeOnly: { kind: 'annotation' }, + $comment: { kind: 'annotation' }, + $id: { kind: 'annotation' }, + $$id: { kind: 'annotation' }, + $schema: { kind: 'annotation' }, + $$schema: { kind: 'annotation' }, + $defs: { kind: 'annotation', values: 'schemaMap' }, + definitions: { kind: 'annotation', values: 'schemaMap' }, + // Draft-07 treats `format` as an annotation unless assertion is enabled. + format: { kind: 'annotation' }, - if (!newEntity) { - backwardErrors.push(`New schema not found: ${newId}`); - return this.buildResult(oldId, newId, false, false, false, backwardErrors, forwardErrors); - } + // Folded in by the resolver before anything is compared + allOf: { kind: 'composition', values: 'schemaList', shape: Array.isArray }, + $ref: { kind: 'composition', shape: (v) => typeof v === 'string' }, + $$ref: { kind: 'composition', shape: (v) => typeof v === 'string' }, - if (!oldEntity.isSchema) { - backwardErrors.push(`Old entity is not a schema: ${oldId}`); - return this.buildResult(oldId, newId, false, false, false, backwardErrors, forwardErrors); - } + // Compared directly + type: { kind: 'modeled', shape: isStringOrStringArray }, + enum: { kind: 'modeled', shape: Array.isArray }, + const: { kind: 'modeled' }, + items: { kind: 'modeled', values: 'schema', shape: (v) => isSchemaValue(v) || Array.isArray(v) }, + properties: { kind: 'modeled', object: true, values: 'schemaMap', shape: isObject }, + required: { kind: 'modeled', object: true, shape: (v) => Array.isArray(v) && v.every((n) => typeof n === 'string') }, + additionalProperties: { kind: 'modeled', object: true, values: 'schema', shape: isSchemaValue }, + unevaluatedProperties: { kind: 'modeled', object: true, values: 'schema', shape: isSchemaValue }, + minimum: { kind: 'modeled', bound: { axis: 'minimum', exclusive: false }, shape: isNumber }, + exclusiveMinimum: { kind: 'modeled', bound: { axis: 'minimum', exclusive: true }, shape: isNumber }, + maximum: { kind: 'modeled', bound: { axis: 'maximum', exclusive: false }, shape: isNumber }, + exclusiveMaximum: { kind: 'modeled', bound: { axis: 'maximum', exclusive: true }, shape: isNumber }, + minLength: { kind: 'modeled', bound: { axis: 'length', exclusive: false }, shape: isNumber }, + maxLength: { kind: 'modeled', bound: { axis: 'length', exclusive: false }, shape: isNumber }, + minItems: { kind: 'modeled', bound: { axis: 'items', exclusive: false }, shape: isNumber }, + maxItems: { kind: 'modeled', bound: { axis: 'items', exclusive: false }, shape: isNumber }, - if (!newEntity.isSchema) { - backwardErrors.push(`New entity is not a schema: ${newId}`); - return this.buildResult(oldId, newId, false, false, false, backwardErrors, forwardErrors); - } + // Real assertions the engine does not model + oneOf: { kind: 'unmodeled', values: 'schemaList' }, + anyOf: { kind: 'unmodeled', values: 'schemaList' }, + not: { kind: 'unmodeled', values: 'schema' }, + if: { kind: 'unmodeled', values: 'schema' }, + then: { kind: 'unmodeled', values: 'schema' }, + else: { kind: 'unmodeled', values: 'schema' }, + pattern: { kind: 'unmodeled' }, + patternProperties: { kind: 'unmodeled', object: true, values: 'schemaMap' }, + propertyNames: { kind: 'unmodeled', object: true, values: 'schema' }, + dependencies: { kind: 'unmodeled', object: true }, + dependentSchemas: { kind: 'unmodeled', object: true, values: 'schemaMap' }, + dependentRequired: { kind: 'unmodeled', object: true }, + multipleOf: { kind: 'unmodeled' }, + contains: { kind: 'unmodeled', values: 'schema' }, + additionalItems: { kind: 'unmodeled', values: 'schema' }, + uniqueItems: { kind: 'unmodeled' }, + // Enforced against instances by OP#6 (§9.6), so it is an assertion, not an + // annotation - even though it shares the `x-gts-` prefix with the type-level + // keywords that genuinely are metadata. + 'x-gts-ref': { kind: 'unmodeled' }, +}; + +/** + * Where subschemas live under each keyword, derived from `KEYWORDS` so this + * remains the single source of truth for the position-aware walk instead of + * a second, divergence-prone copy. Consumed here by `stripSubschemas()` / + * `hasMalformedKeyword()`, and by `GtsModifiers.scanSubschemas()` (see + * `modifiers.ts`), which needs the same schema/schemaMap/schemaList + * classification for its own position-aware walk but has no other reason to + * depend on the rest of this module's keyword handling. + */ +export const SCHEMA_KEYWORD_POSITIONS: Record = Object.fromEntries( + Object.entries(KEYWORDS) + .filter(([, spec]) => spec.values !== undefined) + .map(([key, spec]) => [key, spec.values as 'schema' | 'schemaMap' | 'schemaList']) +); - const oldSchema = oldEntity.content; - const newSchema = newEntity.content; +function keywordKind(key: string): KeywordKind { + const spec = KEYWORDS[key]; + if (spec) return spec.kind; + // The remaining `x-gts-*` keywords describe the type, not the instance. + if (key.startsWith('x-gts-')) return 'annotation'; + // Unrecognised keywords are assumed to constrain something. + return 'unmodeled'; +} - const isBackward = this.checkBackwardCompatibility(oldSchema, newSchema, backwardErrors); - const isForward = this.checkForwardCompatibility(oldSchema, newSchema, forwardErrors); - const isFullyCompatible = isBackward && isForward; +/** + * True when this schema carries a value the engine cannot read: a keyword of + * the wrong shape, or a subschema position holding something that is not a + * schema. Both would otherwise be dropped during resolution and read as + * "no constraint". + */ +function hasMalformedKeyword(schema: Schema, depth = 0): boolean { + if (schema === undefined) return false; + if (typeof schema === 'boolean') return false; + if (typeof schema !== 'object' || schema === null) return true; + if (depth > MAX_SCHEMA_DEPTH) return true; - return this.buildResult(oldId, newId, isFullyCompatible, isBackward, isForward, backwardErrors, forwardErrors); - } catch (error) { - backwardErrors.push(error instanceof Error ? error.message : String(error)); - return this.buildResult(oldId, newId, false, false, false, backwardErrors, forwardErrors); + return Object.entries(schema).some(([key, value]) => { + const spec = KEYWORDS[key]; + // Annotation-kind content (e.g. `$defs`) is never read for comparison, so + // its internal shape must not be able to force an inconclusive verdict. + if (spec?.kind === 'annotation') return false; + if (spec?.shape && !spec.shape(value)) return true; + + switch (spec?.values) { + case 'schema': + return Array.isArray(value) + ? value.some((v) => hasMalformedKeyword(v, depth + 1)) + : malformedSubschema(value, depth); + case 'schemaList': + return !Array.isArray(value) || value.some((v) => hasMalformedKeyword(v, depth + 1)); + case 'schemaMap': + if (typeof value !== 'object' || value === null || Array.isArray(value)) return true; + return Object.values(value).some((v) => malformedSubschema(v, depth)); + default: + return false; } + }); +} + +function malformedSubschema(value: unknown, depth: number): boolean { + if (typeof value === 'boolean') return false; + if (typeof value !== 'object' || value === null || Array.isArray(value)) return true; + return hasMalformedKeyword(value, depth + 1); +} + +/** + * True when this schema contains a local JSON-pointer `$ref`/`$$ref` + * (a string starting with `#`) anywhere reachable through a genuinely + * compared position - `properties`, `items`, `allOf`, etc, per `KEYWORDS`' + * `values` metadata. `SchemaResolver.lookupRef()` deliberately never follows + * local pointers, so a local ref buried under a compared position would + * otherwise be dropped silently during resolution and read as "no + * constraint" rather than downgrading the verdict to `unknown`. + * + * Annotation-kind positions (e.g. `$defs` itself) are not walked: their + * content is never compared, so a local ref sitting only inside `$defs` is + * irrelevant (see `hasMalformedKeyword`'s matching annotation skip above). + */ +function hasUnresolvableLocalRef(schema: Schema, depth = 0): boolean { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return false; + if (depth > MAX_SCHEMA_DEPTH) return false; + + return Object.entries(schema).some(([key, value]) => { + if ((key === '$ref' || key === '$$ref') && typeof value === 'string' && value.startsWith('#')) return true; + + const spec = KEYWORDS[key]; + if (spec?.kind === 'annotation') return false; + + switch (spec?.values) { + case 'schema': + return Array.isArray(value) + ? value.some((v) => hasUnresolvableLocalRef(v, depth + 1)) + : hasUnresolvableLocalRef(value, depth + 1); + case 'schemaList': + return Array.isArray(value) && value.some((v) => hasUnresolvableLocalRef(v, depth + 1)); + case 'schemaMap': + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.values(value).some((v) => hasUnresolvableLocalRef(v, depth + 1)) + ); + default: + return false; + } + }); +} + +/** Keywords whose presence means this level says something about object content. */ +const OBJECT_KEYWORDS = Object.keys(KEYWORDS).filter((key) => KEYWORDS[key].object); + +/** The bound keywords grouped by axis, for normalized `(value, exclusive)` comparison. */ +const BOUND_AXES: Array<{ axis: string; isLower: boolean; keywords: Array<{ key: string; exclusive: boolean }> }> = [ + { axis: 'minimum', isLower: true, keywords: [] }, + { axis: 'maximum', isLower: false, keywords: [] }, + { axis: 'minLength', isLower: true, keywords: [{ key: 'minLength', exclusive: false }] }, + { axis: 'maxLength', isLower: false, keywords: [{ key: 'maxLength', exclusive: false }] }, + { axis: 'minItems', isLower: true, keywords: [{ key: 'minItems', exclusive: false }] }, + { axis: 'maxItems', isLower: false, keywords: [{ key: 'maxItems', exclusive: false }] }, +]; +for (const [key, spec] of Object.entries(KEYWORDS)) { + if (spec.bound?.axis === 'minimum') BOUND_AXES[0].keywords.push({ key, exclusive: spec.bound.exclusive }); + if (spec.bound?.axis === 'maximum') BOUND_AXES[1].keywords.push({ key, exclusive: spec.bound.exclusive }); +} + +/** A schema whose accepted set is everything, used for undeclared properties of an open model. */ +const ANY_SCHEMA = true; + +type Schema = any; + +/** Worst-case combination: incompatible dominates unknown, which dominates compatible. */ +function worst(a: CompatVerdict, b: CompatVerdict): CompatVerdict { + if (a === 'incompatible' || b === 'incompatible') return 'incompatible'; + if (a === 'unknown' || b === 'unknown') return 'unknown'; + return 'compatible'; +} + +function deepEqual(a: any, b: any): boolean { + if (a === b) return true; + if (typeof a !== typeof b || a === null || b === null) return false; + if (typeof a !== 'object') return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length && aKeys.every((k) => k in b && deepEqual(a[k], b[k])); +} - private static buildResult( - oldId: string, - newId: string, - isFullyCompatible: boolean, - isBackward: boolean, - isForward: boolean, - backwardErrors: string[], - forwardErrors: string[] - ): CompatibilityResult { - return { - from: oldId, - to: newId, - old: oldId, - new: newId, - direction: this.inferDirection(oldId, newId), - added_properties: [], - removed_properties: [], - changed_properties: [], - is_fully_compatible: isFullyCompatible, - is_backward_compatible: isBackward, - is_forward_compatible: isForward, - incompatibility_reasons: [...backwardErrors, ...forwardErrors], - backward_errors: backwardErrors, - forward_errors: forwardErrors, - }; +/** + * Strip annotations so that documentation-only edits compare equal. + * + * The walk is position-aware: annotation keywords are only removed where a + * *schema* is expected. Inside a `properties` map the keys are user-chosen + * property names, so a property legitimately called `title` or `format` is data + * and must survive; recursing blindly deleted it and made two schemas that + * differ only in that property compare as identical. + */ +function stripAnnotations(schema: Schema): Schema { + if (typeof schema === 'boolean') return schema; + if (schema === null || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(stripAnnotations); + + const out: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (keywordKind(key) === 'annotation') continue; + out[key] = stripSubschemas(key, value); } + return out; +} - private static inferDirection(fromId: string, toId: string): string { - try { - const fromGtsId = Gts.parseGtsID(fromId); - const toGtsId = Gts.parseGtsID(toId); +/** Applies `stripAnnotations` only to the schema positions under `keyword`. */ +function stripSubschemas(keyword: string, value: any): any { + switch (KEYWORDS[keyword]?.values) { + case 'schema': + return stripAnnotations(value); + case 'schemaList': + return Array.isArray(value) ? value.map(stripAnnotations) : value; + case 'schemaMap': + if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; + return Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, stripAnnotations(sub)])); + default: + // Plain data (`const`, `enum`, `required`, `type`, ...) is left alone. + return value; + } +} - if (!fromGtsId.segments.length || !toGtsId.segments.length) { - return 'unknown'; - } +export function isEmptySchema(schema: Schema): boolean { + if (schema === true) return true; + if (typeof schema !== 'object' || schema === null) return false; + return Object.keys(stripAnnotations(schema)).length === 0; +} - const fromSeg = fromGtsId.segments[fromGtsId.segments.length - 1]; - const toSeg = toGtsId.segments[toGtsId.segments.length - 1]; +/** The JSON-Schema type name(s) a single fixed value implies, by its JS runtime shape. */ +function impliedTypesOf(value: any): string[] { + if (value === null) return ['null']; + if (Array.isArray(value)) return ['array']; + switch (typeof value) { + case 'string': + return ['string']; + case 'boolean': + return ['boolean']; + case 'number': + return Number.isInteger(value) ? ['number', 'integer'] : ['number']; + case 'object': + return ['object']; + default: + return []; + } +} - if (fromSeg.verMajor < toSeg.verMajor) return 'upgrade'; - if (fromSeg.verMajor > toSeg.verMajor) return 'downgrade'; - if ((fromSeg.verMinor || 0) < (toSeg.verMinor || 0)) return 'upgrade'; - if ((fromSeg.verMinor || 0) > (toSeg.verMinor || 0)) return 'downgrade'; +function typeSet(schema: Schema): Set | null { + if (typeof schema !== 'object' || schema === null) return null; + const type = schema.type; + if (type !== undefined) return new Set(Array.isArray(type) ? type : [type]); - return 'same'; - } catch { - return 'unknown'; + // No `type` keyword: a `const`/`enum` value set still implies a type - its + // values ARE that type - even though the schema never states it literally. + const values = fixedValues(schema); + if (values === null) return null; // genuinely unconstrained + const implied = new Set(); + for (const v of values) for (const t of impliedTypesOf(v)) implied.add(t); + return implied; +} + +/** `number` also accepts every `integer`, so widen the accepting side. */ +function widenNumeric(types: Set): Set { + const out = new Set(types); + if (out.has('number')) out.add('integer'); + return out; +} + +/** + * Intersects two `type` keywords, or returns null when they are disjoint - the + * conjunction then accepts nothing, and the caller collapses the whole schema + * to `false` rather than keeping one side and pretending it is satisfiable. + */ +function intersectTypes(a: any, b: any): any | null { + const setA = new Set(Array.isArray(a) ? a : [a]); + const setB = new Set(Array.isArray(b) ? b : [b]); + + // `integer` is a subset of `number`, so each side keeps a type the other + // admits. Widening *both* sides made `number` ∩ `number` yield + // `['number','integer']`, and the specificity rule below then collapsed it to + // `integer` - narrowing a type that neither side narrowed. + const both = [ + ...Array.from(setA).filter((t) => setB.has(t) || (t === 'integer' && setB.has('number'))), + ...Array.from(setB).filter((t) => t === 'integer' && setA.has('number')), + ]; + const unique = Array.from(new Set(both)); + + if (unique.length === 0) return null; + return unique.length === 1 ? unique[0] : unique; +} + +/** The finite value set a schema pins down via `const` / `enum`, or null if unconstrained. */ +function fixedValues(schema: Schema): any[] | null { + if (typeof schema !== 'object' || schema === null) return null; + if ('const' in schema) return [schema.const]; + if (Array.isArray(schema.enum)) return schema.enum; + return null; +} + +type ContentModel = 'open' | 'closed' | 'partial'; + +/** Whether a keyword value is a schema (not `undefined`/`true`/an effectively-empty schema). */ +function isRestrictiveSchema(value: Schema | undefined): boolean { + return value !== undefined && value !== true && !isEmptySchema(value); +} + +function contentModel(schema: Schema): ContentModel { + if (typeof schema !== 'object' || schema === null) return 'open'; + const ap = schema.additionalProperties; + const up = schema.unevaluatedProperties; + if (ap === false || up === false) return 'closed'; + // `additionalProperties: true` evaluates every property `properties` / + // `patternProperties` did not already evaluate, so `unevaluatedProperties` + // never applies to anything - the level is fully open regardless of what + // `unevaluatedProperties` says (2019-09+ `unevaluatedProperties` semantics). + if (ap === true) return 'open'; + if (isRestrictiveSchema(ap) || isRestrictiveSchema(up)) return 'partial'; + return 'open'; +} + +/** + * The schema an undeclared property must satisfy, or null when the level + * rejects undeclared properties outright. + */ +function undeclaredSchema(schema: Schema): Schema | null { + const model = contentModel(schema); + if (model === 'closed') return null; + if (model === 'open') return ANY_SCHEMA; + const ap = schema.additionalProperties; + const up = schema.unevaluatedProperties; + const apRestrictive = isRestrictiveSchema(ap); + const upRestrictive = isRestrictiveSchema(up); + if (apRestrictive && upRestrictive) return mergeSchemas(ap, up); + return apRestrictive ? ap : up; +} + +/** Conjunction of two schemas, used to flatten `allOf` and `$ref` into one effective schema. */ +function mergeSchemas(a: Schema, b: Schema): Schema { + if (a === false || b === false) return false; + const left = a === true || a === undefined ? {} : a; + const right = b === true || b === undefined ? {} : b; + if (typeof left !== 'object' || typeof right !== 'object') return left; + + const out: Record = { ...left }; + for (const [key, value] of Object.entries(right)) { + if (!(key in out)) { + out[key] = value; + continue; + } + const current = out[key]; + switch (key) { + case 'required': + out[key] = Array.from(new Set([...(current || []), ...(value as any[])])); + break; + case 'properties': { + const merged: Record = { ...current }; + for (const [prop, propSchema] of Object.entries(value as Record)) { + merged[prop] = prop in merged ? mergeSchemas(merged[prop], propSchema) : propSchema; + } + out[key] = merged; + break; + } + case 'additionalProperties': + case 'unevaluatedProperties': + if (current === false || value === false) out[key] = false; + else if (current === true || current === undefined) out[key] = value; + else if (value === true) out[key] = current; + else out[key] = mergeSchemas(current, value); + break; + case 'type': { + const intersection = intersectTypes(current, value); + // Disjoint types across `allOf` branches: the conjunction is the + // unsatisfiable schema, which accepts no instance at all. + if (intersection === null) return false; + out[key] = intersection; + break; + } + case 'enum': + // Schemas are registered without meta-validation, so a branch may carry + // a malformed keyword. Keep the left-hand value rather than throwing; + // the divergence then surfaces through the normal comparison. + if (Array.isArray(current) && Array.isArray(value)) { + out[key] = current.filter((x) => value.some((y) => deepEqual(x, y))); + } + break; + case 'items': + out[key] = mergeSchemas(current, value); + break; + case 'minimum': + case 'exclusiveMinimum': + case 'minLength': + case 'minItems': + if (typeof current === 'number' && typeof value === 'number') { + out[key] = Math.max(current, value); + } + break; + case 'maximum': + case 'exclusiveMaximum': + case 'maxLength': + case 'maxItems': + if (typeof current === 'number' && typeof value === 'number') { + out[key] = Math.min(current, value); + } + break; + default: + // Keep the left-hand value; unmodeled divergence surfaces as `unknown`. + break; } } + return out; +} - private static checkBackwardCompatibility(oldSchema: any, newSchema: any, errors: string[]): boolean { - const oldProps = oldSchema.properties || {}; - const newProps = newSchema.properties || {}; - const oldRequired = new Set(oldSchema.required || []); +/** + * Resolves a schema to its effective form at one level: `$ref` targets and + * `allOf` branches are merged in, per §4.4 ("classify the level from the + * resolved effective schema"). Nested subschemas stay unresolved and are + * resolved lazily when they are compared. + */ +class SchemaResolver { + private unresolved = false; - let compatible = true; + // Bounds the total number of `$ref` follows and `allOf` branch recursions + // this resolver may take across its whole lifetime (one top-level + // `subsumes()` call and every nested comparison made through it), on top + // of `MAX_SCHEMA_DEPTH`'s per-chain-depth bound. A diamond-shaped `allOf`/ + // `$ref` DAG (level N reaching both level N-1 and N-2, which themselves + // both reach a shared ancestor) revisits the same ref from multiple + // sibling branches; with no cross-branch cache (see the removed + // `resolvedRefCache` - caching resolved ref content by id is unsound here, + // since a diamond ancestor can legitimately be reached at different + // depths and `resolve()`'s own depth-based bailout must be evaluated + // fresh each time), each revisit re-resolves the entire subtree beneath + // it, compounding multiplicatively per level. Counted the same way as + // `resolveTraitSchemaRefs`'s budget in `store.ts` and bailing out the same + // way this class already bails out on `MAX_SCHEMA_DEPTH` - marking the + // affected branch unresolved, which `finalize()` downgrades to `unknown` + // - rather than throwing: nothing upstream of `compareSchemas()` (e.g. + // `validateTraitChainSatisfiability` in `store.ts`) currently catches an + // exception from this path, and an inconclusive verdict is this class's + // own established convention for "part of the schema could not be + // resolved" (see the depth bailout just below and this class's doc + // comment). + private pathCount = 0; - for (const propName of Object.keys(oldProps)) { - if (!(propName in newProps)) { - if (oldRequired.has(propName)) { - errors.push(`Required property '${propName}' removed in new schema`); - compatible = false; - } + constructor(private store: EntityLookup) {} + + /** True when any `$ref` encountered so far could not be resolved. */ + get hadUnresolvedRef(): boolean { + return this.unresolved; + } + + resolve(schema: Schema, depth = 0): Schema { + if (schema === false) return false; + if (schema === true || schema === undefined || schema === null) return {}; + if (typeof schema !== 'object') return {}; + // Bailing out here leaves part of the schema uninspected. Recording it as + // an unresolved reference makes `finalize()` downgrade the verdict to + // `unknown`, instead of returning {} which reads as "no constraints". + if (depth > MAX_SCHEMA_DEPTH) { + this.unresolved = true; + return {}; + } + if (this.pathCount > MAX_SCHEMA_PATHS) { + this.unresolved = true; + return {}; + } + + const { allOf, $ref, $$ref, ...rest } = schema as Record; + let effective: Schema = rest; + + const ref = $ref || $$ref; + if (typeof ref === 'string') { + this.pathCount++; + const target = this.pathCount > MAX_SCHEMA_PATHS ? null : this.lookupRef(ref); + if (target === null) { + this.unresolved = true; } else { - if (!this.checkPropertyCompatibility(propName, oldProps[propName], newProps[propName], errors, 'backward')) { - compatible = false; + effective = mergeSchemas(this.resolve(target, depth + 1), effective); + } + } + + if (Array.isArray(allOf)) { + for (const branch of allOf) { + this.pathCount++; + if (this.pathCount > MAX_SCHEMA_PATHS) { + this.unresolved = true; + break; } + effective = mergeSchemas(effective, this.resolve(branch, depth + 1)); } } - return compatible; + return effective; } - private static checkForwardCompatibility(oldSchema: any, newSchema: any, errors: string[]): boolean { - const oldProps = oldSchema.properties || {}; - const newProps = newSchema.properties || {}; - const newRequired = new Set(newSchema.required || []); + private lookupRef(ref: string): Schema | null { + // Local pointers are not followed; they are left to the unmodeled check. + if (ref.startsWith('#')) return null; - let compatible = true; + const id = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref; + if (!Gts.isValidGtsID(id)) return null; - for (const propName of Object.keys(newProps)) { - if (!(propName in oldProps)) { - if (newRequired.has(propName)) { - errors.push(`New required property '${propName}' added`); - compatible = false; - } - } else { - if (!this.checkPropertyCompatibility(propName, oldProps[propName], newProps[propName], errors, 'forward')) { - compatible = false; - } + const entity = this.store.get(id); + if (!entity || !entity.isSchema || !entity.content) return null; + return entity.content; + } +} + +type Bound = { value: number; exclusive: boolean }; +type BoundAxis = { axis: string; isLower: boolean; keywords: Array<{ key: string; exclusive: boolean }> }; + +/** The effective bound on one axis as `(value, exclusive)`, or null when unconstrained. */ +function readBound(schema: Schema, axis: BoundAxis): Bound | null { + const candidates: Bound[] = []; + for (const { key, exclusive } of axis.keywords) { + if (typeof schema?.[key] === 'number') candidates.push({ value: schema[key], exclusive }); + } + if (candidates.length === 0) return null; + + // Both forms present: the tighter one wins, matching `allOf` conjunction. + return candidates.reduce((strictest, candidate) => + isAtLeastAsStrict(candidate, strictest, axis.isLower) ? candidate : strictest + ); +} + +function isAtLeastAsStrict(candidate: Bound, reference: Bound, isLower: boolean): boolean { + if (candidate.value === reference.value) { + // At the same value, excluding the endpoint is the stricter constraint. + return candidate.exclusive || !reference.exclusive; + } + return isLower ? candidate.value > reference.value : candidate.value < reference.value; +} + +/** + * The number a fixed value contributes on a given bound axis: the value + * itself for `minimum`/`maximum`, or its `.length` for the length/items axes. + * Null when the value's type does not fit the axis (e.g. a string enum member + * measured against `minimum`), so the caller can fail closed. + */ +function measureForAxis(value: any, axis: BoundAxis): number | null { + if (axis.axis === 'minimum' || axis.axis === 'maximum') { + return typeof value === 'number' ? value : null; + } + if (axis.axis === 'minLength' || axis.axis === 'maxLength') { + return typeof value === 'string' ? value.length : null; + } + if (axis.axis === 'minItems' || axis.axis === 'maxItems') { + return Array.isArray(value) ? value.length : null; + } + return null; +} + +/** Whether a measured value satisfies a `(value, exclusive, isLower)` bound. */ +function satisfiesBound(measure: number, bound: Bound, isLower: boolean): boolean { + return isLower + ? bound.exclusive + ? measure > bound.value + : measure >= bound.value + : bound.exclusive + ? measure < bound.value + : measure <= bound.value; +} + +/** + * Describes a lower/upper bound pair that no value can satisfy once every + * subschema is composed, or null when the bounds are consistent. + * + * Shared with the OP#13 trait satisfiability check so that both use the same + * normalized `(value, exclusive)` comparison; comparing raw `minimum` against + * raw `maximum` misses `exclusiveMinimum: 10` against `maximum: 10`. + */ +export function findCrossedBound(subSchemas: Schema[]): string | null { + for (const [lowerIndex, upperIndex] of [ + [0, 1], + [2, 3], + [4, 5], + ]) { + const lowerAxis = BOUND_AXES[lowerIndex]; + const upperAxis = BOUND_AXES[upperIndex]; + let lower: Bound | null = null; + let upper: Bound | null = null; + + for (const sub of subSchemas) { + if (typeof sub !== 'object' || sub === null) continue; + const l = readBound(sub, lowerAxis); + if (l && (lower === null || isAtLeastAsStrict(l, lower, true))) lower = l; + const u = readBound(sub, upperAxis); + if (u && (upper === null || isAtLeastAsStrict(u, upper, false))) upper = u; + } + + if (lower && upper) { + const crossed = + lower.value > upper.value || (lower.value === upper.value && (lower.exclusive || upper.exclusive)); + if (crossed) { + return `${lowerAxis.axis} ${lower.exclusive ? '>' : '>='} ${lower.value} cannot hold together with ${upperAxis.axis} ${upper.exclusive ? '<' : '<='} ${upper.value}`; } } + } + return null; +} + +class SubsumptionChecker { + private resolver: SchemaResolver; + + constructor(store: EntityLookup) { + this.resolver = new SchemaResolver(store); + } - return compatible; + /** + * A reference the resolver could not follow (a local JSON pointer, or a GTS + * identifier that is not registered) means part of the schema was never + * compared. Any `compatible` reached under that condition is downgraded to + * `unknown` so the check fails closed rather than passing on the strength of + * the fragment that happened to be visible. + */ + private finalize(verdict: CompatVerdict): CompatVerdict { + return this.resolver.hadUnresolvedRef && verdict === 'compatible' ? 'unknown' : verdict; } - private static checkPropertyCompatibility( - propName: string, - oldProp: any, - newProp: any, - errors: string[], - direction: 'backward' | 'forward' - ): boolean { - const oldType = this.normalizeType(oldProp.type); - const newType = this.normalizeType(newProp.type); + /** Verdict for `Valid(inner) subset-of Valid(outer)`. */ + subsumes(outerRaw: Schema, innerRaw: Schema, depth = 0): CompatVerdict { + if (depth > MAX_SCHEMA_DEPTH) return 'unknown'; - if (oldType !== newType) { - if (!this.areTypesCompatible(oldType, newType, direction)) { - errors.push(`Property '${propName}' type incompatibly changed from ${oldType} to ${newType}`); - return false; + // Checked on the raw documents: `resolve()` drops `allOf` / `$ref`, so a + // malformed composition keyword would be invisible afterwards. + if (hasMalformedKeyword(outerRaw) || hasMalformedKeyword(innerRaw)) return 'unknown'; + + // A local `$ref`/`$$ref` in a compared position is never followed by the + // resolver (see `lookupRef`), so it must downgrade the verdict here, + // before the `deepEqual` fast-path below can return `compatible` on the + // strength of two schemas that normalize identically once `$defs` - + // where the ref's actual target content lives - is stripped away. + if (hasUnresolvableLocalRef(outerRaw) || hasUnresolvableLocalRef(innerRaw)) return 'unknown'; + + const outer = this.resolver.resolve(outerRaw, depth); + const inner = this.resolver.resolve(innerRaw, depth); + + if (inner === false) return this.finalize('compatible'); // accepts nothing, trivially included + if (outer === false) return 'incompatible'; + if (isEmptySchema(outer)) return this.finalize('compatible'); // accepts everything + + const outerNorm = stripAnnotations(outer); + const innerNorm = stripAnnotations(inner); + if (deepEqual(outerNorm, innerNorm)) return this.finalize('compatible'); + + let verdict: CompatVerdict = 'compatible'; + verdict = worst(verdict, this.compareTypes(outerNorm, innerNorm)); + verdict = worst(verdict, this.compareFixedValues(outerNorm, innerNorm)); + verdict = worst(verdict, this.compareBounds(outerNorm, innerNorm)); + verdict = worst(verdict, this.compareObjects(outerNorm, innerNorm, depth)); + verdict = worst(verdict, this.compareArrays(outerNorm, innerNorm, depth)); + verdict = worst(verdict, this.compareUnmodeled(outerNorm, innerNorm)); + + return this.finalize(verdict); + } + + private compareTypes(outer: Schema, inner: Schema): CompatVerdict { + const outerTypes = typeSet(outer); + if (outerTypes === null) return 'compatible'; // outer accepts any type + const innerTypes = typeSet(inner); + if (innerTypes === null) return 'incompatible'; // inner accepts types outer rejects + + const accepted = widenNumeric(outerTypes); + return Array.from(innerTypes).every((t) => accepted.has(t)) ? 'compatible' : 'incompatible'; + } + + private compareFixedValues(outer: Schema, inner: Schema): CompatVerdict { + const outerValues = fixedValues(outer); + if (outerValues === null) return 'compatible'; // outer does not pin values down + const innerValues = fixedValues(inner); + if (innerValues === null) return 'incompatible'; // inner admits values outside outer's set + + return innerValues.every((v) => outerValues.some((o) => deepEqual(o, v))) ? 'compatible' : 'incompatible'; + } + + private compareBounds(outer: Schema, inner: Schema): CompatVerdict { + // The inclusive and exclusive forms constrain the same axis, so they are + // normalized to (value, exclusive) before being compared. Without this, + // `minimum: 0` and `exclusiveMinimum: 0` look like unrelated keywords even + // though `x > 0` is a strict subset of `x >= 0`. + for (const axis of BOUND_AXES) { + const outerBound = readBound(outer, axis); + if (outerBound === null) continue; // outer constrains nothing on this axis + const innerBound = readBound(inner, axis); + if (innerBound === null) { + // Not bounded directly, but a pinned-down value set (`const`/`enum`) + // is itself a bound: if every value it admits already satisfies + // outer's bound on this axis, inner cannot escape it either. + const innerValues = fixedValues(inner); + if (innerValues === null || innerValues.length === 0) return 'incompatible'; + const measures = innerValues.map((v) => measureForAxis(v, axis)); + if (measures.some((m) => m === null)) return 'incompatible'; + const allSatisfy = (measures as number[]).every((m) => satisfiesBound(m, outerBound, axis.isLower)); + if (!allSatisfy) return 'incompatible'; + continue; // inner is unbounded here, but its fixed values are all within outer's bound } + + if (!isAtLeastAsStrict(innerBound, outerBound, axis.isLower)) return 'incompatible'; + } + + return 'compatible'; + } + + private compareObjects(outer: Schema, inner: Schema, depth: number): CompatVerdict { + const outerProps: Record = outer.properties || {}; + const innerProps: Record = inner.properties || {}; + // Derived from the keyword table, so a keyword that affects the content + // model - `unevaluatedProperties`, say - cannot be honoured by + // `contentModel()` while being invisible to this guard. + const constrainsObjects = OBJECT_KEYWORDS.some((key) => key in outer || key in inner); + if (!constrainsObjects) return 'compatible'; + + // Outer may not demand a property the inner schema allows to be absent. + const outerRequired: string[] = outer.required || []; + const innerRequired = new Set(inner.required || []); + for (const name of outerRequired) { + if (!innerRequired.has(name)) return 'incompatible'; + } + + let verdict: CompatVerdict = 'compatible'; + + const outerUndeclared = undeclaredSchema(outer); + const innerUndeclared = undeclaredSchema(inner); + + const names = new Set([...Object.keys(outerProps), ...Object.keys(innerProps)]); + for (const name of names) { + const innerPropSchema = name in innerProps ? innerProps[name] : innerUndeclared; + // The inner schema cannot carry this property at all - nothing to check. + if (innerPropSchema === null) continue; + + const outerPropSchema = name in outerProps ? outerProps[name] : outerUndeclared; + if (outerPropSchema === null) return 'incompatible'; + + verdict = worst(verdict, this.subsumes(outerPropSchema, innerPropSchema, depth + 1)); + if (verdict === 'incompatible') return verdict; + } + + // Property names declared by neither schema. + if (innerUndeclared !== null) { + if (outerUndeclared === null) return 'incompatible'; + verdict = worst(verdict, this.subsumes(outerUndeclared, innerUndeclared, depth + 1)); } - if (oldProp.enum && newProp.enum) { - const oldEnum = new Set(oldProp.enum); - const newEnum = new Set(newProp.enum); + return verdict; + } + + private compareArrays(outer: Schema, inner: Schema, depth: number): CompatVerdict { + if (!('items' in outer) && !('items' in inner)) return 'compatible'; + const outerItems = outer.items; + const innerItems = inner.items; + // Tuple-form `items` is not modeled. + if (Array.isArray(outerItems) || Array.isArray(innerItems)) { + return deepEqual(outerItems, innerItems) ? 'compatible' : 'unknown'; + } + return this.subsumes( + outerItems === undefined ? ANY_SCHEMA : outerItems, + innerItems === undefined ? ANY_SCHEMA : innerItems, + depth + 1 + ); + } - if (direction === 'backward') { - for (const value of oldEnum) { - if (!newEnum.has(value)) { - errors.push(`Enum value '${value}' removed from property '${propName}'`); - return false; + private compareUnmodeled(outer: Schema, inner: Schema): CompatVerdict { + // Every keyword either side declares that the engine does not compare + // directly. `keywordKind` classifies unrecognised keywords as `unmodeled`, + // so a keyword nobody has thought about yet fails closed to `unknown` + // rather than being silently ignored. + const keys = new Set([...Object.keys(outer), ...Object.keys(inner)]); + for (const key of keys) { + if (keywordKind(key) !== 'unmodeled') continue; + if (deepEqual(outer[key], inner[key])) continue; + + // `pattern` is otherwise compared by exact equality like any other + // unmodeled keyword, but a pinned-down value set (`const`/`enum`) that + // already matches outer's pattern satisfies it just as much as + // restating the pattern would - mirrors the `compareBounds` fixed-value + // carve-out above, scoped narrowly to this one keyword. + if (key === 'pattern' && typeof outer.pattern === 'string') { + const innerValues = fixedValues(inner); + if (innerValues !== null && innerValues.length > 0) { + let regex: RegExp | null = null; + try { + regex = new RegExp(outer.pattern); + } catch { + regex = null; + } + if (regex !== null && innerValues.every((v) => typeof v === 'string' && regex!.test(v))) { + continue; } } } + + return 'unknown'; } - return true; + return 'compatible'; } +} - private static normalizeType(type: any): string { - if (Array.isArray(type)) { - return type.join('|'); - } - return type || 'any'; +export class GtsCompatibility { + /** + * Compares two schema documents directly (rather than by identifier) and + * reports both evolution relations. + */ + static compareSchemas( + store: EntityLookup, + oldSchema: Schema, + newSchema: Schema + ): { backward: CompatVerdict; forward: CompatVerdict } { + return { + backward: new SubsumptionChecker(store).subsumes(newSchema, oldSchema), + forward: new SubsumptionChecker(store).subsumes(oldSchema, newSchema), + }; } - private static areTypesCompatible(oldType: string, newType: string, direction: 'backward' | 'forward'): boolean { - if (oldType === newType) return true; + static checkCompatibility( + store: EntityLookup, + oldId: string, + newId: string, + _mode: 'backward' | 'forward' | 'full' = 'full' + ): CompatibilityResult { + const normalizedOld = this.normalizeId(oldId); + const normalizedNew = this.normalizeId(newId); + + const oldEntity = store.get(normalizedOld); + const newEntity = store.get(normalizedNew); - if (direction === 'backward') { - if (newType === 'any') return true; - if (oldType === 'integer' && newType === 'number') return true; - } else { - if (oldType === 'any') return true; - if (newType === 'integer' && oldType === 'number') return true; + const missing: string[] = []; + if (!oldEntity) missing.push(`Old type schema not found: ${oldId}`); + else if (!oldEntity.isSchema) missing.push(`Old entity is not a type schema: ${oldId}`); + if (!newEntity) missing.push(`New type schema not found: ${newId}`); + else if (!newEntity.isSchema) missing.push(`New entity is not a type schema: ${newId}`); + + if (missing.length > 0) { + // The check cannot be performed, which is inconclusive rather than incompatible. + return this.buildResult(normalizedOld, normalizedNew, 'unknown', 'unknown', missing, missing); } - const oldTypes = new Set(oldType.split('|')); - const newTypes = new Set(newType.split('|')); + const oldSchema = oldEntity!.content; + const newSchema = newEntity!.content; - if (direction === 'backward') { - for (const t of oldTypes) { - if (!newTypes.has(t)) return false; - } - } else { - for (const t of newTypes) { - if (!oldTypes.has(t)) return false; - } + try { + // backward: Valid(old) subset-of Valid(new); forward: Valid(new) subset-of Valid(old). + const { backward, forward } = this.compareSchemas(store, oldSchema, newSchema); + + return this.buildResult( + normalizedOld, + normalizedNew, + backward, + forward, + backward === 'compatible' ? [] : [`Backward compatibility is ${backward}`], + forward === 'compatible' ? [] : [`Forward compatibility is ${forward}`] + ); + } catch (error) { + // Schemas are registered without meta-validation, so a malformed document + // can reach the engine. That makes the check inconclusive - it must not + // take the caller down with it. + const reason = `Compatibility check failed: ${error instanceof Error ? error.message : String(error)}`; + return this.buildResult(normalizedOld, normalizedNew, 'unknown', 'unknown', [reason], []); } + } + + private static normalizeId(id: string): string { + return id.startsWith(GTS_URI_PREFIX) ? id.substring(GTS_URI_PREFIX.length) : id; + } + + /** Full compatibility holds only when both directions hold (§4.3). */ + private static fullVerdict(backward: CompatVerdict, forward: CompatVerdict): CompatVerdict { + if (backward === 'incompatible' || forward === 'incompatible') return 'incompatible'; + if (backward === 'unknown' || forward === 'unknown') return 'unknown'; + return 'compatible'; + } + + private static buildResult( + oldId: string, + newId: string, + backward: CompatVerdict, + forward: CompatVerdict, + backwardErrors: string[], + forwardErrors: string[] + ): CompatibilityResult { + const full = this.fullVerdict(backward, forward); + + return { + old: oldId, + new: newId, + backward_compatibility: backward, + forward_compatibility: forward, + full_compatibility: full, + from: oldId, + to: newId, + direction: this.inferDirection(oldId, newId), + added_properties: [], + removed_properties: [], + changed_properties: [], + is_fully_compatible: full === 'compatible', + is_backward_compatible: backward === 'compatible', + is_forward_compatible: forward === 'compatible', + // A reason that applies to both directions is reported once. + incompatibility_reasons: Array.from(new Set([...backwardErrors, ...forwardErrors])), + backward_errors: backwardErrors, + forward_errors: forwardErrors, + }; + } + + /** + * Classifies the version step between two identifiers as + * `upgrade` / `downgrade` / `same` / `unknown`. + * + * Shared by OP#8 and OP#9 so the `direction` field means the same thing on + * `GET /compatibility` and `POST /cast`. + */ + static inferDirection(fromId: string, toId: string): string { + try { + const fromGtsId = Gts.parseGtsID(fromId); + const toGtsId = Gts.parseGtsID(toId); + + if (!fromGtsId.segments.length || !toGtsId.segments.length) { + return 'unknown'; + } + + const fromSeg = fromGtsId.segments[fromGtsId.segments.length - 1]; + const toSeg = toGtsId.segments[toGtsId.segments.length - 1]; - return true; + if (fromSeg.verMajor < toSeg.verMajor) return 'upgrade'; + if (fromSeg.verMajor > toSeg.verMajor) return 'downgrade'; + if ((fromSeg.verMinor || 0) < (toSeg.verMinor || 0)) return 'upgrade'; + if ((fromSeg.verMinor || 0) > (toSeg.verMinor || 0)) return 'downgrade'; + + return 'same'; + } catch { + return 'unknown'; + } } } diff --git a/src/extract.ts b/src/extract.ts index dea6976..7bff060 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -146,14 +146,10 @@ export class GtsExtractor { schemaId = id.substring(0, lastTilde + 1); selectedSchemaIdField = selectedEntityField; } - } else { - // Base schema (single segment type or no $id) - use $schema field value - const schemaResult = this.findFirstValidField(content, ['$schema', '$$schema']); - if (schemaResult) { - schemaId = schemaResult.value; - selectedSchemaIdField = schemaResult.field; - } } + // A base type schema has no GTS parent type, so type_id stays null. + // The JSON Schema dialect URL in $schema is not a GTS Type Identifier + // and must never be reported as one. } else { // For instances (non-schemas): // $id without $schema means the doc is an instance, NOT a schema @@ -204,10 +200,10 @@ export class GtsExtractor { return { id, - schema_id: schemaId, + type_id: schemaId, selected_entity_field: selectedEntityField, - selected_schema_id_field: selectedSchemaIdField, - is_schema: isSchema, + selected_type_id_field: selectedSchemaIdField, + is_type_schema: isSchema, }; } } diff --git a/src/gts.ts b/src/gts.ts index fa8b9b0..78ae192 100644 --- a/src/gts.ts +++ b/src/gts.ts @@ -213,6 +213,16 @@ export class Gts { } } + /** + * Whether `value` is a plain UUID (v4/v5-shaped) string - the id form + * gts-spec §3.7 permits for an "anonymous instance" (a non-schema entity + * identified by a bare UUID, with schema resolution carried by a separate + * `type` field rather than by the id's own GTS-chain shape). + */ + static isUuid(value: string): boolean { + return UUID_REGEX.test(value); + } + static validateGtsID(id: string): ValidationResult { const isWildcard = id.includes('*'); try { @@ -266,7 +276,16 @@ export class Gts { static idToUUID(id: string): UUIDResult { try { - this.parseGtsID(id); + const parsed = this.parseGtsID(id); + + // A combined anonymous instance already carries its UUID as the tail + // segment; that UUID is the instance identity, so return it as-is rather + // than deriving a second one from the string. + const lastSegment = parsed.segments[parsed.segments.length - 1]; + if (lastSegment && lastSegment.isUuidTail) { + return { id, uuid: lastSegment.segment }; + } + return { id, uuid: this.toUUID(id), @@ -280,7 +299,21 @@ export class Gts { } } - static matchIDPattern(candidate: string, pattern: string): MatchResult { + /** + * OP#4 - match a candidate identifier against a pattern. + * + * `chainSuffixMatchesSelf` controls whether a bare chain-suffix wildcard + * (`type.v1~*`) also matches the type it is anchored on, rather than only the + * identifiers derived from it. Spec §10 states the inclusive reading for + * pattern matching, while its collection examples (and OP#10) enumerate only + * the strictly-derived identifiers, so OP#10 queries pass `false`. + */ + static matchIDPattern( + candidate: string, + pattern: string, + options?: { chainSuffixMatchesSelf?: boolean } + ): MatchResult { + const chainSuffixMatchesSelf = options?.chainSuffixMatchesSelf !== false; try { // Validate and parse candidate // If candidate contains '*', validate it as a wildcard pattern first @@ -315,7 +348,7 @@ export class Gts { } // Perform matching - const match = this.wildcardMatch(candidateId, patternId); + const match = this.wildcardMatch(candidateId, patternId, chainSuffixMatchesSelf); return { match, @@ -477,14 +510,14 @@ export class Gts { return gtsId; } - private static wildcardMatch(candidate: GtsID, pattern: GtsID): boolean { + private static wildcardMatch(candidate: GtsID, pattern: GtsID, chainSuffixMatchesSelf: boolean = true): boolean { if (!candidate || !pattern) { return false; } // If no wildcard in pattern, perform exact match with version flexibility if (!pattern.id.includes('*')) { - return this.matchSegments(pattern.segments, candidate.segments); + return this.matchSegments(pattern.segments, candidate.segments, chainSuffixMatchesSelf); } // Wildcard case @@ -493,16 +526,50 @@ export class Gts { } // Use segment matching for wildcard patterns too - return this.matchSegments(pattern.segments, candidate.segments); + return this.matchSegments(pattern.segments, candidate.segments, chainSuffixMatchesSelf); + } + + /** + * Reads the major version out of a wildcard pattern segment such as + * `x.pkg.ns.type.v0.*`. The parsed segment cannot express this: an omitted + * major version and `v0` both leave `verMajor` at 0. + * + * Only the major version can appear before the wildcard. A minor-qualified + * form (`type.v1.2.*`) would be a seven-token segment, which the parser + * rejects; the way to select one minor version and its derived types is the + * chain-suffix wildcard `type.v1.2~*`. + */ + private static wildcardPatternVersion(segment: string): { majorSpecified: boolean; major: number } { + const match = /(?:^|\.)v(\d+)\.\*$/.exec(segment); + if (!match) { + return { majorSpecified: false, major: 0 }; + } + return { majorSpecified: true, major: parseInt(match[1], 10) }; } - private static matchSegments(patternSegs: GtsIDSegment[], candidateSegs: GtsIDSegment[]): boolean { + private static matchSegments( + patternSegs: GtsIDSegment[], + candidateSegs: GtsIDSegment[], + chainSuffixMatchesSelf: boolean = true + ): boolean { + // A bare chain-suffix wildcard (`type.v1~*`) matches everything derived + // from the type, and - unless the caller opts out - the type itself, so it + // may absorb zero segments. + const lastPattern = patternSegs[patternSegs.length - 1]; + const hasBareTrailingWildcard = !!lastPattern && lastPattern.isWildcard && lastPattern.segment === '*'; + const requiredSegs = hasBareTrailingWildcard ? patternSegs.length - 1 : patternSegs.length; + // If pattern is longer than candidate, no match - if (patternSegs.length > candidateSegs.length) { + if (requiredSegs > candidateSegs.length) { return false; } - for (let i = 0; i < patternSegs.length; i++) { + // Strictly-derived mode: the wildcard must absorb at least one segment. + if (hasBareTrailingWildcard && !chainSuffixMatchesSelf && candidateSegs.length <= requiredSegs) { + return false; + } + + for (let i = 0; i < requiredSegs; i++) { const pSeg = patternSegs[i]; const cSeg = candidateSegs[i]; @@ -521,11 +588,10 @@ export class Gts { if (pSeg.type && pSeg.type !== cSeg.type) { return false; } - // Check version fields if they are set in the pattern - if (pSeg.verMajor !== 0 && pSeg.verMajor !== cSeg.verMajor) { - return false; - } - if (pSeg.verMinor !== undefined && (cSeg.verMinor === undefined || pSeg.verMinor !== cSeg.verMinor)) { + // Check the version only when the pattern actually spells one out. + // A major-only wildcard matches any minor of that major. + const patternVersion = this.wildcardPatternVersion(pSeg.segment); + if (patternVersion.majorSpecified && patternVersion.major !== cSeg.verMajor) { return false; } // Check is_type flag if set diff --git a/src/index.ts b/src/index.ts index 005a891..32e693b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,15 +4,14 @@ export { GtsExtractor } from './extract'; export { GtsStore, createJsonEntity } from './store'; export { GtsRelationships } from './relationships'; export { GtsCompatibility } from './compatibility'; -export { GtsCast } from './cast'; export { GtsQuery } from './query'; +export { GtsModifiers, DOCUMENT_LEVEL_KEYWORDS } from './modifiers'; import { Gts } from './gts'; import { GtsExtractor } from './extract'; import { GtsStore, createJsonEntity } from './store'; import { GtsRelationships } from './relationships'; import { GtsCompatibility } from './compatibility'; -import { GtsCast } from './cast'; import { GtsQuery } from './query'; import { ValidationResult, @@ -26,6 +25,7 @@ import { CompatibilityResult, CastResult, GtsConfig, + EntityLookup, } from './types'; export const isValidGtsID = (id: string): boolean => Gts.isValidGtsID(id); @@ -89,8 +89,75 @@ export class GTS { return GtsCompatibility.checkCompatibility(this.store, oldId, newId, mode); } - castInstance(fromId: string, toSchemaId: string): CastResult { - return GtsCast.castInstance(this.store, fromId, toSchemaId); + /** + * OP#9 - cast an instance to another version of its type. + * + * Delegates to the registry implementation so that the library, the CLI and + * `POST /cast` all share one cast: it flattens the target through `allOf` and + * GTS `$ref`s before transforming, and validates the result against the + * target type. + */ + castInstance(fromId: string, toTypeId: string): CastResult { + const result = this.store.castInstance(fromId, toTypeId); + return { + ok: result.ok, + fromId, + toId: toTypeId, + result: result.casted_entity ?? undefined, + error: result.error || undefined, + }; + } + + /** + * The raw registry cast result (every field the store computes - added / + * removed properties, per-direction compatibility, etc.), for callers that + * need the full response shape rather than the narrower `CastResult` that + * `castInstance()` above returns. + */ + castInstanceRaw(fromId: string, toTypeId: string): any { + return this.store.castInstance(fromId, toTypeId); + } + + /** + * The document-level GTS rules for a type schema (§9.7.1, §9.11). Delegates + * to the registry implementation so that `register()`, `validateEntity()` + * and the HTTP server all share the same check instead of the server + * reaching past `GtsStore`'s encapsulation to call it directly. + */ + checkTypeSchemaRules(content: any, id: string | undefined, options: { enforceGuards: boolean }): string | null { + return this.store.checkTypeSchemaRules(content, id, options); + } + + /** + * The document-level GTS rule for an instance: its rightmost type must be + * instantiable (§9.11.3 item 1). + */ + checkInstanceRules(typeId: string | null | undefined): string | null { + return this.store.checkInstanceRules(typeId); + } + + /** Resolves a single attribute path on an entity, given as two separate arguments. */ + getAttributeAt(gtsId: string, path: string): AttributeResult { + return this.store.getAttribute(gtsId, path); + } + + /** + * A minimal, read-only view of the registry for collaborators (e.g. + * `XGtsRefValidator`) that only need to resolve an id to an entity, so they + * do not have to depend on `GtsStore` - or reach past this class's private + * field to get one - just to look entities up. + */ + asEntityLookup(): EntityLookup { + return this.store; + } + + /** + * Derivation and trait completeness are both type-level properties (§9.7.5). + * Exposed directly because `validateEntity()` below applies it only after + * first resolving `id` to an entity. + */ + validateSchemaAgainstParent(schemaId: string): ValidationResult { + return this.store.validateSchemaAgainstParent(schemaId); } validateEntity(id: string): ValidationResult & { entity_type: string } { @@ -100,14 +167,9 @@ export class GTS { } if (entity.isSchema) { + // Derivation and trait completeness are both type-level properties, so + // /validate-entity applies exactly the same checks as OP#12 (§9.7.5). const result = this.store.validateSchemaAgainstParent(id); - if (!result.ok) { - return { ...result, entity_type: 'schema' }; - } - const traitsResult = this.store.validateEntityTraits(id); - if (!traitsResult.ok) { - return { ...traitsResult, entity_type: 'schema' }; - } return { ...result, entity_type: 'schema' }; } else { const result = this.store.validateInstance(id); diff --git a/src/modifiers.ts b/src/modifiers.ts new file mode 100644 index 0000000..2e67115 --- /dev/null +++ b/src/modifiers.ts @@ -0,0 +1,159 @@ +/** + * GTS Type Schema Modifiers - `x-gts-final` / `x-gts-abstract` (spec §9.11), + * plus the shared document-level keyword placement rule (§9.7.1 / §9.11.5). + * + * - `x-gts-final: true` - the type cannot be inherited from. + * - `x-gts-abstract: true` - the type cannot be directly instantiated. + * + * Both are type-level keywords: they describe the GTS Type as a whole and are + * only meaningful at the top level of the schema document. The same is true of + * the two trait keywords, so the placement check covers all four. + */ + +import { MAX_SCHEMA_DEPTH } from './types'; +import { SCHEMA_KEYWORD_POSITIONS } from './compatibility'; + +export const X_GTS_FINAL = 'x-gts-final'; +export const X_GTS_ABSTRACT = 'x-gts-abstract'; +export const X_GTS_TRAITS = 'x-gts-traits'; +export const X_GTS_TRAITS_SCHEMA = 'x-gts-traits-schema'; + +/** + * The four keywords that describe the type as a whole and therefore MUST sit at + * the top level of the schema document (§9.7.1, §9.11.2 item 5, §9.11.3 item 6). + */ +export const DOCUMENT_LEVEL_KEYWORDS = [X_GTS_FINAL, X_GTS_ABSTRACT, X_GTS_TRAITS_SCHEMA, X_GTS_TRAITS]; + +export class GtsModifiers { + /** True when the schema declares `x-gts-final: true`; `false`/absent are no-ops. */ + static isFinal(schema: any): boolean { + return this.readModifier(schema, X_GTS_FINAL) === true; + } + + /** True when the schema declares `x-gts-abstract: true`; `false`/absent are no-ops. */ + static isAbstract(schema: any): boolean { + return this.readModifier(schema, X_GTS_ABSTRACT) === true; + } + + private static readModifier(schema: any, keyword: string): unknown { + if (!schema || typeof schema !== 'object') return undefined; + return schema[keyword]; + } + + /** + * Checks the declaration of the modifiers on a single schema document: + * non-boolean values and the meaningless `final + abstract` combination are + * both invalid (§9.11.1). Returns an error message, or null when valid. + */ + static validateDeclaration(schema: any): string | null { + if (!schema || typeof schema !== 'object') return null; + + for (const keyword of [X_GTS_FINAL, X_GTS_ABSTRACT]) { + const value = schema[keyword]; + if (value !== undefined && typeof value !== 'boolean') { + return `${keyword} must be a boolean, got ${JSON.stringify(value)}`; + } + } + + if (schema[X_GTS_FINAL] === true && schema[X_GTS_ABSTRACT] === true) { + return `a schema must not declare both ${X_GTS_FINAL} and ${X_GTS_ABSTRACT}`; + } + + return null; + } + + /** + * Finds document-level GTS keywords that were placed inside a subschema + * (an `allOf` entry, a `properties` value, a `definitions` entry, ...). + * Such a keyword attaches to a subschema rather than to the type and MUST be + * rejected rather than silently ignored. + * + * Returns the JSON paths of the misplaced keywords, empty when correct. + */ + static findMisplacedKeywords(schema: any): string[] { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return []; + + const found: string[] = []; + for (const [key, value] of Object.entries(schema)) { + // The top-level occurrences are the correct placement. Their values are + // trait data or a trait subschema, never a place for further keywords. + if (DOCUMENT_LEVEL_KEYWORDS.includes(key)) continue; + this.scanSubschemas(key, value, key, found, 0); + } + return found; + } + + /** + * Scans a *schema position* (never a data position) for misplaced keywords. + * Position-aware for the same reason `compatibility.ts`'s `stripAnnotations` + * walk is: `{ properties: { 'x-gts-abstract': {...} } }` names a property + * called `x-gts-abstract`, not an occurrence of the keyword, and must not be + * flagged. + */ + private static scan(node: any, path: string, found: string[], depth: number): void { + if (!node || typeof node !== 'object') return; + + // The guard bounds recursion on pathological input. Stopping silently would + // let a misplaced keyword below the limit through, so it fails closed: the + // unscanned subtree is itself reported and the document is rejected. + if (depth > MAX_SCHEMA_DEPTH) { + found.push(`${path} (nesting exceeds ${MAX_SCHEMA_DEPTH} levels; cannot verify keyword placement)`); + return; + } + + if (Array.isArray(node)) { + node.forEach((item, index) => this.scan(item, `${path}[${index}]`, found, depth + 1)); + return; + } + + for (const [key, value] of Object.entries(node)) { + const childPath = `${path}/${key}`; + if (DOCUMENT_LEVEL_KEYWORDS.includes(key)) { + found.push(childPath); + continue; + } + this.scanSubschemas(key, value, childPath, found, depth); + } + } + + /** Recurses into `key`'s value only through the schema-bearing positions it defines. */ + private static scanSubschemas(key: string, value: any, path: string, found: string[], depth: number): void { + // `dependencies` (draft-07) is heterogeneous per-entry: each map entry is + // either a schema (schema dependency form) or a plain array of property + // names (property dependency form). `compatibility.ts`'s KEYWORDS table + // can't express that split without regressing its malformed-shape + // detection for the array form, so it's handled locally here instead: + // only the schema-shaped entries are schema positions worth scanning. + if (key === 'dependencies') { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [name, sub] of Object.entries(value)) { + if (sub && typeof sub === 'object' && !Array.isArray(sub)) { + this.scan(sub, `${path}/${name}`, found, depth + 1); + } + } + } + return; + } + + switch (SCHEMA_KEYWORD_POSITIONS[key]) { + case 'schema': + this.scan(value, path, found, depth + 1); + break; + case 'schemaList': + if (Array.isArray(value)) + value.forEach((item, index) => this.scan(item, `${path}[${index}]`, found, depth + 1)); + break; + case 'schemaMap': + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [name, sub] of Object.entries(value)) { + this.scan(sub, `${path}/${name}`, found, depth + 1); + } + } + break; + default: + // A data or unmodeled position: never a place a document-level keyword + // can legitimately occur, and never a place to look for one either. + break; + } + } +} diff --git a/src/query.ts b/src/query.ts index ec3d5a1..28363d0 100644 --- a/src/query.ts +++ b/src/query.ts @@ -158,8 +158,10 @@ export class GtsQuery { } private static matchesIDPattern(entityID: string, basePattern: string): boolean { - // Always use the proper matchIDPattern function which handles wildcards and version matching - const matchResult = Gts.matchIDPattern(entityID, basePattern); + // Always use the proper matchIDPattern function which handles wildcards and version matching. + // A collection query with a chain-suffix wildcard returns the identifiers + // derived from the type, not the type itself (spec §10 examples). + const matchResult = Gts.matchIDPattern(entityID, basePattern, { chainSuffixMatchesSelf: false }); return matchResult.match; } diff --git a/src/server/index.ts b/src/server/index.ts index 82b2734..b06c2fa 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,13 +4,14 @@ import { ServerConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; import { createJsonEntity } from '../index'; +import { PACKAGE_VERSION } from '../version'; const program = new Command(); program .name('gts-server') .description('GTS HTTP Server') - .version('0.1.0') + .version(PACKAGE_VERSION) .option('-h, --host ', 'Host to bind to', '127.0.0.1') .option('-p, --port ', 'Port to listen on', '8000') .option('-v, --verbose ', 'Verbosity level (0=silent, 1=info, 2=debug)', '1') diff --git a/src/server/server.ts b/src/server/server.ts index 3ad636f..493ce0b 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -13,10 +13,12 @@ import { CompatibilityParams, CastBody, QueryParams, - ValidateSchemaBody, + ValidateTypeSchemaBody, + TypeSchemaRegisterBody, ValidateEntityBody, } from './types'; import * as gts from '../index'; +import { PACKAGE_VERSION } from '../version'; export class GtsServer { private fastify: FastifyInstance; @@ -40,6 +42,14 @@ export class GtsServer { this.registerRoutes(); } + /** + * The underlying Fastify instance, exposed read-only so tests can exercise + * routes via `.inject()` without opening a real network listener. + */ + public get instance(): FastifyInstance { + return this.fastify; + } + private setupMiddleware(): void { // Enable CORS manually this.fastify.addHook('onRequest', async (_request, reply) => { @@ -66,7 +76,7 @@ export class GtsServer { this.fastify.get('/entities/:id', this.handleGetEntity.bind(this)); this.fastify.post('/entities', this.handleAddEntity.bind(this)); this.fastify.post('/entities/bulk', this.handleAddEntities.bind(this)); - this.fastify.post('/schemas', this.handleAddSchema.bind(this)); + this.fastify.post('/type-schemas', this.handleAddTypeSchema.bind(this)); // OP#1 - Validate ID this.fastify.get('/validate-id', this.handleValidateID.bind(this)); @@ -101,8 +111,8 @@ export class GtsServer { // OP#11 - Attribute Access this.fastify.get('/attr', this.handleAttribute.bind(this)); - // OP#12 - Validate Schema - this.fastify.post('/validate-schema', this.handleValidateSchema.bind(this)); + // OP#12 - Validate Type Schema + this.fastify.post('/validate-type-schema', this.handleValidateTypeSchema.bind(this)); // OP#12 - Validate Entity (unified) this.fastify.post('/validate-entity', this.handleValidateEntity.bind(this)); @@ -160,7 +170,19 @@ export class GtsServer { const validate = request.query.validate === 'true' || request.query.validation === 'true'; const entity = createJsonEntity(content); - // Strict validation for schemas when validate=true + // §9.11.1 - a malformed modifier declaration is always rejected: the + // document cannot be interpreted, so there is nothing to register. + // The guards beyond that are gated on `validate` per §9.11.5. + const ruleError = entity.isSchema + ? this.store.checkTypeSchemaRules(content, entity.id, { enforceGuards: validate }) + : validate + ? this.store.checkInstanceRules(entity.schemaId) + : null; + if (ruleError) { + reply.code(422); + return { ok: false, error: ruleError }; + } + if (validate && entity.isSchema) { const validationError = this.validateSchemaStrict(content); if (validationError) { @@ -198,7 +220,7 @@ export class GtsServer { // Validate schema with x-gts-ref if it's a schema // x-gts-ref validation always returns 422 on failure (not just when validate=true) if (entity.isSchema) { - const xGtsRefValidator = new XGtsRefValidator(this.store['store']); + const xGtsRefValidator = new XGtsRefValidator(this.store.asEntityLookup()); const xGtsRefErrors = xGtsRefValidator.validateSchema(content); if (xGtsRefErrors.length > 0) { const errorMsgs = xGtsRefErrors.map((err) => `${err.fieldPath}: ${err.reason}`).join('; '); @@ -353,6 +375,17 @@ export class GtsServer { for (const content of entities) { try { const entity = createJsonEntity(content); + + // The bulk endpoint has no `validate` switch, so it applies the same + // always-on rules as POST /entities and none of the gated guards. + const declarationError = entity.isSchema + ? this.store.checkTypeSchemaRules(content, entity.id, { enforceGuards: false }) + : null; + if (declarationError) { + errors.push(declarationError); + continue; + } + if (entity.id) { this.store.register(content); registered.push(entity.id); @@ -377,8 +410,35 @@ export class GtsServer { } } - private async handleAddSchema(request: FastifyRequest<{ Body: any }>, reply: FastifyReply): Promise { - return this.handleAddEntity(request as any, reply); + // Register a GTS Type Schema under an explicit type_id + private async handleAddTypeSchema( + request: FastifyRequest<{ Body: TypeSchemaRegisterBody }>, + reply: FastifyReply + ): Promise { + const { type_id, type_schema } = request.body || ({} as TypeSchemaRegisterBody); + + if (!type_id || !type_schema || typeof type_schema !== 'object') { + reply.code(422); + return { ok: false, error: 'Missing required fields: type_id, type_schema' }; + } + + // §2.1 / §11.1 Rule C.1 - a GTS Type Identifier MUST end with `~`. + if (!gts.isValidGtsID(type_id) || !type_id.endsWith('~')) { + reply.code(422); + return { + ok: false, + error: `Invalid type_id: must be a well-formed GTS Type Identifier ending with '~', got '${type_id}'`, + }; + } + + // The explicit type_id wins over any identifier carried inside the body, so + // an embedded $id must be dropped rather than left to shadow it. + const content: Record = { ...type_schema }; + delete content['$id']; + delete content['$$id']; + content['$$id'] = type_id; + + return this.handleAddEntity({ ...request, body: content } as any, reply); } // OP#1 - Validate ID @@ -430,15 +490,22 @@ export class GtsServer { is_type: seg.isType, })) || []; - // is_schema: true if ends with ~ and not a wildcard ending with ~* - const isSchema = id.endsWith('~') && !isWildcard; + // is_type_schema: true if ends with ~ and not a wildcard ending with ~* + const isTypeSchema = id.endsWith('~') && !isWildcard; + + // is_type: whether the identifier names a GTS Type rather than an instance, + // taken from the rightmost segment (a UUID tail or a well-known instance + // segment makes it an instance). + const lastSegment = result.segments?.[result.segments.length - 1]; + const isType = lastSegment ? lastSegment.isType : isTypeSchema; return { id, ok: result.ok, segments, error: result.error || '', - is_schema: isSchema, + is_type: isType, + is_type_schema: isTypeSchema, is_wildcard: isWildcard, }; } @@ -508,28 +575,27 @@ export class GtsServer { request: FastifyRequest<{ Querystring: CompatibilityParams }>, reply: FastifyReply ): Promise { - const { old_schema_id, new_schema_id, mode = 'full' } = request.query; + const { old_type_id, new_type_id, mode = 'full' } = request.query; - if (!old_schema_id || !new_schema_id) { + if (!old_type_id || !new_type_id) { reply.code(400); - throw new Error('Missing required parameters: old_schema_id, new_schema_id'); + throw new Error('Missing required parameters: old_type_id, new_type_id'); } - // Call the store's checkCompatibility directly to get the correct response format - return this.store['store'].checkCompatibility(old_schema_id, new_schema_id, mode); + return this.store.checkCompatibility(old_type_id, new_type_id, mode); } // OP#9 - Cast private async handleCast(request: FastifyRequest<{ Body: CastBody }>, reply: FastifyReply): Promise { - const { instance_id, to_schema_id } = request.body; + const { instance_id, to_type_id } = request.body; - if (!instance_id || !to_schema_id) { + if (!instance_id || !to_type_id) { reply.code(400); - throw new Error('Missing required fields: instance_id, to_schema_id'); + throw new Error('Missing required fields: instance_id, to_type_id'); } // Call the store's castInstance directly to get the correct response format - return this.store['store'].castInstance(instance_id, to_schema_id); + return this.store.castInstanceRaw(instance_id, to_type_id); } // OP#10 - Query @@ -589,19 +655,19 @@ export class GtsServer { throw new Error('Missing required parameters: gts_with_path or (gts_id, path)'); } - return this.store['store'].getAttribute(gtsId, path); + return this.store.getAttributeAt(gtsId, path); } - // OP#12 - Validate Schema - private async handleValidateSchema( - request: FastifyRequest<{ Body: ValidateSchemaBody }>, + // OP#12 - Validate Type Schema + private async handleValidateTypeSchema( + request: FastifyRequest<{ Body: ValidateTypeSchemaBody }>, _reply: FastifyReply ): Promise { - const { schema_id } = request.body; - if (!schema_id) { - return { ok: false, error: 'Missing required field: schema_id' }; + const { type_id } = request.body; + if (!type_id) { + return { ok: false, error: 'Missing required field: type_id' }; } - return this.store['store'].validateSchemaAgainstParent(schema_id); + return this.store.validateSchemaAgainstParent(type_id); } // OP#12 - Validate Entity (unified) @@ -623,7 +689,7 @@ export class GtsServer { openapi: '3.0.0', info: { title: 'GTS Server', - version: '0.1.0', + version: PACKAGE_VERSION, description: 'GTS (Global Type System) HTTP API', }, servers: [ @@ -639,6 +705,28 @@ export class GtsServer { private getOpenAPIPaths(): any { return { + '/health': { + get: { + summary: 'Check server liveness', + operationId: 'health', + responses: { + 200: { + description: 'Server status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + timestamp: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, '/entities': { get: { summary: 'Get all entities in the registry', @@ -691,13 +779,110 @@ export class GtsServer { }, }, }, + '/entities/{id}': { + get: { + summary: 'Get a single entity by its GTS ID', + operationId: 'getEntity', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + description: 'GTS ID of the entity', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'The entity', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + content: { type: 'object' }, + }, + }, + }, + }, + }, + 404: { + description: 'Entity not found', + }, + }, + }, + }, + '/entities/bulk': { + post: { + summary: 'Add multiple entities in a single call', + operationId: 'addEntities', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'object' } }, + }, + }, + }, + responses: { + 200: { + description: 'Bulk operation result', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + registered: { type: 'array', items: { type: 'string' } }, + errors: { type: 'array', items: { type: 'string' } }, + }, + required: ['ok'], + }, + }, + }, + }, + }, + }, + }, + '/type-schemas': { + post: { + summary: 'Register a GTS Type Schema under an explicit type_id', + operationId: 'addTypeSchema', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + type_id: { type: 'string' }, + type_schema: { type: 'object' }, + }, + required: ['type_id', 'type_schema'], + }, + }, + }, + }, + responses: { + 200: { + description: 'Operation result', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/OperationResult' }, + }, + }, + }, + }, + }, + }, '/validate-id': { get: { summary: 'Validate a GTS ID', operationId: 'validateID', parameters: [ { - name: 'id', + name: 'gts_id', in: 'query', required: true, description: 'GTS ID to validate', @@ -716,6 +901,245 @@ export class GtsServer { }, }, }, + '/extract-id': { + post: { + summary: 'Extract the GTS ID implied by an entity body', + operationId: 'extractID', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + responses: { + 200: { + description: 'Extraction result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/parse-id': { + get: { + summary: 'Parse a GTS ID into its component segments', + operationId: 'parseID', + parameters: [ + { + name: 'gts_id', + in: 'query', + required: true, + description: 'GTS ID to parse', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'Parsed segments', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + ok: { type: 'boolean' }, + segments: { type: 'array', items: { type: 'object' } }, + error: { type: 'string' }, + is_type: { type: 'boolean' }, + is_type_schema: { type: 'boolean' }, + is_wildcard: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/match-id-pattern': { + get: { + summary: 'Check whether a candidate GTS ID matches a wildcard pattern', + operationId: 'matchIDPattern', + parameters: [ + { + name: 'pattern', + in: 'query', + required: true, + description: 'GTS ID pattern, possibly containing wildcards', + schema: { type: 'string' }, + }, + { + name: 'candidate', + in: 'query', + required: true, + description: 'Candidate GTS ID to test against the pattern', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'Match result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/uuid': { + get: { + summary: 'Derive the deterministic UUID for a GTS ID', + operationId: 'idToUUID', + parameters: [ + { + name: 'gts_id', + in: 'query', + required: true, + description: 'GTS ID to derive the UUID from', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'UUID result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/validate-instance': { + post: { + summary: 'Validate a registered instance against its type schema', + operationId: 'validateInstance', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { instance_id: { type: 'string' } }, + required: ['instance_id'], + }, + }, + }, + }, + responses: { + 200: { + description: 'Validation result', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ValidationResult' }, + }, + }, + }, + }, + }, + }, + '/resolve-relationships': { + get: { + summary: 'Resolve the relationships declared by an entity', + operationId: 'resolveRelationships', + parameters: [ + { + name: 'gts_id', + in: 'query', + required: true, + description: 'GTS ID of the entity', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'Resolved relationships', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/compatibility': { + get: { + summary: 'Check compatibility between two type schema versions', + operationId: 'checkCompatibility', + parameters: [ + { + name: 'old_type_id', + in: 'query', + required: true, + description: 'GTS Type ID of the earlier version', + schema: { type: 'string' }, + }, + { + name: 'new_type_id', + in: 'query', + required: true, + description: 'GTS Type ID of the later version', + schema: { type: 'string' }, + }, + { + name: 'mode', + in: 'query', + description: 'Compatibility mode', + schema: { type: 'string', default: 'full' }, + }, + ], + responses: { + 200: { + description: 'Compatibility result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/cast': { + post: { + summary: 'Cast a registered instance to another type', + operationId: 'cast', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + instance_id: { type: 'string' }, + to_type_id: { type: 'string' }, + }, + required: ['instance_id', 'to_type_id'], + }, + }, + }, + }, + responses: { + 200: { + description: 'Cast result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, '/query': { get: { summary: 'Query entities using GTS query language', @@ -747,6 +1171,116 @@ export class GtsServer { }, }, }, + '/attr': { + get: { + summary: 'Resolve an attribute path on an entity', + operationId: 'getAttribute', + parameters: [ + { + name: 'gts_with_path', + in: 'query', + description: "Combined 'gts_id@path' reference", + schema: { type: 'string' }, + }, + { + name: 'gts_id', + in: 'query', + description: 'GTS ID of the entity (used together with `path`)', + schema: { type: 'string' }, + }, + { + name: 'path', + in: 'query', + description: 'Attribute path within the entity (used together with `gts_id`)', + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'Attribute resolution result', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + '/validate-type-schema': { + post: { + summary: "Validate a registered type schema against its parent's constraints", + operationId: 'validateTypeSchema', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { type_id: { type: 'string' } }, + required: ['type_id'], + }, + }, + }, + }, + responses: { + 200: { + description: 'Validation result', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ValidationResult' }, + }, + }, + }, + }, + }, + }, + '/validate-entity': { + post: { + summary: 'Validate a registered entity (schema or instance) uniformly', + operationId: 'validateEntity', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + entity_id: { type: 'string' }, + gts_id: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: 'Validation result', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ValidationResult' }, + }, + }, + }, + }, + }, + }, + '/openapi': { + get: { + summary: 'Get the OpenAPI specification for this server', + operationId: 'getOpenAPISpec', + responses: { + 200: { + description: 'OpenAPI 3.0 document', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, }; } @@ -757,6 +1291,7 @@ export class GtsServer { type: 'object', properties: { ok: { type: 'boolean' }, + id: { type: 'string' }, error: { type: 'string' }, }, required: ['ok'], diff --git a/src/server/types.ts b/src/server/types.ts index 671a2f3..387f796 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -53,14 +53,14 @@ export interface ResolveRelationshipsParams { } export interface CompatibilityParams { - old_schema_id: string; - new_schema_id: string; + old_type_id?: string; + new_type_id?: string; mode?: 'backward' | 'forward' | 'full'; } export interface CastBody { instance_id: string; - to_schema_id: string; + to_type_id: string; } export interface QueryParams { @@ -73,8 +73,13 @@ export interface AttributeParams { path: string; } -export interface ValidateSchemaBody { - schema_id: string; +export interface ValidateTypeSchemaBody { + type_id: string; +} + +export interface TypeSchemaRegisterBody { + type_id: string; + type_schema: Record; } export interface ValidateEntityBody { diff --git a/src/store.ts b/src/store.ts index 2193554..02ee32d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,8 +1,10 @@ import Ajv from 'ajv'; -import { GtsConfig, JsonEntity, ValidationResult, CompatibilityResult, GTS_URI_PREFIX } from './types'; +import { GtsConfig, JsonEntity, ValidationResult, GTS_URI_PREFIX, MAX_SCHEMA_DEPTH, MAX_SCHEMA_PATHS } from './types'; import { Gts } from './gts'; import { GtsExtractor } from './extract'; import { XGtsRefValidator } from './x-gts-ref'; +import { GtsCompatibility, findCrossedBound, isEmptySchema } from './compatibility'; +import { GtsModifiers } from './modifiers'; interface ResolvedSchema { properties: Record; @@ -11,6 +13,15 @@ interface ResolvedSchema { type?: string; } +/** + * Keywords that describe an object level's structure rather than the value + * constraints of a single property - gts-rust's `STRUCTURAL_KEYWORDS` + * (`schema_derivation.rs`), used by `declaredTraitSchema`/`absorbProperty` to + * decide which keywords a restated property replaces wholesale versus which + * ones compose across `allOf` branches. + */ +const TRAIT_STRUCTURAL_KEYWORDS = ['properties', 'required', 'additionalProperties']; + export class GtsStore { private byId: Map = new Map(); private config: GtsConfig; @@ -45,6 +56,21 @@ export class GtsStore { } register(entity: JsonEntity): void { + // A malformed entity id would silently break every ancestor-chain + // computation downstream (`buildSchemaChain` and friends), which then + // fail open by treating the entity as if it had no ancestors at all - + // so, like the modifier-declaration check below, this MUST be rejected + // unconditionally at registration time, regardless of `validateRefs` or + // any other config. A SCHEMA must always carry a well-formed GTS Type + // ID. A non-schema INSTANCE may instead be an "anonymous instance" + // (gts-spec §3.7): identified by a plain UUID, with schema resolution + // carried by its own `type` field rather than by the id's GTS-chain + // shape - so a plain UUID id is accepted for instances only. + const hasValidId = Gts.isValidGtsID(entity.id) || (!entity.isSchema && Gts.isUuid(entity.id)); + if (!hasValidId) { + throw new Error(`Invalid GTS entity id: '${entity.id}'`); + } + if (this.config.validateRefs) { for (const ref of entity.references) { if (!this.byId.has(ref)) { @@ -52,6 +78,20 @@ export class GtsStore { } } } + + // A malformed modifier declaration (mutually-exclusive x-gts-final + + // x-gts-abstract, or a non-boolean value) "MUST be rejected during schema + // registration" (§9.11.1) unconditionally - unlike the placement/guard + // checks in `checkTypeSchemaRules`'s `enforceGuards` branch, this part + // does not depend on `validateEntity`/HTTP-only enforcement, so it has to + // run here to cover every entry point (library, CLI, HTTP). + if (entity.isSchema && entity.content) { + const declarationError = this.checkTypeSchemaRules(entity.content, entity.id, { enforceGuards: false }); + if (declarationError) { + throw new Error(declarationError); + } + } + this.byId.set(entity.id, entity); // If this is a schema, add it to AJV for reference resolution @@ -139,6 +179,16 @@ export class GtsStore { }; } + // §9.11.3 item 2 - the rightmost type in the chain must be instantiable + if (GtsModifiers.isAbstract(schemaEntity.content)) { + return { + id: gtsId, + ok: false, + valid: false, + error: `Type '${obj.schemaId}' is abstract and cannot be directly instantiated`, + }; + } + const validate = this.ajv.compile(this.normalizeSchema(schemaEntity.content)); const isValid = validate(obj.content); @@ -377,334 +427,6 @@ export class GtsStore { return (s.startsWith('http://') || s.startsWith('https://')) && s.includes('json-schema.org'); } - checkCompatibility(oldSchemaId: string, newSchemaId: string, _mode?: string): CompatibilityResult { - const oldEntity = this.get(oldSchemaId); - const newEntity = this.get(newSchemaId); - - if (!oldEntity || !newEntity) { - return { - from: oldSchemaId, - to: newSchemaId, - old: oldSchemaId, - new: newSchemaId, - direction: 'unknown', - added_properties: [], - removed_properties: [], - changed_properties: [], - is_fully_compatible: false, - is_backward_compatible: false, - is_forward_compatible: false, - incompatibility_reasons: [], - backward_errors: ['Schema not found'], - forward_errors: ['Schema not found'], - }; - } - - const oldSchema = oldEntity.content; - const newSchema = newEntity.content; - - if (!oldSchema || !newSchema) { - return { - from: oldSchemaId, - to: newSchemaId, - old: oldSchemaId, - new: newSchemaId, - direction: 'unknown', - added_properties: [], - removed_properties: [], - changed_properties: [], - is_fully_compatible: false, - is_backward_compatible: false, - is_forward_compatible: false, - incompatibility_reasons: [], - backward_errors: ['Invalid schema content'], - forward_errors: ['Invalid schema content'], - }; - } - - // Check compatibility - const { isBackward, backwardErrors } = this.checkBackwardCompatibility(oldSchema, newSchema); - const { isForward, forwardErrors } = this.checkForwardCompatibility(oldSchema, newSchema); - - // Determine direction - const direction = this.inferDirection(oldSchemaId, newSchemaId); - - return { - from: oldSchemaId, - to: newSchemaId, - old: oldSchemaId, - new: newSchemaId, - direction, - added_properties: [], - removed_properties: [], - changed_properties: [], - is_fully_compatible: isBackward && isForward, - is_backward_compatible: isBackward, - is_forward_compatible: isForward, - incompatibility_reasons: [], - backward_errors: backwardErrors, - forward_errors: forwardErrors, - }; - } - - private inferDirection(fromId: string, toId: string): string { - try { - const fromGtsId = Gts.parseGtsID(fromId); - const toGtsId = Gts.parseGtsID(toId); - - if (!fromGtsId.segments.length || !toGtsId.segments.length) { - return 'unknown'; - } - - const fromSeg = fromGtsId.segments[fromGtsId.segments.length - 1]; - const toSeg = toGtsId.segments[toGtsId.segments.length - 1]; - - if (fromSeg.verMinor !== undefined && toSeg.verMinor !== undefined) { - if (toSeg.verMinor > fromSeg.verMinor) { - return 'up'; - } - if (toSeg.verMinor < fromSeg.verMinor) { - return 'down'; - } - return 'none'; - } - - return 'unknown'; - } catch { - return 'unknown'; - } - } - - private checkBackwardCompatibility( - oldSchema: any, - newSchema: any - ): { isBackward: boolean; backwardErrors: string[] } { - return this.checkSchemaCompatibility(oldSchema, newSchema, true); - } - - private checkForwardCompatibility(oldSchema: any, newSchema: any): { isForward: boolean; forwardErrors: string[] } { - return this.checkSchemaCompatibility(oldSchema, newSchema, false); - } - - private checkSchemaCompatibility(oldSchema: any, newSchema: any, checkBackward: boolean): any { - const errors: string[] = []; - - // Flatten schemas to handle allOf - const oldFlat = this.flattenSchema(oldSchema); - const newFlat = this.flattenSchema(newSchema); - - const oldProps = oldFlat.properties || {}; - const newProps = newFlat.properties || {}; - const oldRequired = new Set(oldFlat.required || []); - const newRequired = new Set(newFlat.required || []); - - // Check required properties changes - if (checkBackward) { - // Backward: cannot add required properties - const newlyRequired = Array.from(newRequired).filter((p) => !oldRequired.has(p)); - if (newlyRequired.length > 0) { - errors.push(`Added required properties: ${newlyRequired.join(', ')}`); - } - } else { - // Forward: cannot remove required properties - const removedRequired = Array.from(oldRequired).filter((p) => !newRequired.has(p)); - if (removedRequired.length > 0) { - errors.push(`Removed required properties: ${removedRequired.join(', ')}`); - } - } - - // Check properties that exist in both schemas - const commonProps = Object.keys(oldProps).filter((k) => k in newProps); - for (const prop of commonProps) { - const oldPropSchema = oldProps[prop] || {}; - const newPropSchema = newProps[prop] || {}; - - // Check if type changed - const oldType = oldPropSchema.type; - const newType = newPropSchema.type; - if (oldType && newType && oldType !== newType) { - errors.push(`Property '${prop}' type changed from ${oldType} to ${newType}`); - } - - // Check enum constraints - const oldEnum = oldPropSchema.enum || []; - const newEnum = newPropSchema.enum || []; - if (oldEnum.length > 0 && newEnum.length > 0) { - const oldEnumSet = new Set(oldEnum); - const newEnumSet = new Set(newEnum); - if (checkBackward) { - // Backward: cannot add enum values - const addedEnumValues = newEnum.filter((v: any) => !oldEnumSet.has(v)); - if (addedEnumValues.length > 0) { - errors.push(`Property '${prop}' added enum values: ${addedEnumValues.join(', ')}`); - } - } else { - // Forward: cannot remove enum values - const removedEnumValues = oldEnum.filter((v: any) => !newEnumSet.has(v)); - if (removedEnumValues.length > 0) { - errors.push(`Property '${prop}' removed enum values: ${removedEnumValues.join(', ')}`); - } - } - } - - // Check constraint compatibility - errors.push(...this.checkConstraintCompatibility(prop, oldPropSchema, newPropSchema, checkBackward)); - - // Recursively check nested object properties - if (oldType === 'object' && newType === 'object') { - const nestedResult = this.checkSchemaCompatibility(oldPropSchema, newPropSchema, checkBackward); - const nestedErrors = checkBackward ? nestedResult.backwardErrors : nestedResult.forwardErrors; - if (nestedErrors) { - errors.push(...nestedErrors.map((e: string) => `Property '${prop}': ${e}`)); - } - } - - // Recursively check array item schemas - if (oldType === 'array' && newType === 'array' && oldPropSchema.items && newPropSchema.items) { - const itemsResult = this.checkSchemaCompatibility(oldPropSchema.items, newPropSchema.items, checkBackward); - const itemsErrors = checkBackward ? itemsResult.backwardErrors : itemsResult.forwardErrors; - if (itemsErrors) { - errors.push(...itemsErrors.map((e: string) => `Property '${prop}' array items: ${e}`)); - } - } - } - - if (checkBackward) { - return { isBackward: errors.length === 0, backwardErrors: errors }; - } else { - return { isForward: errors.length === 0, forwardErrors: errors }; - } - } - - private checkConstraintCompatibility( - prop: string, - oldPropSchema: any, - newPropSchema: any, - checkTightening: boolean - ): string[] { - const errors: string[] = []; - const propType = oldPropSchema.type; - - // Numeric constraints - if (propType === 'number' || propType === 'integer') { - errors.push( - ...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minimum', 'maximum', checkTightening) - ); - } - - // String constraints - if (propType === 'string') { - errors.push( - ...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minLength', 'maxLength', checkTightening) - ); - } - - // Array constraints - if (propType === 'array') { - errors.push( - ...this.checkMinMaxConstraint(prop, oldPropSchema, newPropSchema, 'minItems', 'maxItems', checkTightening) - ); - } - - return errors; - } - - private checkMinMaxConstraint( - prop: string, - oldSchema: any, - newSchema: any, - minKey: string, - maxKey: string, - checkTightening: boolean - ): string[] { - const errors: string[] = []; - - const oldMin = oldSchema[minKey]; - const newMin = newSchema[minKey]; - const oldMax = oldSchema[maxKey]; - const newMax = newSchema[maxKey]; - - // Check minimum constraint - if (checkTightening) { - // Backward: cannot increase minimum (tighten) - if (oldMin !== undefined && newMin !== undefined && newMin > oldMin) { - errors.push(`Property '${prop}' ${minKey} increased from ${oldMin} to ${newMin}`); - } else if (oldMin === undefined && newMin !== undefined) { - errors.push(`Property '${prop}' added ${minKey} constraint: ${newMin}`); - } - } else { - // Forward: cannot decrease minimum (relax) - if (oldMin !== undefined && newMin !== undefined && newMin < oldMin) { - errors.push(`Property '${prop}' ${minKey} decreased from ${oldMin} to ${newMin}`); - } else if (oldMin !== undefined && newMin === undefined) { - errors.push(`Property '${prop}' removed ${minKey} constraint`); - } - } - - // Check maximum constraint - if (checkTightening) { - // Backward: cannot decrease maximum (tighten) - if (oldMax !== undefined && newMax !== undefined && newMax < oldMax) { - errors.push(`Property '${prop}' ${maxKey} decreased from ${oldMax} to ${newMax}`); - } else if (oldMax === undefined && newMax !== undefined) { - errors.push(`Property '${prop}' added ${maxKey} constraint: ${newMax}`); - } - } else { - // Forward: cannot increase maximum (relax) - if (oldMax !== undefined && newMax !== undefined && newMax > oldMax) { - errors.push(`Property '${prop}' ${maxKey} increased from ${oldMax} to ${newMax}`); - } else if (oldMax !== undefined && newMax === undefined) { - errors.push(`Property '${prop}' removed ${maxKey} constraint`); - } - } - - return errors; - } - - private flattenSchema(schema: any): any { - const result: any = { - properties: {}, - required: [], - }; - - // Merge allOf schemas - if (schema.allOf && Array.isArray(schema.allOf)) { - for (const subSchema of schema.allOf) { - const flattened = this.flattenSchema(subSchema); - - // Merge properties - Object.assign(result.properties, flattened.properties || {}); - - // Merge required - if (flattened.required && Array.isArray(flattened.required)) { - result.required.push(...flattened.required); - } - - // Preserve additionalProperties - if (flattened.additionalProperties !== undefined) { - result.additionalProperties = flattened.additionalProperties; - } - } - } - - // Add direct properties - if (schema.properties) { - Object.assign(result.properties, schema.properties); - } - - // Add direct required - if (schema.required && Array.isArray(schema.required)) { - result.required.push(...schema.required); - } - - // Top level additionalProperties overrides - if (schema.additionalProperties !== undefined) { - result.additionalProperties = schema.additionalProperties; - } - - return result; - } - castInstance(instanceId: string, toSchemaId: string): any { try { // Get instance entity @@ -712,7 +434,7 @@ export class GtsStore { if (!instanceEntity) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: `Entity not found: ${instanceId}`, }; @@ -723,7 +445,7 @@ export class GtsStore { if (!toSchema) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: `Schema not found: ${toSchemaId}`, }; @@ -736,7 +458,7 @@ export class GtsStore { // Not allowed to cast directly from a schema return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: 'Source must be an instance, not a schema', }; @@ -746,7 +468,7 @@ export class GtsStore { if (!fromSchemaId) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: `Schema not found for instance: ${instanceId}`, }; @@ -755,7 +477,7 @@ export class GtsStore { if (fromSchemaId.startsWith('http://') || fromSchemaId.startsWith('https://')) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: `Cannot cast instance with schema ${fromSchemaId}`, }; @@ -764,7 +486,7 @@ export class GtsStore { if (!fromSchema) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: `Schema not found: ${fromSchemaId}`, }; @@ -776,12 +498,31 @@ export class GtsStore { const fromSchemaContent = fromSchema.content; const toSchemaContent = toSchema.content; + // A cast that lands on an abstract type would produce an instance the + // registry could never accept directly (§9.11.3), so reject it here + // too rather than only at direct instantiation/validation time. + if (GtsModifiers.isAbstract(toSchemaContent)) { + return { + instance_id: instanceId, + to_type_id: toSchemaId, + ok: false, + error: `Cannot cast to abstract type: ${toSchemaId}`, + }; + } + // Perform the cast - return this.performCast(instanceId, toSchemaId, instanceContent, fromSchemaContent, toSchemaContent); + return this.performCast( + instanceId, + fromSchemaId, + toSchemaId, + instanceContent, + fromSchemaContent, + toSchemaContent + ); } catch (error) { return { instance_id: instanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: false, error: error instanceof Error ? error.message : String(error), }; @@ -790,26 +531,33 @@ export class GtsStore { private performCast( fromInstanceId: string, + fromSchemaId: string, toSchemaId: string, fromInstanceContent: any, fromSchemaContent: any, toSchemaContent: any ): any { - // Flatten target schema to merge allOf - const targetSchema = this.flattenSchema(toSchemaContent); + // Flatten target schema to merge allOf. `resolveSchemaFully` is the + // visited-set-protected implementation already used elsewhere in this + // file; reusing it here avoids a third divergent "flatten a schema" copy + // and its exponential blowup on diamond-shaped multi-parent hierarchies. + const targetSchema = this.resolveSchemaFully(toSchemaContent); // Determine direction - const direction = this.inferDirection(fromInstanceId, toSchemaId); + // The direction is a property of the two type schemas. Deriving it from + // the instance identifier compares the instance's own version against the + // target type and gets the answer wrong. + const direction = GtsCompatibility.inferDirection(fromSchemaId, toSchemaId); // Determine which is old/new based on direction let oldSchema: any; let newSchema: any; switch (direction) { - case 'up': + case 'upgrade': oldSchema = fromSchemaContent; newSchema = toSchemaContent; break; - case 'down': + case 'downgrade': oldSchema = toSchemaContent; newSchema = fromSchemaContent; break; @@ -819,9 +567,12 @@ export class GtsStore { break; } - // Check compatibility - const { isBackward, backwardErrors } = this.checkBackwardCompatibility(oldSchema, newSchema); - const { isForward, forwardErrors } = this.checkForwardCompatibility(oldSchema, newSchema); + // Check evolution compatibility between the two type schemas (spec §4.2) + const { backward, forward } = GtsCompatibility.compareSchemas(this, oldSchema, newSchema); + const isBackward = backward === 'compatible'; + const isForward = forward === 'compatible'; + const backwardErrors = isBackward ? [] : [`Backward compatibility is ${backward}`]; + const forwardErrors = isForward ? [] : [`Forward compatibility is ${forward}`]; // Apply casting rules to transform the instance const { casted, added, removed, incompatibilityReasons } = this.castInstanceToSchema( @@ -830,22 +581,14 @@ export class GtsStore { '' ); - // Validate the casted instance against the target schema + // The cast succeeds only if its result satisfies the target type. let isFullyCompatible = false; if (casted) { - try { - const modifiedSchema = this.removeGtsConstConstraints(toSchemaContent); - const validate = this.ajv.compile(this.normalizeSchema(modifiedSchema)); - const isValid = validate(casted); - if (!isValid) { - const errors = - validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; ') || 'Validation failed'; - incompatibilityReasons.push(errors); - } else { - isFullyCompatible = true; - } - } catch (err) { - incompatibilityReasons.push(err instanceof Error ? err.message : String(err)); + const validationError = this.validateCastResult(toSchemaContent, casted); + if (validationError) { + incompatibilityReasons.push(validationError); + } else { + isFullyCompatible = true; } } @@ -866,7 +609,7 @@ export class GtsStore { forward_errors: forwardErrors, casted_entity: casted, instance_id: fromInstanceId, - to_schema_id: toSchemaId, + to_type_id: toSchemaId, ok: isFullyCompatible, error: isFullyCompatible ? '' : incompatibilityReasons.join('; '), }; @@ -1026,6 +769,32 @@ export class GtsStore { return schema; } + /** + * Validates a cast result against the target type schema, ignoring the + * identity `const`s that a cast legitimately rewrites (§4.6.3). Returns an + * error message, or null when the result satisfies the target type. + */ + validateCastResult(toSchema: any, casted: any): string | null { + try { + const modifiedSchema = this.removeGtsConstConstraints(toSchema); + const validate = this.ajv.compile(this.normalizeSchema(modifiedSchema)); + if (!validate(casted)) { + return validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; ') || 'Validation failed'; + } + + // `x-gts-ref` is an assertion enforced on instances (§9.6), so a cast + // result has to satisfy it just as a registered instance would. + const xGtsRefErrors = new XGtsRefValidator(this).validateInstance(casted, toSchema); + if (xGtsRefErrors.length > 0) { + return `x-gts-ref validation failed: ${xGtsRefErrors.map((err) => err.reason).join('; ')}`; + } + + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + private removeGtsConstConstraints(schema: any): any { if (schema === null || schema === undefined) { return schema; @@ -1097,15 +866,28 @@ export class GtsStore { const content = entity.content; - // Find parent reference in allOf - const parentRef = this.findParentRef(content); - if (!parentRef) { + // §9.11.5 - the explicit validation endpoints always enforce the guards. + const ruleError = this.checkTypeSchemaRules(content, schemaId, { enforceGuards: true }); + if (ruleError) { + return { id: schemaId, ok: false, error: ruleError }; + } + + // Per ADR-0001 derivation is established by the chained `$id` alone, so the + // parent is taken from the chain. A body that references the parent via + // `allOf` + `$ref` and one that restates the parent's fields are both valid + // derivation forms and are checked identically. + let chain: string[]; + try { + chain = this.buildSchemaChain(schemaId); + } catch (err) { + return { id: schemaId, ok: false, error: err instanceof Error ? err.message : String(err) }; + } + const parentId = chain.length > 1 ? chain[chain.length - 2] : null; + if (!parentId) { // Base schema with no parent → still validate traits return this.validateSchemaTraits(schemaId); } - // Resolve parent entity - const parentId = parentRef.startsWith(GTS_URI_PREFIX) ? parentRef.substring(GTS_URI_PREFIX.length) : parentRef; const parentEntity = this.get(parentId); if (!parentEntity) { return { id: schemaId, ok: false, error: `Parent schema not found: ${parentId}` }; @@ -1127,7 +909,8 @@ export class GtsStore { const overlay = this.extractOverlay(content); // Compare overlay against resolved parent - const errors = this.compareOverlayToBase(overlay, resolvedParent, ''); + const inheritsViaRef = this.inheritsParentViaRef(content, parentId); + const errors = this.compareOverlayToBase(overlay, resolvedParent, '', inheritsViaRef); if (errors.length > 0) { return { id: schemaId, ok: false, error: errors.join('; ') }; } @@ -1141,85 +924,81 @@ export class GtsStore { return { id: schemaId, ok: true, error: '' }; } - // OP#13: Validate schema traits across the inheritance chain + /** + * OP#13 - trait validation across the `$id` chain (spec §9.7.5, ADR-0002/3/4). + * + * 1. effective trait-schema = `allOf` of every top-level `x-gts-traits-schema` + * along the chain, root to leaf; + * 2. effective traits object = every top-level `x-gts-traits` applied in turn + * as an RFC 7396 JSON Merge Patch, root to leaf; + * 3. materialize trait-schema `default`s for properties the merge left absent; + * 4. for non-abstract types, the materialized object must validate against the + * effective trait-schema (the "completeness check"). + * + * There is no bespoke immutability rule: a publisher locks a trait value with + * `const` in the trait-schema, which the standard validation in step 4 enforces. + */ private validateSchemaTraits(schemaId: string): ValidationResult { - // Build the chain of schema IDs from base to leaf - const chain = this.buildSchemaChain(schemaId); + let chain: string[]; + try { + chain = this.buildSchemaChain(schemaId); + } catch (err) { + return { id: schemaId, ok: false, error: err instanceof Error ? err.message : String(err) }; + } - // Collect trait schemas and trait values from each level, tracking immutability const traitSchemas: any[] = []; - const mergedTraits: Record = {}; - const lockedTraits = new Set(); - const knownDefaults = new Map(); + // `x-gts-traits-schema: false` is the "no traits permitted" declaration and + // makes the aggregate unsatisfiable; tracked separately so that a subtree + // with no traits at all still validates (ADR-0002). + let traitsProhibited = false; + // A `true` declaration constrains nothing but still establishes that the + // chain defines a trait surface, so descendants may carry trait values. + let hasTraitSchemaDeclaration = false; + let effectiveTraits: Record = {}; for (const chainSchemaId of chain) { const entity = this.get(chainSchemaId); if (!entity || !entity.content) continue; - - // Collect trait schemas from this level and track which properties this level introduces - const prevSchemaCount = traitSchemas.length; - this.collectTraitSchemas(entity.content, traitSchemas); - const levelSchemaProps = new Set(); - for (const ts of traitSchemas.slice(prevSchemaCount)) { - if (typeof ts === 'object' && ts !== null && typeof ts.properties === 'object' && ts.properties !== null) { - for (const [propName, propSchema] of Object.entries(ts.properties)) { - levelSchemaProps.add(propName); - // Detect default override: ancestor default cannot be changed by descendant - if ( - typeof propSchema === 'object' && - propSchema !== null && - 'default' in (propSchema as Record) - ) { - const newDefault = (propSchema as Record).default; - if (knownDefaults.has(propName)) { - const oldDefault = knownDefaults.get(propName); - if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) { - return { - id: schemaId, - ok: false, - error: `trait schema default for '${propName}' in '${chainSchemaId}' overrides default set by ancestor`, - }; - } - } else { - knownDefaults.set(propName, newDefault); - } - } + const content = entity.content; + + const declaredSchema = content['x-gts-traits-schema']; + if (declaredSchema !== undefined) { + hasTraitSchemaDeclaration = true; + if (declaredSchema === false) { + traitsProhibited = true; + } else if (declaredSchema !== true) { + const isPlainObject = + typeof declaredSchema === 'object' && declaredSchema !== null && !Array.isArray(declaredSchema); + if (!isPlainObject) { + return { + id: schemaId, + ok: false, + error: `x-gts-traits-schema in '${chainSchemaId}' must be an object subschema or a boolean`, + }; + } + try { + traitSchemas.push(this.resolveTraitSchemaRefs(declaredSchema, new Set())); + } catch (e) { + return { id: schemaId, ok: false, error: e instanceof Error ? e.message : String(e) }; } } } - // Collect trait values from this level - const levelTraits: Record = {}; - this.collectTraitValues(entity.content, levelTraits); - - // Check immutability: trait values set by ancestor are locked unless - // this level also introduces a trait schema covering that property - for (const [k, v] of Object.entries(levelTraits)) { - if (k in mergedTraits && JSON.stringify(mergedTraits[k]) !== JSON.stringify(v) && lockedTraits.has(k)) { + const declaredValues = content['x-gts-traits']; + if (declaredValues !== undefined) { + if (typeof declaredValues !== 'object' || declaredValues === null || Array.isArray(declaredValues)) { return { id: schemaId, ok: false, - error: `trait '${k}' in '${chainSchemaId}' overrides value set by ancestor`, + error: `x-gts-traits in '${chainSchemaId}' must be an object`, }; } + effectiveTraits = this.applyMergePatch(effectiveTraits, declaredValues); } - - // Mark trait values as locked or unlocked based on whether this level - // also introduced a trait schema covering the property - for (const k of Object.keys(levelTraits)) { - if (levelSchemaProps.has(k)) { - lockedTraits.delete(k); - } else { - lockedTraits.add(k); - } - } - - Object.assign(mergedTraits, levelTraits); } - // If no trait schemas in the chain, nothing to validate - if (traitSchemas.length === 0) { - if (Object.keys(mergedTraits).length > 0) { + if (!hasTraitSchemaDeclaration) { + if (Object.keys(effectiveTraits).length > 0) { return { id: schemaId, ok: false, @@ -1229,64 +1008,42 @@ export class GtsStore { return { id: schemaId, ok: true, error: '' }; } - // Validate each trait schema - for (let i = 0; i < traitSchemas.length; i++) { - const ts = traitSchemas[i]; + const effectiveSchema: any = traitSchemas.length === 1 ? traitSchemas[0] : { allOf: traitSchemas }; + const materialized = this.applyTraitDefaults(effectiveSchema, effectiveTraits); - // Check: trait schema must have type "object" (or no type, which defaults to object) - if (typeof ts === 'object' && ts !== null && ts.type && ts.type !== 'object') { - return { - id: schemaId, - ok: false, - error: `x-gts-traits-schema must have type "object", got "${ts.type}"`, - }; - } - - // Check: trait schema must not contain x-gts-traits - if (typeof ts === 'object' && ts !== null && ts['x-gts-traits']) { - return { - id: schemaId, - ok: false, - error: 'x-gts-traits-schema must not contain x-gts-traits', - }; - } + // The effective trait-schema must be satisfiable in the first place. This + // is a property of the composed schema, so - unlike completeness - it is + // checked for abstract types too. + const unsatisfiable = this.validateTraitChainSatisfiability(traitSchemas); + if (unsatisfiable) { + return { id: schemaId, ok: false, error: `effective trait schema cannot be satisfied: ${unsatisfiable}` }; } - // Resolve $ref inside trait schemas and check for cycles - const resolvedTraitSchemas: any[] = []; - for (const ts of traitSchemas) { - try { - const resolved = this.resolveTraitSchemaRefs(ts, new Set()); - resolvedTraitSchemas.push(resolved); - } catch (e) { - return { - id: schemaId, - ok: false, - error: e instanceof Error ? e.message : String(e), - }; - } + // `x-gts-traits-schema: false` bans traits across the whole subtree, which + // is a prohibition rather than a completeness requirement - so it applies + // to abstract members of that subtree too, and is checked before the + // abstract exemption below. + if (traitsProhibited && Object.keys(materialized).length > 0) { + return { + id: schemaId, + ok: false, + error: 'x-gts-traits-schema is false in the inheritance chain, so no traits are permitted', + }; } - // Build effective trait schema (allOf composition) - let effectiveSchema: any; - if (resolvedTraitSchemas.length === 1) { - effectiveSchema = resolvedTraitSchemas[0]; - } else { - effectiveSchema = { - type: 'object', - allOf: resolvedTraitSchemas, - }; + // Abstract types are exempt from completeness: descendants close the gaps. + const self = this.get(schemaId); + if (self && GtsModifiers.isAbstract(self.content)) { + return { id: schemaId, ok: true, error: '' }; } - // Apply defaults from trait schema to merged traits - const effectiveTraits = this.applyTraitDefaults(effectiveSchema, mergedTraits); + if (traitSchemas.length === 0) { + return { id: schemaId, ok: true, error: '' }; + } - // Validate effective traits against effective schema using AJV try { - const normalizedSchema = this.normalizeSchema(effectiveSchema); - const validate = this.ajv.compile(normalizedSchema); - const isValid = validate(effectiveTraits); - if (!isValid) { + const validate = this.ajv.compile(this.normalizeSchema(effectiveSchema)); + if (!validate(materialized)) { const errors = validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; ') || 'Trait validation failed'; return { id: schemaId, ok: false, error: `trait validation: ${errors}` }; @@ -1299,83 +1056,482 @@ export class GtsStore { }; } - // Check for unresolved trait properties (no value and no default) - const allProps = this.collectAllTraitProperties(effectiveSchema); - for (const [propName, propSchema] of Object.entries(allProps)) { - const hasValue = propName in effectiveTraits; - const hasDefault = typeof propSchema === 'object' && propSchema !== null && 'default' in propSchema; - if (!hasValue && !hasDefault) { - return { - id: schemaId, - ok: false, - error: `trait property '${propName}' is not resolved: no value provided and no default defined`, - }; - } + // `x-gts-ref` is an assertion keyword (§9.6) that plain Ajv validation + // ignores, so materialized trait values must also be checked against it + // explicitly - mirroring the same check applied to cast results above. + // Unlike that check, no store is passed here: trait values are + // schema-level example/default data documenting a type's shape, not live + // references that must already be registered, so only GTS-ID + // pattern/format validity is enforced - not registry existence. + const xGtsRefErrors = new XGtsRefValidator().validateInstance(materialized, effectiveSchema); + if (xGtsRefErrors.length > 0) { + return { + id: schemaId, + ok: false, + error: `x-gts-ref validation failed: ${xGtsRefErrors.map((err) => err.reason).join('; ')}`, + }; } return { id: schemaId, ok: true, error: '' }; } - // OP#13: Entity-level traits validation - validateEntityTraits(entityId: string): ValidationResult { - const entity = this.get(entityId); - if (!entity) { - return { id: entityId, ok: false, error: `Entity not found: ${entityId}` }; + /** + * Checks that the `allOf` composition of the chain's trait-schema branches + * leaves every declared trait property expressible (§9.7.5, "if the effective + * trait schema cannot be satisfied ... schema validation MUST fail"). + * + * Faithful port of gts-rust's `schema_traits::validate_trait_schema_compatibility`, + * which - for each level `i` of the chain - builds `ancestor` and `descendant` + * as `{allOf: [...]}` wrappers over the chain prefixes `[0..i)` / `[0..i+1)` + * and runs TWO separate checks against them: + * + * 1. `schema_derivation::validate_closed_descendant_branches` - a raw, + * structural walk (recursing into every shared property, and into the + * descendant's own `allOf`) that finds a closed `allOf` branch, at any + * depth, orphaning a property an earlier branch declared. Ported here + * as `collectClosedBranchOrphanErrors`, fed by `resolveSchemaFully` + * (this file's `flatten_schema` analog) flattening the ancestor + * prefix once per chain level, per gts-rust's own + * `validate_closed_descendant_branches` entry point. Deliberately NOT + * gated on required-ness: orphaning any property via a closed + * conjunct, required or not, always makes that conjunct reject a + * value the other allows. + * + * 2. `schema_derivation::validate_derivation` - "does the descendant's + * *declared* schema stay included in the ancestor's *declared* schema". + * Ported here via `declaredTraitSchema` (a faithful port of + * `declared_schema`/`absorb_declaration`/`absorb_property`/ + * `merge_additional_properties_constraint`) plus the existing, already + * verified `GtsCompatibility.compareSchemas` as the accepted-set- + * inclusion checker (gts-rust's `check_accepted_set_inclusion`). Also + * ports `collect_disabled_base_properties`, the one admission rule + * `validate_derivation` runs alongside inclusion that inclusion itself + * does not express (disabling an inherited property narrows the + * accepted set, so plain subsumption lets it through). + * + * Returns a description of the first problem found, or null when satisfiable. + */ + private validateTraitChainSatisfiability(traitSchemas: any[]): string | null { + // Both checks run per chain level - `ancestor = chain[0..i)`, + // `descendant = chain[0..i+1)` - matching gts-rust's own loop + // (`validate_trait_schema_compatibility`), rather than over the whole + // chain's flattened `allOf` branches at once. + for (let i = 1; i < traitSchemas.length; i++) { + // 1) Closed-branch orphan check - raw/structural, unconditional on + // required-ness (see doc comment above). `ancestorFlat` is the + // ancestor prefix flattened ONCE (gts-rust's `flatten_schema`, i.e. + // this file's `resolveSchemaFully`); the descendant prefix is passed + // RAW so the recursion can walk its own `allOf` directly. + const ancestorFlat = this.resolveSchemaFully({ allOf: traitSchemas.slice(0, i) }); + const descendantRaw = { allOf: traitSchemas.slice(0, i + 1) }; + const orphanErrors = this.collectClosedBranchOrphanErrors(ancestorFlat, descendantRaw, '', 0); + if (orphanErrors.length > 0) { + return orphanErrors.join('; '); + } + + // 2) Declared-schema-fold + accepted-set-inclusion check, per chain + // level. + const ancestorDeclared = this.declaredTraitSchema({ allOf: traitSchemas.slice(0, i) }, 0); + const descendantDeclared = this.declaredTraitSchema({ allOf: traitSchemas.slice(0, i + 1) }, 0); + + const disabledError = this.findDisabledBaseProperty(ancestorDeclared, descendantDeclared); + if (disabledError) { + return disabledError; + } + + // `forward`: Valid(descendantDeclared) ⊆ Valid(ancestorDeclared) - the + // inclusion direction §9.7.5 requires. Admission fails closed (mirroring + // gts-rust's own comment on `validate_derivation`): `unknown` is + // rejected exactly like `incompatible`, only `compatible` passes. + const { forward } = GtsCompatibility.compareSchemas(this, ancestorDeclared, descendantDeclared); + if (forward !== 'compatible') { + return `trait-schema level ${i} is not a valid narrowing of the preceding effective trait schema (${forward})`; + } } - if (!entity.isSchema) { - return { id: entityId, ok: true, error: '' }; + return null; + } + + /** + * Faithful port of gts-rust's `collect_closed_descendant_branch_errors` + * (`schema_derivation.rs`): a descendant branch that closes itself with + * `additionalProperties: false` must restate every property the flattened + * ancestor declared, or the closed branch rejects a value the ancestor + * allows once composed via `allOf` - checked at every depth, not only the + * top level, since a closed branch nested inside a shared property's own + * value constrains that same object instance just as directly. + * + * `ancestorFlat` is flattened ONCE by the caller and re-flattened here only + * for the property this call recurses into (mirroring gts-rust's own + * `flatten_schema(ancestor_prop)` at each level); `descendantRaw` is walked + * as authored so its own `allOf` branches are visited directly. + */ + private collectClosedBranchOrphanErrors( + ancestorFlat: ResolvedSchema, + descendantRaw: any, + path: string, + depth: number + ): string[] { + const errors: string[] = []; + if (depth >= MAX_SCHEMA_DEPTH) { + errors.push( + `closed-branch orphan check at '${path || ''}' exceeds ${MAX_SCHEMA_DEPTH} levels and cannot be resolved` + ); + return errors; + } + if (typeof descendantRaw !== 'object' || descendantRaw === null || Array.isArray(descendantRaw)) { + return errors; } - // Build the chain for this schema - const chain = this.buildSchemaChain(entityId); + const ancestorProps = ancestorFlat.properties || {}; + const descendantProps: Record = descendantRaw.properties || {}; - const traitSchemas: any[] = []; - let hasTraitValues = false; + if (descendantRaw.additionalProperties === false) { + const orphaned = Object.keys(ancestorProps) + .filter((name) => ancestorProps[name] !== false && !(name in descendantProps)) + .sort(); + for (const name of orphaned) { + const fullPath = path ? `${path}.${name}` : name; + errors.push( + `Property '${fullPath}' is declared in a preceding trait-schema branch but excluded by additionalProperties: false` + ); + } + } - for (const chainSchemaId of chain) { - const chainEntity = this.get(chainSchemaId); - if (!chainEntity || !chainEntity.content) continue; + const commonNames = Object.keys(descendantProps) + .filter((name) => name in ancestorProps) + .sort(); + for (const name of commonNames) { + const nextAncestorFlat = this.resolveSchemaFully(ancestorProps[name]); + const nextPath = path ? `${path}.${name}` : name; + errors.push( + ...this.collectClosedBranchOrphanErrors(nextAncestorFlat, descendantProps[name], nextPath, depth + 1) + ); + } - this.collectTraitSchemas(chainEntity.content, traitSchemas); + if (Array.isArray(descendantRaw.allOf)) { + for (const item of descendantRaw.allOf) { + errors.push(...this.collectClosedBranchOrphanErrors(ancestorFlat, item, path, depth + 1)); + } + } + + return errors; + } - const levelTraits: Record = {}; - this.collectTraitValues(chainEntity.content, levelTraits); - if (Object.keys(levelTraits).length > 0) { - hasTraitValues = true; + /** + * Faithful port of gts-rust's `collect_disabled_base_properties`: rejects a + * descendant trait-schema level that switches an ancestor-declared property + * off with `false`. Plain accepted-set inclusion permits this (rejecting + * every instance that carries the property keeps the descendant's accepted + * set inside the ancestor's), but disabling an inherited property is not a + * valid narrowing of the trait-schema chain, so this is a separate + * admission rule rather than a compatibility one. + */ + private findDisabledBaseProperty(ancestorDeclared: any, descendantDeclared: any): string | null { + const ancestorProps = + ancestorDeclared && typeof ancestorDeclared === 'object' ? ancestorDeclared.properties || {} : {}; + const descendantProps = + descendantDeclared && typeof descendantDeclared === 'object' ? descendantDeclared.properties || {} : {}; + for (const [name, property] of Object.entries(descendantProps)) { + if (property === false && ancestorProps[name] !== undefined) { + return `property '${name}': trait-schema disables a property defined by a preceding trait-schema level`; } } + return null; + } - if (traitSchemas.length === 0) { - return { id: entityId, ok: true, error: '' }; + /** + * Faithful port of gts-rust's `declared_schema`/`absorb_declaration` (see + * `schema_derivation.rs`): reduces a schema to what it *declares*, folding + * `allOf` branches in order so that a later declaration of a property's own + * (non-structural) keywords replaces earlier ones - a level that restates a + * property redeclares that property's value constraints outright, it does + * not intersect with what came before. Object structure (`properties`, + * `required`, `additionalProperties`) composes instead, via `absorbProperty` + * / `mergeAdditionalPropertiesConstraint`. + * + * Below `MAX_SCHEMA_DEPTH` the schema is returned as authored, matching + * gts-rust's own fail-closed choice: an unreduced declaration reads as + * looser than it is to the inclusion checker, so recursing further would + * only affect how conservative the rejection is, never turn a real problem + * into a false pass. + */ + private declaredTraitSchema(schema: any, depth: number = 0): any { + if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) { + return schema; + } + if (depth >= MAX_SCHEMA_DEPTH) { + return schema; } - // If trait schemas exist but no trait values, entity is incomplete - if (!hasTraitValues) { - return { - id: entityId, - ok: false, - error: 'Entity defines x-gts-traits-schema but no x-gts-traits values are provided', - }; + const declared: Record = {}; + const additionalProperties: { value?: any } = {}; + + if (Array.isArray(schema.allOf)) { + for (const branch of schema.allOf) { + const declaredBranch = this.declaredTraitSchema(branch, depth + 1); + if (typeof declaredBranch === 'object' && declaredBranch !== null && !Array.isArray(declaredBranch)) { + this.absorbDeclaration(declared, additionalProperties, declaredBranch, depth); + } + } } + this.absorbDeclaration(declared, additionalProperties, schema, depth); - // Each trait schema must have additionalProperties: false (closed) - for (const ts of traitSchemas) { - if (typeof ts === 'object' && ts !== null) { - if (ts.additionalProperties !== false) { - return { - id: entityId, - ok: false, - error: 'Trait schema must set additionalProperties: false for entity validation', - }; + if ('value' in additionalProperties) { + declared.additionalProperties = additionalProperties.value; + } + + return declared; + } + + /** Folds one declaration level into the accumulated one (see `declaredTraitSchema`). */ + private absorbDeclaration( + declared: Record, + additionalProperties: { value?: any }, + source: Record, + depth: number + ): void { + for (const [keyword, value] of Object.entries(source)) { + switch (keyword) { + case 'allOf': + break; + case 'additionalProperties': + this.mergeAdditionalPropertiesConstraint(additionalProperties, value); + break; + case 'properties': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) break; + const target: Record = (declared.properties = declared.properties || {}); + for (const [name, property] of Object.entries(value)) { + const resolvedProperty = this.declaredTraitSchema(property, depth + 1); + if (target[name] !== undefined) { + this.absorbProperty(target, name, resolvedProperty, depth + 1); + } else { + target[name] = resolvedProperty; + } + } + break; } + case 'required': { + if (!Array.isArray(value)) break; + const target: string[] = (declared.required = declared.required || []); + for (const name of value) { + if (!target.includes(name)) target.push(name); + } + break; + } + default: + // Later declaration of the same keyword wins (overwrite). + declared[keyword] = value; + } + } + } + + /** + * Faithful port of gts-rust's `absorb_property`: folds an overlay's + * declaration of a property into the one inherited from an earlier `allOf` + * branch. The overlay's own non-structural keywords replace the inherited + * ones wholesale (a restated property redeclares its value constraints, it + * does not inherit an unrestated bound), while `properties`/`required`/ + * `additionalProperties` compose structurally by re-folding the overlay's + * own structural keys on top of the inherited ones via `absorbDeclaration`. + */ + private absorbProperty(target: Record, name: string, overlay: any, depth: number): void { + const inherited = target[name]; + const inheritedIsObject = typeof inherited === 'object' && inherited !== null && !Array.isArray(inherited); + const overlayIsObject = typeof overlay === 'object' && overlay !== null && !Array.isArray(overlay); + + if (depth >= MAX_SCHEMA_DEPTH || !inheritedIsObject || !overlayIsObject) { + target[name] = overlay; + return; + } + + const composed: Record = {}; + for (const [keyword, value] of Object.entries(overlay)) { + if (!TRAIT_STRUCTURAL_KEYWORDS.includes(keyword)) { + composed[keyword] = value; + } + } + + const additionalProperties: { value?: any } = {}; + if (inherited.additionalProperties !== undefined) { + additionalProperties.value = inherited.additionalProperties; + } + for (const keyword of ['properties', 'required']) { + if (inherited[keyword] !== undefined) { + composed[keyword] = inherited[keyword]; + } + } + + this.absorbDeclaration(composed, additionalProperties, overlay, depth); + + if ('value' in additionalProperties) { + composed.additionalProperties = additionalProperties.value; + } + + target[name] = composed; + } + + /** + * Faithful port of gts-rust's `merge_additional_properties_constraint`: a + * closedness-preserving lattice over `additionalProperties` values - a + * schema equivalent to `false` (closed) always wins, a schema equivalent to + * `true` (open) never overrides an existing constraint, and anything else + * replaces the accumulated value. Mirrors `allOf` composition, where the + * level stays closed if ANY branch gives `additionalProperties` a + * false-equivalent schema, so a permissive overlay can never loosen a + * closed ancestor. + */ + private mergeAdditionalPropertiesConstraint(accumulated: { value?: any }, candidate: any): void { + const currentBool = 'value' in accumulated ? this.schemaBooleanValue(accumulated.value) : undefined; + if (currentBool === false) return; // already closed, stays closed + const candidateBool = this.schemaBooleanValue(candidate); + if (candidateBool === true && 'value' in accumulated) return; // intersecting with `true` changes nothing + accumulated.value = candidate; + } + + /** `true`/`false` when `schema` is boolean-equivalent, `undefined` otherwise. */ + private schemaBooleanValue(schema: any): boolean | undefined { + if (schema === false) return false; + if (isEmptySchema(schema)) return true; + return undefined; + } + + /** + * Returns a description when the `const` / `enum` value sets across + * subschemas leave no value that satisfies every branch. + */ + private findValueConflict(subSchemas: any[]): string | null { + let allowed: any[] | null = null; + const seen: string[] = []; + + for (const subSchema of subSchemas) { + if (typeof subSchema !== 'object' || subSchema === null) continue; + + let values: any[] | null = null; + if ('const' in subSchema) values = [subSchema.const]; + else if (Array.isArray(subSchema.enum)) values = subSchema.enum; + if (values === null) continue; + + seen.push(JSON.stringify(values)); + if (allowed === null) { + allowed = values; + continue; + } + allowed = allowed.filter((a) => values.some((b) => JSON.stringify(a) === JSON.stringify(b))); + if (allowed.length === 0) { + return `no value satisfies every declared const/enum (${seen.join(' vs ')})`; + } + } + + return null; + } + + /** + * JSON Merge Patch (RFC 7396): objects merge recursively, every other value + * replaces wholesale, and a `null` deletes the key. + */ + private applyMergePatch(target: Record, patch: Record): Record { + const result: Record = { ...target }; + + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete result[key]; + continue; } + if (typeof value === 'object' && !Array.isArray(value)) { + const current = result[key]; + const base = typeof current === 'object' && current !== null && !Array.isArray(current) ? current : {}; + result[key] = this.applyMergePatch(base, value); + continue; + } + result[key] = value; } - return { id: entityId, ok: true, error: '' }; + return result; } // Build the schema chain from base to leaf for a given schema ID + /** + * The document-level GTS rules for a type schema (§9.7.1, §9.11), in one + * place so that every entry point enforces the same set. + * + * A malformed modifier declaration always fails: the document cannot be + * interpreted at all. The remaining rules are the "guards" of §9.11.5 - they + * run when validation is enabled at registration, and unconditionally on the + * explicit validation endpoints. + * + * Returns an error message, or null when the document passes. + */ + checkTypeSchemaRules(content: any, id: string | undefined, options: { enforceGuards: boolean }): string | null { + const declarationError = GtsModifiers.validateDeclaration(content); + if (declarationError) { + return declarationError; + } + + if (!options.enforceGuards) { + return null; + } + + const misplaced = GtsModifiers.findMisplacedKeywords(content); + if (misplaced.length > 0) { + return `document-level GTS keywords must appear at the schema top level; found at: ${misplaced.join(', ')}`; + } + + // `register()` rejects a malformed id up front, so `findFinalBaseInChain` + // should never actually throw here; the catch only keeps this + // string-or-null-returning check from turning into an uncaught exception + // if that invariant is ever violated. + let finalBase: string | null; + try { + finalBase = id ? this.findFinalBaseInChain(id) : null; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + if (finalBase) { + return `base type '${finalBase}' is final and cannot be extended`; + } + + return null; + } + + /** + * The document-level GTS rule for an instance: its rightmost type must be + * instantiable (§9.11.3 item 1). Returns an error message, or null. + */ + checkInstanceRules(typeId: string | null | undefined): string | null { + if (typeId && this.isAbstractType(typeId)) { + return `Type '${typeId}' is abstract and cannot be directly instantiated`; + } + return null; + } + + /** + * Returns the id of the first base type in the `$id` chain of `schemaId` that + * is marked `x-gts-final`, or null when the chain is derivable (§9.11.2). + * + * Determined from the chained `$id` alone, so it holds regardless of whether + * the derived body uses `allOf` + `$ref` or restates the parent's fields. + * Only proper ancestors are considered: a type being final does not + * invalidate itself. + */ + findFinalBaseInChain(schemaId: string): string | null { + const chain = this.buildSchemaChain(schemaId); + for (const baseId of chain.slice(0, -1)) { + const baseEntity = this.get(baseId); + if (baseEntity && baseEntity.isSchema && GtsModifiers.isFinal(baseEntity.content)) { + return baseId; + } + } + return null; + } + + /** True when `typeId` resolves to a registered type marked `x-gts-abstract` (§9.11.3). */ + isAbstractType(typeId: string): boolean { + const normalized = typeId.startsWith(GTS_URI_PREFIX) ? typeId.substring(GTS_URI_PREFIX.length) : typeId; + const entity = this.get(normalized); + return !!entity && entity.isSchema && GtsModifiers.isAbstract(entity.content); + } + private buildSchemaChain(schemaId: string): string[] { // Parse the schema ID to get segments try { @@ -1394,44 +1550,47 @@ export class GtsStore { } return chain; - } catch { - return [schemaId]; - } - } - - // Collect x-gts-traits-schema from a schema content (recursing into allOf) - private collectTraitSchemas(content: any, out: any[], depth: number = 0): void { - if (depth > 64 || typeof content !== 'object' || content === null) return; - - if (content['x-gts-traits-schema'] !== undefined) { - out.push(content['x-gts-traits-schema']); - } - - if (Array.isArray(content.allOf)) { - for (const item of content.allOf) { - this.collectTraitSchemas(item, out, depth + 1); - } - } - } - - // Collect x-gts-traits from a schema content (recursing into allOf) - private collectTraitValues(content: any, merged: Record, depth: number = 0): void { - if (depth > 64 || typeof content !== 'object' || content === null) return; - - if (typeof content['x-gts-traits'] === 'object' && content['x-gts-traits'] !== null) { - Object.assign(merged, content['x-gts-traits']); - } - - if (Array.isArray(content.allOf)) { - for (const item of content.allOf) { - this.collectTraitValues(item, merged, depth + 1); - } + } catch (err) { + // Returning a truncated (or single-element) chain would silently hide + // every ancestor of `schemaId` from the caller, which then fails open + // by treating the entity as if it had no parent/trait-schema/final-base + // ancestors at all - so, like `resolveTraitSchemaRefs`'s depth guard + // above, the caller is told instead of being handed a permissive + // fallback. `register()` rejects malformed ids up front, so this + // should be unreachable in practice. + throw new Error(`Cannot build schema chain for '${schemaId}': ${err instanceof Error ? err.message : err}`); } } // Resolve $ref inside a trait schema, detecting cycles - private resolveTraitSchemaRefs(schema: any, visited: Set, depth: number = 0): any { - if (depth > 64) return schema; + // + // `pathBudget` bounds the total number of `$ref` follows and `allOf` branch + // recursions taken across the whole top-level call, not just the depth of + // any one chain. `MAX_SCHEMA_DEPTH` alone only bounds how deep a single + // chain can go; it does nothing to stop a diamond-shaped `allOf`/`$ref` + // DAG (level N reaching both level N-1 and N-2, which themselves both + // reach a shared ancestor) from being walked once per root-to-leaf path + // through it - a count that doubles per level and can reach the millions + // within `MAX_SCHEMA_DEPTH`. This function's own tree-walk is cheap even + // at that path count (well under a second - see the shared MAX_SCHEMA_PATHS' + // budget below), but the resulting *inlined* schema handed to Ajv is not: + // Ajv's compiled validator has one function-call node per occurrence, so + // validating even a single instance against it becomes exponential too. + // A mutable holder (rather than a primitive `count`) is used so every + // recursive call increments the SAME counter, matching how `visited` is + // threaded per-path but in the opposite sense - shared across all paths, + // not cloned per branch. + private resolveTraitSchemaRefs( + schema: any, + visited: Set, + depth: number = 0, + pathBudget: { count: number } = { count: 0 } + ): any { + // Returning the unresolved schema would silently drop the constraints + // behind the remaining refs, so the caller is told instead. + if (depth > MAX_SCHEMA_DEPTH) { + throw new Error(`x-gts-traits-schema nests deeper than ${MAX_SCHEMA_DEPTH} levels and cannot be resolved`); + } if (typeof schema !== 'object' || schema === null) return schema; const result: any = {}; @@ -1441,16 +1600,30 @@ export class GtsStore { const refUri = value as string; const refId = refUri.startsWith(GTS_URI_PREFIX) ? refUri.substring(GTS_URI_PREFIX.length) : refUri; + // `visited` tracks the active recursion path, not every reference seen + // anywhere: two siblings may legitimately point at the same trait + // schema, and only a reference back into its own ancestry is a cycle. if (visited.has(refId)) { throw new Error(`Cyclic reference detected in trait schema: ${refId}`); } - visited.add(refId); + + pathBudget.count++; + if (pathBudget.count > MAX_SCHEMA_PATHS) { + throw new Error( + `x-gts-traits-schema reference graph has too many composition paths (exceeds ${MAX_SCHEMA_PATHS}) and cannot be resolved` + ); + } const refEntity = this.get(refId); if (!refEntity || !refEntity.content) { throw new Error(`Unresolvable trait schema reference: ${refUri}`); } - const resolved = this.resolveTraitSchemaRefs(refEntity.content, visited, depth + 1); + const resolved = this.resolveTraitSchemaRefs( + refEntity.content, + new Set(visited).add(refId), + depth + 1, + pathBudget + ); // Merge resolved content into result for (const [rk, rv] of Object.entries(resolved)) { if (rk !== '$id' && rk !== '$$id' && rk !== '$schema' && rk !== '$$schema') { @@ -1461,9 +1634,18 @@ export class GtsStore { } if (key === 'allOf' && Array.isArray(value)) { - result.allOf = (value as any[]).map((item) => this.resolveTraitSchemaRefs(item, visited, depth + 1)); + // Each branch gets its own path, so sibling branches may reuse a ref. + result.allOf = (value as any[]).map((item) => { + pathBudget.count++; + if (pathBudget.count > MAX_SCHEMA_PATHS) { + throw new Error( + `x-gts-traits-schema reference graph has too many composition paths (exceeds ${MAX_SCHEMA_PATHS}) and cannot be resolved` + ); + } + return this.resolveTraitSchemaRefs(item, new Set(visited), depth + 1, pathBudget); + }); } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - result[key] = this.resolveTraitSchemaRefs(value, new Set(visited), depth + 1); + result[key] = this.resolveTraitSchemaRefs(value, new Set(visited), depth + 1, pathBudget); } else { result[key] = value; } @@ -1473,40 +1655,174 @@ export class GtsStore { } // Apply defaults from trait schema to trait values - private applyTraitDefaults(schema: any, traits: Record): Record { + /** + * Materializes trait-schema `default`s into the effective traits object + * before the completeness check (§9.7.5, ADR-0003). + * + * Recursive: a default declared on a nested trait property is materialized + * too, provided its containing object is present or itself materialized. + * Applying only top-level defaults left `routing.topic` unresolved and the + * completeness check then failed on a trait the schema had already answered. + */ + private applyTraitDefaults(schema: any, traits: Record, depth: number = 0): Record { + if (depth > MAX_SCHEMA_DEPTH) return traits; + const result = { ...traits }; - const props = this.collectAllTraitProperties(schema); + const { properties, required } = this.collectTraitSurface(schema); - for (const [propName, propSchema] of Object.entries(props)) { - if (!(propName in result) && typeof propSchema === 'object' && propSchema !== null && 'default' in propSchema) { + for (const [propName, propSchema] of Object.entries(properties)) { + if (typeof propSchema !== 'object' || propSchema === null) continue; + + // Already carries a value: fill in whatever its own subtree defaults. + if (propName in result) { + const current = result[propName]; + if (typeof current === 'object' && current !== null && !Array.isArray(current)) { + result[propName] = this.applyTraitDefaults(propSchema, current, depth + 1); + } + continue; + } + + if ('default' in propSchema) { result[propName] = propSchema.default; + continue; + } + + // No `default` of its own. ADR-0003 licenses materializing defaults, not + // inventing values, so an absent *optional* object stays absent - + // conjuring one would validate a subtree the author never supplied and + // could fail a type that is legitimately incomplete there. An absent + // *required* object is filled from its subtree; whatever the subtree + // cannot supply is then a genuine completeness failure. + if (required.has(propName)) { + const nested = this.applyTraitDefaults(propSchema, {}, depth + 1); + if (Object.keys(nested).length > 0) result[propName] = nested; } } return result; } - // Collect all properties from a trait schema (handling allOf composition) - private collectAllTraitProperties(schema: any, depth: number = 0): Record { - const props: Record = {}; - if (depth > 64 || typeof schema !== 'object' || schema === null) return props; + /** + * The declared surface of a trait schema at one level - its properties and + * which of them are required - with `allOf` branches merged in. + * + * Both are collected together so that a caller can never read a merged + * property list against a stale or differently-merged `required` list. + * + * A property can be redeclared by more than one contributing branch (the + * schema's own `properties` and each `allOf` branch, recursively) - real + * `allOf` semantics require a value to satisfy every branch independently, + * not just the last one written, so merging by wholesale-replacing an + * earlier branch's subschema with a later one silently drops whatever the + * earlier branch declared and the later branch did not repeat (most + * notably `default`). Every branch's subschema for a name is accumulated + * and combined via `mergeTraitPropertySchemas` instead. + */ + private collectTraitSurface( + schema: any, + depth: number = 0 + ): { properties: Record; required: Set } { + const { declaredBy, required } = this.collectTraitDeclarations(schema, depth); + + const properties: Record = {}; + for (const [name, subSchemas] of declaredBy) { + properties[name] = subSchemas.length === 1 ? subSchemas[0] : this.mergeTraitPropertySchemas(subSchemas); + } + + return { properties, required }; + } + + /** + * The raw, per-branch-unmerged form `collectTraitSurface` collapses into + * its `properties` map: every subschema any branch (recursively through + * its own `allOf`) declares for a property name, kept as separate list + * entries rather than merged into one value. + * + * `collectTraitSurface` collapses this for `applyTraitDefaults`, which only + * ever needs one schema per name to read `default` from and recurse into. + * (Trait-schema chain *satisfiability* is a separate concern, answered by + * `validateTraitChainSatisfiability` via `resolveSchemaFully` / + * `compareOverlayToBase` instead of this flattening.) + */ + private collectTraitDeclarations( + schema: any, + depth: number = 0 + ): { declaredBy: Map; required: Set } { + const declaredBy = new Map(); + const required = new Set(); + if (depth > MAX_SCHEMA_DEPTH || typeof schema !== 'object' || schema === null) { + return { declaredBy, required }; + } + + const addBranchProperties = (props: Record) => { + for (const [name, subSchema] of Object.entries(props)) { + const existing = declaredBy.get(name); + if (existing) existing.push(subSchema); + else declaredBy.set(name, [subSchema]); + } + }; if (typeof schema.properties === 'object' && schema.properties !== null) { - Object.assign(props, schema.properties); + addBranchProperties(schema.properties); + } + if (Array.isArray(schema.required)) { + for (const name of schema.required) { + if (typeof name === 'string') required.add(name); + } } if (Array.isArray(schema.allOf)) { for (const item of schema.allOf) { - Object.assign(props, this.collectAllTraitProperties(item, depth + 1)); + const branch = this.collectTraitDeclarations(item, depth + 1); + for (const [name, subSchemas] of branch.declaredBy) { + const existing = declaredBy.get(name); + if (existing) existing.push(...subSchemas); + else declaredBy.set(name, [...subSchemas]); + } + for (const name of branch.required) required.add(name); } } - return props; + return { declaredBy, required }; + } + + /** + * Combines more than one branch's subschema for the same property name + * into one schema that `applyTraitDefaults` can read. + * + * `applyTraitDefaults` looks for a `default` directly on the property + * schema it's given, then recurses into that same schema as a sub-schema + * for nested properties/required. Wrapping the branches in `{ allOf: [...] }` + * gives the recursive call everything it needs (nested `properties` and + * `required` still resolve correctly, since `collectTraitSurface` already + * knows how to flatten `allOf`); the one piece `allOf` doesn't surface for + * a direct read is `default`, so it's hoisted onto the wrapper too. + * + * `subSchemas` is built root-to-leaf (the chain walk that produces + * `traitSchemas` runs from the base schema down to the leaf), so when more + * than one branch declares a `default` for the same property, the LAST + * match is the most-derived (descendant) declaration. That's the one that + * wins, consistent with the descendant-overrides-ancestor convention used + * everywhere else in this file (e.g. `applyMergePatch`'s RFC 7396 + * last-wins merge) - a descendant redeclaring a property's default is + * meant to override its ancestor's, not the other way around. + */ + private mergeTraitPropertySchemas(subSchemas: any[]): any { + const withDefault = [...subSchemas].reverse().find((s) => typeof s === 'object' && s !== null && 'default' in s); + const merged: any = { allOf: subSchemas }; + if (withDefault) merged.default = withDefault.default; + return merged; } // Detect cyclic $$ref/$ref references reachable from a schema's content private detectRefCycle(originId: string, content: any, visited: Set, depth: number = 0): string | null { - if (depth > 64 || !content || typeof content !== 'object') return null; + // Fail closed, and separately from the non-object base case: `null` means + // "no cycle here", so returning it on overflow would let a cycle that sits + // below the limit pass unexamined. + if (depth > MAX_SCHEMA_DEPTH) { + return `reference chain from '${originId}' exceeds ${MAX_SCHEMA_DEPTH} levels and cannot be checked for cycles`; + } + if (!content || typeof content !== 'object') return null; // Check direct ref on this object const ref = content['$$ref'] || content['$ref']; @@ -1517,16 +1833,19 @@ export class GtsStore { } const refEntity = this.get(refId); if (refEntity && refEntity.content) { - visited.add(refId); - const inner = this.detectRefCycle(originId, refEntity.content, visited, depth + 1); + // Same rule as resolveTraitSchemaRefs: `visited` is the active + // recursion path, so following a ref extends a copy of it. Sharing one + // set across siblings reports ordinary reuse of a common schema as a + // cycle. + const inner = this.detectRefCycle(originId, refEntity.content, new Set(visited).add(refId), depth + 1); if (inner) return inner; } } - // Recurse into allOf + // Recurse into allOf - each branch is its own path. if (Array.isArray(content.allOf)) { for (const sub of content.allOf) { - const inner = this.detectRefCycle(originId, sub, visited, depth + 1); + const inner = this.detectRefCycle(originId, sub, new Set(visited), depth + 1); if (inner) return inner; } } @@ -1534,19 +1853,51 @@ export class GtsStore { return null; } - private findParentRef(schema: any): string | null { - if (!schema || !schema.allOf || !Array.isArray(schema.allOf)) { - return null; + /** + * Every `$ref` / `$$ref` this schema declares directly - at the top level or + * in an `allOf` branch. + * + * The top level counts: `{"$ref": parent}` is a valid JSON Schema way to say + * "identical to the parent", and ADR-0001 leaves the derivation body free, so + * a derived type written that way inherits just as much as an `allOf` one. + */ + private collectDirectRefs(schema: any): string[] { + if (!schema || typeof schema !== 'object') { + return []; } - for (const sub of schema.allOf) { - if (sub && typeof sub === 'object') { - const ref = sub['$$ref'] || sub['$ref']; - if (typeof ref === 'string') { - return ref; + + const refs: string[] = []; + const own = schema['$$ref'] || schema['$ref']; + if (typeof own === 'string') { + refs.push(own); + } + + if (Array.isArray(schema.allOf)) { + for (const sub of schema.allOf) { + if (sub && typeof sub === 'object') { + const ref = sub['$$ref'] || sub['$ref']; + if (typeof ref === 'string') { + refs.push(ref); + } } } } - return null; + return refs; + } + + /** + * True when the derived body pulls its chain parent in through `allOf` + + * `$ref`, so the parent's constraints keep applying to the same instance. + * + * A reference to some unrelated type does not count: the parent's + * constraints would not be inherited, so the derived body still has to + * restate them (ADR-0001 variant 2c). + */ + private inheritsParentViaRef(schema: any, parentId: string): boolean { + return this.collectDirectRefs(schema).some((ref) => { + const normalized = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref; + return normalized === parentId; + }); } private resolveSchemaFully(schema: any, visited: Set = new Set()): ResolvedSchema { @@ -1557,6 +1908,28 @@ export class GtsStore { type: schema.type, }; + // A top-level `$ref` carries the whole referenced schema, exactly as an + // `allOf` branch does; without this a parent written that way resolves to + // nothing and its constraints become unenforceable for descendants. + const ownRef = schema['$$ref'] || schema['$ref']; + if (typeof ownRef === 'string') { + const refId = ownRef.startsWith(GTS_URI_PREFIX) ? ownRef.substring(GTS_URI_PREFIX.length) : ownRef; + if (!visited.has(refId)) { + const refEntity = this.get(refId); + if (refEntity && refEntity.content) { + const resolved = this.resolveSchemaFully(refEntity.content, new Set(visited).add(refId)); + Object.assign(result.properties, resolved.properties); + result.required.push(...(resolved.required || [])); + if (resolved.additionalProperties !== undefined) { + result.additionalProperties = resolved.additionalProperties; + } + if (resolved.type && !result.type) { + result.type = resolved.type; + } + } + } + } + // If this schema has allOf, resolve each part if (schema.allOf && Array.isArray(schema.allOf)) { for (const sub of schema.allOf) { @@ -1700,7 +2073,22 @@ export class GtsStore { return overlay; } - private compareOverlayToBase(overlay: ResolvedSchema, baseResolved: ResolvedSchema, path: string): string[] { + /** + * Compares a derived schema's overlay against its resolved base (§3.1, §4.1). + * + * `inheritsViaRef` says whether the derived body pulls the base in through + * `allOf` + `$ref`. When it does, the base branch keeps applying to the same + * instance, so the overlay cannot loosen anything by omission - only by + * excluding values the base allows. When the derived body instead restates + * the parent's fields (ADR-0001 variant 2c), anything it fails to restate is + * a genuine loosening. + */ + private compareOverlayToBase( + overlay: ResolvedSchema, + baseResolved: ResolvedSchema, + path: string, + inheritsViaRef: boolean = true + ): string[] { const errors: string[] = []; const overlayProps = overlay.properties || {}; const baseProps = baseResolved.properties || {}; @@ -1734,29 +2122,68 @@ export class GtsStore { // Both base and overlay have this property — compare constraints if (typeof propSchema === 'object' && propSchema !== null) { - errors.push(...this.comparePropertyConstraints(propSchema, baseProp, propPath)); + errors.push(...this.comparePropertyConstraints(propSchema, baseProp, propPath, inheritsViaRef)); + } + } + + // A derived level that closes itself must restate the base's properties: + // under allOf the closed branch is evaluated on its own and would reject + // every value the base declares but the derived omits. + if (overlay.additionalProperties === false) { + for (const propName of Object.keys(baseProps)) { + if (baseProps[propName] === false) continue; + if (!(propName in overlayProps)) { + const propPath = path ? `${path}.${propName}` : propName; + errors.push(`Property '${propPath}' is declared in base but excluded by additionalProperties: false`); + } } } - // Check additionalProperties - if (baseResolved.additionalProperties === false) { - if (overlay.additionalProperties === true) { + if (!inheritsViaRef) { + // The derived schema stands alone, so it must carry the base's + // constraints itself rather than inherit them through a $ref. + if (baseResolved.additionalProperties === false && overlay.additionalProperties !== false) { errors.push('Cannot loosen additionalProperties from false to true'); - } else if (overlay.additionalProperties === undefined) { - errors.push('Base has additionalProperties: false but derived does not restate it'); + } + + const overlayRequired = new Set(overlay.required || []); + for (const requiredProp of baseResolved.required || []) { + if (!overlayRequired.has(requiredProp)) { + const propPath = path ? `${path}.${requiredProp}` : requiredProp; + errors.push(`Property '${propPath}' is required in base but not in derived`); + } } } return errors; } - private comparePropertyConstraints(derived: any, base: any, propPath: string): string[] { + private comparePropertyConstraints( + derived: any, + base: any, + propPath: string, + inheritsViaRef: boolean = true + ): string[] { const errors: string[] = []; if (typeof base !== 'object' || base === null) { return errors; } + // Cross-keyword conflicts: the loosening/drop checks below only compare + // the SAME keyword between `derived` and `base` (e.g. `maximum` vs + // `maximum`), so they cannot see a conflict between DIFFERENT keywords + // declared by the two sides on the same property - e.g. `derived` + // declares `maximum: 5` while `base` already declared `minimum: 10`: no + // value satisfies both, but nothing above compares `minimum` against + // `maximum`. `findValueConflict`/`findCrossedBound` check whether a SET + // of sibling subschemas has a non-empty joint value-set/bound-range + // intersection, which is exactly this question. + const crossKeywordConflict = this.findValueConflict([derived, base]) || findCrossedBound([derived, base]); + if (crossKeywordConflict) { + errors.push(`Property '${propPath}' cannot be satisfied: ${crossKeywordConflict}`); + } + // Type check const baseType = base.type; const derivedType = derived.type; @@ -1886,7 +2313,7 @@ export class GtsStore { errors.push(`Property '${propPath}' drops constraint 'items'`); } } else if (typeof base.items === 'object' && typeof derived.items === 'object') { - errors.push(...this.comparePropertyConstraints(derived.items, base.items, `${propPath}.items`)); + errors.push(...this.comparePropertyConstraints(derived.items, base.items, `${propPath}.items`, inheritsViaRef)); } } @@ -1903,7 +2330,7 @@ export class GtsStore { required: base.required || [], additionalProperties: base.additionalProperties, }; - errors.push(...this.compareOverlayToBase(nestedOverlay, nestedBase, propPath)); + errors.push(...this.compareOverlayToBase(nestedOverlay, nestedBase, propPath, inheritsViaRef)); } } @@ -2000,9 +2427,9 @@ export function createJsonEntity(content: any, _config?: Partial): Js return { id: extractResult.id, - schemaId: extractResult.schema_id, + schemaId: extractResult.type_id, content, - isSchema: extractResult.is_schema, + isSchema: extractResult.is_type_schema, references, }; } diff --git a/src/types.ts b/src/types.ts index a19bd7c..e807d56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,31 @@ export const GTS_PREFIX = 'gts.'; export const GTS_URI_PREFIX = 'gts://'; export const MAX_ID_LENGTH = 1024; +/** + * Recursion bound shared by every walker over schema documents. + * + * The limit exists to stop pathological or cyclic input, never to decide a + * result. Whatever hits it must fail closed - report the finding, or mark the + * comparison inconclusive - and must never return a value that reads as + * "unconstrained", which would turn a bailout into a silent pass. + */ +export const MAX_SCHEMA_DEPTH = 64; + +/** + * Bounds the total number of `$ref` follows and `allOf` branch recursions a + * schema walker may take across one top-level call, independent of + * `MAX_SCHEMA_DEPTH` (which only bounds how deep a single chain goes, not how + * many root-to-leaf paths a diamond-shaped `allOf`/`$ref` DAG can have). Path + * count doubles per level in a symmetric diamond, so a modest depth well + * inside `MAX_SCHEMA_DEPTH` can already reach millions of paths, which makes + * naive per-path resolution/comparison exponential even though depth alone + * stays small. 10,000 is generously above any realistic legitimate schema + * hierarchy (expected to be a handful of levels deep with little to no + * branching) while guaranteeing the walk completes in well under a second + * even in the worst case. + */ +export const MAX_SCHEMA_PATHS = 10_000; + export interface GtsIDSegment { num: number; offset: number; @@ -34,7 +59,7 @@ export interface ParseResult { ok: boolean; segments: GtsIDSegment[]; error?: string; - is_schema?: boolean; + is_type_schema?: boolean; is_wildcard?: boolean; } @@ -53,10 +78,10 @@ export interface UUIDResult { export interface ExtractResult { id: string; - schema_id: string | null; + type_id: string | null; selected_entity_field?: string; - selected_schema_id_field?: string; - is_schema: boolean; + selected_type_id_field?: string; + is_type_schema: boolean; error?: string; } @@ -82,14 +107,27 @@ export interface RelationshipResult { error?: string; } +/** Tri-state compatibility verdict (GTS spec 0.13 §4.3). */ +export type CompatVerdict = 'compatible' | 'incompatible' | 'unknown'; + export interface CompatibilityResult { - from: string; - to: string; old: string; new: string; + backward_compatibility: CompatVerdict; + forward_compatibility: CompatVerdict; + full_compatibility: CompatVerdict; + from: string; + to: string; direction: string; + /** + * @deprecated Always empty since 0.4.0. Compatibility is decided by comparing + * accepted-instance sets (§4.3) rather than by diffing properties, so the + * engine no longer produces a property diff. Slated for removal. + */ added_properties: string[]; + /** @deprecated Always empty since 0.4.0. See {@link CompatibilityResult.added_properties}. */ removed_properties: string[]; + /** @deprecated Always empty since 0.4.0. See {@link CompatibilityResult.added_properties}. */ changed_properties: Array>; is_fully_compatible: boolean; is_backward_compatible: boolean; @@ -112,6 +150,19 @@ export interface GtsConfig { strictMode: boolean; } +/** + * The read-only registry surface that the compatibility engine and the + * `x-gts-ref` validator need - entity lookup by identifier, nothing more. + * + * They depend on this instead of on `GtsStore` so that the dependency stays + * one-way: the registry may reach into those modules, and they only need to + * look entities up. `GtsStore` satisfies this structurally, so no call site + * changes and no import cycle. + */ +export interface EntityLookup { + get(id: string): JsonEntity | undefined; +} + export interface JsonEntity { id: string; schemaId: string | null; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..246ae8a --- /dev/null +++ b/src/version.ts @@ -0,0 +1,15 @@ +/** + * Version reported by the CLI, the HTTP server binary, and the generated + * OpenAPI document. Read from `package.json` so none of them can drift from + * the published version; the file sits one level above `src` (ts-node) and + * one level above `dist` (compiled output), so resolution from either + * location needs to walk up two levels from the compiled/source file itself. + */ +export const PACKAGE_VERSION: string = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('../package.json').version || '0.0.0'; + } catch { + return '0.0.0'; + } +})(); diff --git a/src/x-gts-ref.ts b/src/x-gts-ref.ts index 697b249..58d589f 100644 --- a/src/x-gts-ref.ts +++ b/src/x-gts-ref.ts @@ -4,7 +4,7 @@ */ import { Gts } from './gts'; -import { GtsStore } from './store'; +import { EntityLookup } from './types'; export interface XGtsRefValidationError { fieldPath: string; @@ -14,9 +14,16 @@ export interface XGtsRefValidationError { } export class XGtsRefValidator { - private store: GtsStore; + private store: EntityLookup | undefined; - constructor(store: GtsStore) { + /** + * @param store Entity registry used to check that referenced GTS IDs actually + * exist. Omit it (or pass `undefined`) to validate only the GTS-ID + * format/pattern of referenced values without requiring the referenced + * entity to be registered - e.g. for `x-gts-traits` values, which are + * schema-level example/default data rather than live references. + */ + constructor(store?: EntityLookup) { this.store = store; } @@ -334,7 +341,9 @@ export class XGtsRefValidator { }; } - // Optionally check if entity exists in store + // Check if entity exists in store, when a store was provided. Callers + // that only need format/pattern validation (no existence requirement) + // construct this validator without a store. if (this.store) { const entity = this.store.get(value); if (!entity) { diff --git a/tests/compatibility.property.test.ts b/tests/compatibility.property.test.ts new file mode 100644 index 0000000..1049e3f --- /dev/null +++ b/tests/compatibility.property.test.ts @@ -0,0 +1,271 @@ +import Ajv from 'ajv'; +import Ajv2019 from 'ajv/dist/2019'; +import { GTS } from '../src'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +/** + * Property-based soundness test for the OP#8 subsumption engine. + * + * The engine's verdicts are defined by accepted-instance-set inclusion (§4.3), + * which makes them checkable against an actual JSON Schema validator rather + * than against our reading of the spec: + * + * backward === 'compatible' => every instance ajv accepts under `old` + * is also accepted under `new` + * forward === 'compatible' => every instance ajv accepts under `new` + * is also accepted under `old` + * + * A violation is a definite bug: the engine claimed inclusion that does not + * hold. This is the failure mode that repeatedly slipped past hand-written + * tests - a keyword modeled in one place and not another silently widens a + * verdict, and no single example test looks wrong. + * + * Only `compatible` is checked. `unknown` is inconclusive by construction, and + * `incompatible` cannot be refuted by sampling: not finding a distinguishing + * instance among finitely many does not prove none exists. So this test is a + * soundness check, not a completeness one. + * + * The space is enumerated rather than randomised, so a failure reproduces + * exactly and CI cannot flake. + * + * Deliberate exclusions, so the boundary is explicit rather than silent: + * + * - `additionalProperties` never appears *inside* an `allOf` branch. The engine + * builds one resolved effective schema (§4.4 requires classifying the content + * model from it), whereas ajv evaluates `allOf` branches independently, so a + * closed branch beside a branch declaring properties genuinely disagrees. That + * divergence is a modeling decision, not a defect, and would swamp the signal. + * - No `gts://` `$ref`, because ajv would need the registry's ref loader to see + * the same schema the engine does. Plain `allOf` composition is covered. + * - No malformed schemas (`allOf` as an object, a property schema of `1`). ajv + * cannot compile them, so there is no oracle to compare against; that + * behaviour stays covered by the hand-written cases in compatibility.test.ts. + * + * Verified to have teeth by mutation: reverting the position-aware walker + * reports `{"title":"a"}` distinguishing two schemas it called compatible, and + * reverting the numeric-widening fix reports `1.5` doing the same. If a change + * here makes those mutations pass, this test has stopped working. + */ + +/** Leaf property schemas, chosen to cover every keyword the engine models. */ +const LEAVES: Array<[string, Record]> = [ + ['any', {}], + ['string', { type: 'string' }], + ['number', { type: 'number' }], + ['integer', { type: 'integer' }], + ['string-max3', { type: 'string', maxLength: 3 }], + ['string-min2', { type: 'string', minLength: 2 }], + ['num-min0', { type: 'number', minimum: 0 }], + ['num-gt0', { type: 'number', exclusiveMinimum: 0 }], + ['num-max10', { type: 'number', maximum: 10 }], + ['num-lt10', { type: 'number', exclusiveMaximum: 10 }], + ['enum-ab', { type: 'string', enum: ['a', 'b'] }], + ['enum-abc', { type: 'string', enum: ['a', 'b', 'c'] }], + ['const-a', { type: 'string', const: 'a' }], + ['pattern-a', { type: 'string', pattern: '^a' }], +]; + +interface Candidate { + label: string; + body: Record; +} + +function buildCandidates(): Candidate[] { + const candidates: Candidate[] = []; + + // The main matrix: one property, varying its schema, requiredness and the + // content model of the object around it. + for (const [leafName, leaf] of LEAVES) { + for (const required of [false, true]) { + for (const [apName, apKey, ap] of [ + ['open', 'additionalProperties', undefined], + ['closed', 'additionalProperties', false], + ['up-string', 'unevaluatedProperties', { type: 'string' }], + ] as Array<[string, string, unknown]>) { + const body: Record = { type: 'object', properties: { p: leaf } }; + if (required) body.required = ['p']; + if (ap !== undefined) body[apKey] = ap; + candidates.push({ label: `p:${leafName}/${required ? 'req' : 'opt'}/${apName}`, body }); + } + } + } + + // A property whose name collides with an annotation keyword. Stripping + // annotations without knowing that `properties` keys are user-chosen names + // deleted these and made the schemas compare as identical. + for (const dataKey of ['title', 'description', 'format', 'default']) { + for (const [leafName, leaf] of [LEAVES[1], LEAVES[2]].map((l, i) => [i === 0 ? 'string' : 'number', l[1]]) as Array< + [string, Record] + >) { + candidates.push({ + label: `data-key ${dataKey}:${leafName}`, + body: { type: 'object', properties: { [dataKey]: leaf }, required: [dataKey] }, + }); + } + } + + // allOf composition, including a branch restating the same type - which is + // what a derived type declaring a numeric property looks like. + candidates.push( + { label: 'allOf number+number', body: { allOf: [{ type: 'number' }, { type: 'number' }] } }, + { label: 'allOf number+integer', body: { allOf: [{ type: 'number' }, { type: 'integer' }] } }, + { label: 'allOf string+max3', body: { allOf: [{ type: 'string' }, { type: 'string', maxLength: 3 }] } }, + { + label: 'allOf props p+q', + body: { allOf: [{ properties: { p: { type: 'string' } } }, { properties: { q: { type: 'number' } } }] }, + }, + { + label: 'allOf min0+max10', + body: { + allOf: [ + { type: 'number', minimum: 0 }, + { type: 'number', maximum: 10 }, + ], + }, + }, + { label: 'bare number', body: { type: 'number' } }, + { label: 'bare integer', body: { type: 'integer' } }, + { label: 'bare string', body: { type: 'string' } }, + { label: 'empty', body: {} }, + { + label: 'nested object', + body: { + type: 'object', + properties: { outer: { type: 'object', properties: { inner: { type: 'string' } }, required: ['inner'] } }, + required: ['outer'], + }, + } + ); + + return candidates; +} + +/** Instances chosen to distinguish the schemas above. */ +const INSTANCES: unknown[] = [ + {}, + { p: 'a' }, + { p: 'b' }, + { p: 'c' }, + { p: 'ab' }, + { p: 'abcd' }, + { p: 0 }, + { p: 1 }, + { p: 5 }, + { p: 10 }, + { p: 10.5 }, + { p: -1 }, + { p: null }, + { p: true }, + { p: {} }, + { p: [] }, + { q: 'x' }, + { p: 'a', q: 'x' }, + { title: 'a' }, + { title: 1 }, + { description: 'a' }, + { format: 1 }, + { default: 'a' }, + { outer: { inner: 'a' } }, + { outer: {} }, + 'a', + 1, + 1.5, + null, + true, + [], +]; + +describe('OP#8 - subsumption soundness against a real JSON Schema validator', () => { + const candidates = buildCandidates(); + const ids = candidates.map((_, index) => `gts.x.prop.gen.s${index}.v1~`); + + // accepts[i][m] - does schema i accept instance m, per ajv + let accepts: boolean[][]; + let gts: GTS; + + beforeAll(() => { + const ajv = new Ajv({ strict: false, validateSchema: false, allErrors: false }); + // Draft-07 (the `ajv` default dialect) has no `unevaluatedProperties` + // keyword at all, so candidates that use it need a 2019-09 instance - + // otherwise the oracle would silently ignore the keyword and the + // comparison would prove nothing about it either way. + const ajv2019 = new Ajv2019({ strict: false, validateSchema: false, allErrors: false }); + accepts = candidates.map((candidate) => { + const usesUnevaluated = 'unevaluatedProperties' in candidate.body; + const validate = usesUnevaluated + ? ajv2019.compile(candidate.body) + : ajv.compile({ $schema: DRAFT7, ...candidate.body }); + return INSTANCES.map((instance) => validate(instance) as boolean); + }); + + gts = new GTS({ validateRefs: false }); + candidates.forEach((candidate, index) => { + gts.register({ $$id: ids[index], $$schema: DRAFT7, ...candidate.body }); + }); + }); + + test(`no 'compatible' verdict overstates inclusion`, () => { + const violations: string[] = []; + + for (let i = 0; i < candidates.length; i++) { + for (let j = 0; j < candidates.length; j++) { + const result = gts.checkCompatibility(ids[i], ids[j]); + + // backward: Valid(old=i) subset-of Valid(new=j) + if (result.backward_compatibility === 'compatible') { + const witness = INSTANCES.findIndex((_, m) => accepts[i][m] && !accepts[j][m]); + if (witness !== -1) { + violations.push( + `backward said compatible but instance ${JSON.stringify(INSTANCES[witness])} ` + + `is accepted by old [${candidates[i].label}] and rejected by new [${candidates[j].label}]` + ); + } + } + + // forward: Valid(new=j) subset-of Valid(old=i) + if (result.forward_compatibility === 'compatible') { + const witness = INSTANCES.findIndex((_, m) => accepts[j][m] && !accepts[i][m]); + if (witness !== -1) { + violations.push( + `forward said compatible but instance ${JSON.stringify(INSTANCES[witness])} ` + + `is accepted by new [${candidates[j].label}] and rejected by old [${candidates[i].label}]` + ); + } + } + } + } + + expect(violations.slice(0, 10)).toEqual([]); + }); + + test('full compatibility holds exactly when both directions hold', () => { + const inconsistent: string[] = []; + + for (let i = 0; i < candidates.length; i += 7) { + for (let j = 0; j < candidates.length; j += 5) { + const r = gts.checkCompatibility(ids[i], ids[j]); + const expected = + r.backward_compatibility === 'incompatible' || r.forward_compatibility === 'incompatible' + ? 'incompatible' + : r.backward_compatibility === 'unknown' || r.forward_compatibility === 'unknown' + ? 'unknown' + : 'compatible'; + if (r.full_compatibility !== expected) { + inconsistent.push( + `[${candidates[i].label}] -> [${candidates[j].label}]: full=${r.full_compatibility} ` + + `but backward=${r.backward_compatibility}, forward=${r.forward_compatibility}` + ); + } + } + } + + expect(inconsistent).toEqual([]); + }); + + test('the generated space is large enough to be meaningful', () => { + // Guards against a future edit quietly shrinking coverage to nothing. + expect(candidates.length).toBeGreaterThanOrEqual(60); + expect(INSTANCES.length).toBeGreaterThanOrEqual(25); + }); +}); diff --git a/tests/compatibility.test.ts b/tests/compatibility.test.ts new file mode 100644 index 0000000..d5e776d --- /dev/null +++ b/tests/compatibility.test.ts @@ -0,0 +1,985 @@ +import { GTS, CompatVerdict } from '../src'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +/** + * Table-driven transcription of GTS spec 0.13 §4.5, "Type Schema Evolution + * Compatibility Rules". + * + * Each row states a single change between two successive definitions of one + * type identity and the verdict the spec gives for each relation. These are + * pinned here rather than left to the gts-spec conformance suite alone: the + * suite needs Python and a running server, and the rules below are the part of + * 0.13 most likely to be silently broken by a refactor of the subsumption + * engine (0.12 gave the opposite answer for several of these rows). + */ +interface Row { + change: string; + old: Record; + new: Record; + backward: CompatVerdict; + forward: CompatVerdict; + full: CompatVerdict; +} + +const OPEN = {}; +const CLOSED = { additionalProperties: false }; + +const ROWS: Row[] = [ + { + change: 'updating description/examples', + old: { required: ['a'], properties: { a: { type: 'string', description: 'first' } }, ...CLOSED }, + new: { + required: ['a'], + properties: { a: { type: 'string', description: 'second', examples: ['x'] } }, + ...CLOSED, + }, + backward: 'compatible', + forward: 'compatible', + full: 'compatible', + }, + { + change: 'adding optional property (open model)', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + new: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...OPEN }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'adding optional property (closed model)', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'adding new required property (open model)', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + new: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...OPEN }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'adding new required property (closed model)', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + new: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'removing optional property (open model)', + old: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...OPEN }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'removing optional property (closed model)', + old: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'removing required property definition (open model)', + old: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...OPEN }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'removing required property definition (closed model)', + old: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'closing an open object', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'opening a closed object', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string' } }, additionalProperties: true }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'widening a schema-valued unevaluatedProperties to fully open', + old: { + required: ['a'], + properties: { a: { type: 'string' } }, + unevaluatedProperties: { type: 'string' }, + }, + new: { required: ['a'], properties: { a: { type: 'string' } }, ...OPEN }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'changing required property to optional', + old: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'changing optional property to required', + old: { required: ['a'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + new: { required: ['a', 'b'], properties: { a: { type: 'string' }, b: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'adding new enum value', + old: { required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y'] } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y', 'z'] } }, ...CLOSED }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'removing enum value', + old: { required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y', 'z'] } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y'] } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'changing a const value', + old: { required: ['a'], properties: { a: { type: 'string', const: 'A' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', const: 'B' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'widening numeric type (integer -> number)', + old: { required: ['a'], properties: { a: { type: 'integer' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'number' } }, ...CLOSED }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'narrowing numeric type (number -> integer)', + old: { required: ['a'], properties: { a: { type: 'number' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'integer' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'relaxing constraints (increasing max)', + old: { required: ['a'], properties: { a: { type: 'string', maxLength: 10 } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', maxLength: 100 } }, ...CLOSED }, + backward: 'compatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'tightening constraints (decreasing max)', + old: { required: ['a'], properties: { a: { type: 'string', maxLength: 100 } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', maxLength: 10 } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'dropping maxLength for an enum whose members are all within it', + old: { required: ['a'], properties: { a: { type: 'string', maxLength: 100 } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', enum: ['gold', 'platinum'] } }, ...CLOSED }, + backward: 'incompatible', + forward: 'compatible', + full: 'incompatible', + }, + { + change: 'dropping maxLength for an enum with a member outside it', + old: { required: ['a'], properties: { a: { type: 'string', maxLength: 5 } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'string', enum: ['short', 'way-too-long-value'] } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'renaming property', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + new: { required: ['b'], properties: { b: { type: 'string' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, + { + change: 'changing property type (incompatible)', + old: { required: ['a'], properties: { a: { type: 'string' } }, ...CLOSED }, + new: { required: ['a'], properties: { a: { type: 'number' } }, ...CLOSED }, + backward: 'incompatible', + forward: 'incompatible', + full: 'incompatible', + }, +]; + +describe('OP#8 - Type Schema Evolution Compatibility (spec 0.13 §4.5)', () => { + ROWS.forEach((row, index) => { + test(`${row.change}: backward=${row.backward}, forward=${row.forward}, full=${row.full}`, () => { + const gts = new GTS({ validateRefs: false }); + const oldId = `gts.x.unit.compat.case${index}.v1.0~`; + const newId = `gts.x.unit.compat.case${index}.v1.1~`; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', ...row.old }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', ...row.new }); + + const result = gts.checkCompatibility(oldId, newId); + + expect({ + backward: result.backward_compatibility, + forward: result.forward_compatibility, + full: result.full_compatibility, + }).toEqual({ backward: row.backward, forward: row.forward, full: row.full }); + }); + }); +}); + +describe('OP#8 - inconclusive checks report `unknown`', () => { + test('a differing unmodeled assertion is unknown, not incompatible', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.unknown.pattern.v1.0~'; + const newId = 'gts.x.unit.unknown.pattern.v1.1~'; + + // `pattern` is a real constraint the engine does not model, so it cannot + // decide inclusion either way. + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', pattern: '^foo' } }, + additionalProperties: false, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', pattern: '^bar' } }, + additionalProperties: false, + }); + + const result = gts.checkCompatibility(oldId, newId); + + expect(result.backward_compatibility).toBe('unknown'); + expect(result.forward_compatibility).toBe('unknown'); + expect(result.full_compatibility).toBe('unknown'); + // `unknown` is not evidence of incompatibility, but it is not a pass either. + expect(result.is_fully_compatible).toBe(false); + }); + + test('a const already matching the old pattern lets the new schema drop it, forward-compatibly', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.unknown.patternconst.v1.0~'; + const newId = 'gts.x.unit.unknown.patternconst.v1.1~'; + + // `pattern` is still compared by exact equality in general (see the test + // above), but a `const`/`enum` value the new schema pins down that + // already satisfies the old pattern makes dropping the pattern itself + // harmless from the "does everything new could ever hold also satisfy + // old" angle - i.e. forward compatibility, `subsumes(oldSchema, + // newSchema)`. (Backward asks the opposite question - "does everything + // old could ever hold also satisfy new" - and stays `incompatible` + // here regardless of this fix, because narrowing to one `const` value + // legitimately excludes strings old admitted.) + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', pattern: '^[a-z]+$' } }, + additionalProperties: false, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', const: 'hello' } }, + additionalProperties: false, + }); + + const result = gts.checkCompatibility(oldId, newId); + + expect(result.forward_compatibility).toBe('compatible'); + expect(result.backward_compatibility).toBe('incompatible'); + }); + + test('a const that does not match the old pattern stays unknown, not forward-compatible', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.unknown.patternconstbad.v1.0~'; + const newId = 'gts.x.unit.unknown.patternconstbad.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', pattern: '^[a-z]+$' } }, + additionalProperties: false, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + required: ['a'], + properties: { a: { type: 'string', const: 'HELLO' } }, + additionalProperties: false, + }); + + const result = gts.checkCompatibility(oldId, newId); + + expect(result.forward_compatibility).toBe('unknown'); + expect(result.is_fully_compatible).toBe(false); + }); + + test('an unresolvable type identifier is unknown rather than incompatible', () => { + const gts = new GTS({ validateRefs: false }); + + const result = gts.checkCompatibility('gts.x.unit.unknown.missing.v1.0~', 'gts.x.unit.unknown.missing.v1.1~'); + + expect(result.backward_compatibility).toBe('unknown'); + expect(result.forward_compatibility).toBe('unknown'); + expect(result.full_compatibility).toBe('unknown'); + // One reason per missing side, each reported once. + expect(result.incompatibility_reasons).toEqual([ + 'Old type schema not found: gts.x.unit.unknown.missing.v1.0~', + 'New type schema not found: gts.x.unit.unknown.missing.v1.1~', + ]); + }); + + test('a bound that is present but not numeric is unknown', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.unknown.bound.v1.0~'; + const newId = 'gts.x.unit.unknown.bound.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string', maxLength: 10 } }, + additionalProperties: false, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string', maxLength: 'ten' } }, + additionalProperties: false, + }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); +}); + +describe('OP#8 - malformed schemas degrade instead of throwing', () => { + // Schemas are registered without JSON Schema meta-validation, so the engine + // has to survive keywords of the wrong shape. + test('a non-array enum reached through allOf does not crash the check', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.malformed.enum.v1.0~'; + const newId = 'gts.x.unit.malformed.enum.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + allOf: [{ properties: { a: { enum: ['x'] } } }, { properties: { a: { enum: 'not-an-array' } } }], + }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', properties: { a: { enum: ['x', 'y'] } } }); + + expect(() => gts.checkCompatibility(oldId, newId)).not.toThrow(); + }); + + test('a non-array enum makes the comparison inconclusive, not unconstrained', () => { + // `fixedValues()` only recognises array enums, so a malformed one used to + // read as "this schema pins nothing down" and compared as compatible. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.malformed.enumshape.v1.0~'; + const newId = 'gts.x.unit.malformed.enumshape.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'string', enum: 'open' }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'string' }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); + + test('a modeled keyword of the wrong shape makes the comparison inconclusive', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.malformed.reqshape.v1.0~'; + const newId = 'gts.x.unit.malformed.reqshape.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', required: 'a', properties: { a: {} } }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', required: ['a'], properties: { a: {} } }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); + + test('a schema that cannot be compared at all reports unknown', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.malformed.cyclic.v1.0~'; + const newId = 'gts.x.unit.malformed.cyclic.v1.1~'; + + const cyclic: any = { $$id: oldId, $$schema: DRAFT7, type: 'object', properties: {} }; + cyclic.properties.self = cyclic; // a structure JSON could never carry + + gts.register(cyclic); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', properties: { self: { type: 'string' } } }); + + const result = gts.checkCompatibility(oldId, newId); + expect(['unknown', 'incompatible']).toContain(result.full_compatibility); + }); +}); + +describe('OP#8 - assertions that are not annotations', () => { + test('a differing x-gts-ref pattern is not treated as documentation', () => { + // x-gts-ref is enforced against instances by OP#6, so two schemas whose + // reference patterns accept disjoint targets do not accept the same + // instances - the engine must not strip it along with the other x-gts-*. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.xref.evt.v1.0~'; + const newId = 'gts.x.unit.xref.evt.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + properties: { ref: { type: 'string', 'x-gts-ref': 'gts.x.unit.alpha.*' } }, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: { ref: { type: 'string', 'x-gts-ref': 'gts.x.unit.beta.*' } }, + }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); + + test('an identical x-gts-ref still compares as compatible', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.xrefsame.evt.v1.0~'; + const newId = 'gts.x.unit.xrefsame.evt.v1.1~'; + const body = { + type: 'object', + properties: { ref: { type: 'string', 'x-gts-ref': 'gts.x.unit.alpha.*' } }, + additionalProperties: false, + }; + + gts.register({ $$id: oldId, $$schema: DRAFT7, ...body }); + gts.register({ $$id: newId, $$schema: DRAFT7, ...body }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('compatible'); + }); +}); + +describe('OP#8 - unresolvable references fail closed', () => { + test('a local JSON pointer the engine cannot follow reports unknown', () => { + // Both documents look identical once `$ref` is dropped and `definitions` + // is stripped, but the pointed-at subschemas differ. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.localref.t.v1.0~'; + const newId = 'gts.x.unit.localref.t.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + definitions: { T: { type: 'string' } }, + $$ref: '#/definitions/T', + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + definitions: { T: { type: 'number' } }, + $$ref: '#/definitions/T', + }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); + + test('a $ref to an unregistered GTS type reports unknown', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.deadref.t.v1.0~'; + const newId = 'gts.x.unit.deadref.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', properties: { a: { type: 'string' } } }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string' } }, + allOf: [{ $$ref: 'gts://gts.x.unit.deadref.absent.v1~' }], + }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); + + test('a local $ref nested under `properties`, reached via a shared $defs entry, is not silently skipped', () => { + // Both documents are byte-identical once `$defs` is stripped (both use + // the exact same `properties: { x: { $ref: '#/$defs/T' } }`), so the + // `deepEqual` fast path in `subsumes()` must not be allowed to fire + // before the local ref nested under `properties.x` - whose target + // genuinely differs between the two schemas - is accounted for. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.localref.nested.v1.0~'; + const newId = 'gts.x.unit.localref.nested.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + $defs: { T: { type: 'string' } }, + properties: { x: { $ref: '#/$defs/T' } }, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + $defs: { T: { type: 'number' } }, + properties: { x: { $ref: '#/$defs/T' } }, + }); + + const result = gts.checkCompatibility(oldId, newId); + expect(result.backward_compatibility).toBe('unknown'); + expect(result.forward_compatibility).toBe('unknown'); + }); + + test('a genuinely ref-free, identical schema still takes the deepEqual fast path', () => { + // Regression guard for the fix above: schemas with no local `$ref` + // anywhere must still be recognised as trivially compatible. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.norefidentical.t.v1.0~'; + const newId = 'gts.x.unit.norefidentical.t.v1.1~'; + const body = { type: 'object', properties: { x: { type: 'string' } } }; + + gts.register({ $$id: oldId, $$schema: DRAFT7, ...body }); + gts.register({ $$id: newId, $$schema: DRAFT7, ...body }); + + const result = gts.checkCompatibility(oldId, newId); + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('compatible'); + }); +}); + +describe('OP#8 - $defs content is documentation, never compared', () => { + test('malformed content inside $defs does not force an otherwise-comparable pair to unknown', () => { + // `$defs` is annotation-kind (stripped before comparison), so a malformed + // value inside it - a number where a schema/boolean belongs - must not + // make an otherwise identical, otherwise well-formed comparison + // inconclusive: the engine never reads that content for the verdict. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.defsmalformed.t.v1.0~'; + const newId = 'gts.x.unit.defsmalformed.t.v1.1~'; + const body = { type: 'object', $defs: { Note: 1 }, properties: { a: { type: 'string' } } }; + + gts.register({ $$id: oldId, $$schema: DRAFT7, ...body }); + gts.register({ $$id: newId, $$schema: DRAFT7, ...body }); + + const result = gts.checkCompatibility(oldId, newId); + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('compatible'); + }); + + test('a genuinely malformed keyword in a compared position still forces unknown', () => { + // Narrow-scope guard: the annotation skip in `hasMalformedKeyword` must + // only cover annotation-kind keywords like `$defs`; a malformed value in + // a real, compared position (`properties`) must still degrade to unknown. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.propsmalformed.t.v1.0~'; + const newId = 'gts.x.unit.propsmalformed.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', properties: { a: 'not-a-schema' } }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', properties: { a: { type: 'string' } } }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); +}); + +describe('OP#8 - contradictory allOf branches are unsatisfiable', () => { + test('disjoint types across allOf collapse to a schema accepting nothing', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.disjoint.t.v1.0~'; + const newId = 'gts.x.unit.disjoint.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, allOf: [{ type: 'string' }, { type: 'number' }] }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'string' }); + + const result = gts.checkCompatibility(oldId, newId); + // Valid(old) is empty, so it is included in Valid(new) but not vice versa. + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('incompatible'); + expect(result.full_compatibility).toBe('incompatible'); + }); +}); + +describe('OP#8 - inclusive and exclusive bounds are the same axis', () => { + const register = (gts: GTS, id: string, bound: Record) => + gts.register({ + $$id: id, + $$schema: DRAFT7, + type: 'object', + properties: { n: { type: 'number', ...bound } }, + additionalProperties: false, + }); + + test('tightening minimum:0 to exclusiveMinimum:0 is forward compatible only', () => { + const gts = new GTS({ validateRefs: false }); + register(gts, 'gts.x.unit.bounds.excl.v1.0~', { minimum: 0 }); + register(gts, 'gts.x.unit.bounds.excl.v1.1~', { exclusiveMinimum: 0 }); + + const result = gts.checkCompatibility('gts.x.unit.bounds.excl.v1.0~', 'gts.x.unit.bounds.excl.v1.1~'); + // `x > 0` is a strict subset of `x >= 0`. + expect(result.forward_compatibility).toBe('compatible'); + expect(result.backward_compatibility).toBe('incompatible'); + }); + + test('relaxing exclusiveMaximum:10 to maximum:10 is backward compatible only', () => { + const gts = new GTS({ validateRefs: false }); + register(gts, 'gts.x.unit.bounds.incl.v1.0~', { exclusiveMaximum: 10 }); + register(gts, 'gts.x.unit.bounds.incl.v1.1~', { maximum: 10 }); + + const result = gts.checkCompatibility('gts.x.unit.bounds.incl.v1.0~', 'gts.x.unit.bounds.incl.v1.1~'); + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('incompatible'); + }); + + test('the same bound expressed identically stays fully compatible', () => { + const gts = new GTS({ validateRefs: false }); + register(gts, 'gts.x.unit.bounds.same.v1.0~', { exclusiveMinimum: 5 }); + register(gts, 'gts.x.unit.bounds.same.v1.1~', { exclusiveMinimum: 5 }); + + expect( + gts.checkCompatibility('gts.x.unit.bounds.same.v1.0~', 'gts.x.unit.bounds.same.v1.1~').full_compatibility + ).toBe('compatible'); + }); +}); + +describe('OP#8 - the keyword table is the single source of truth', () => { + test('unevaluatedProperties closes a type, on every code path that reads it', () => { + // It was previously honoured by contentModel() but invisible to the object + // guard and to the unmodeled catch-all, so closing a type this way read as + // fully compatible in both directions. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.unevald.t.v1.0~'; + const newId = 'gts.x.unit.unevald.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', properties: { a: { type: 'string' } } }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string' } }, + unevaluatedProperties: false, + }); + + const result = gts.checkCompatibility(oldId, newId); + expect(result.backward_compatibility).toBe('incompatible'); + expect(result.forward_compatibility).toBe('compatible'); + }); + + test('additionalProperties: true makes the level open even alongside a schema-valued unevaluatedProperties', () => { + // unevaluatedProperties only applies to properties that properties / + // patternProperties / additionalProperties did not already evaluate. + // additionalProperties: true evaluates every remaining property, so + // unevaluatedProperties can never actually apply here - the level is + // fully open, not partially restricted by unevaluatedProperties's schema. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.apalwaysopen.t.v1.0~'; + const newId = 'gts.x.unit.apalwaysopen.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', properties: {} }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: {}, + additionalProperties: true, + unevaluatedProperties: { type: 'number' }, + }); + + const result = gts.checkCompatibility(oldId, newId); + expect(result.forward_compatibility).toBe('compatible'); + }); + + test('an unrecognised keyword fails closed to unknown rather than being ignored', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.newkw.t.v1.0~'; + const newId = 'gts.x.unit.newkw.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'string', 'x-some-future-assertion': 'a' }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'string', 'x-some-future-assertion': 'b' }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); +}); + +describe('OP#8 - the walker distinguishes schema positions from data', () => { + test('a property whose name matches an annotation keyword is not stripped', () => { + // Inside `properties` the keys are user-chosen names. Treating `title` as + // the annotation keyword deleted the property and made the two schemas + // normalize to the same thing. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.datakw.t.v1.0~'; + const newId = 'gts.x.unit.datakw.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, type: 'object', properties: { title: { type: 'string' } } }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object', properties: { title: { type: 'number' } } }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('incompatible'); + }); + + test('annotations are still stripped where a schema is expected', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.datakw.ann.v1.0~'; + const newId = 'gts.x.unit.datakw.ann.v1.1~'; + + gts.register({ + $$id: oldId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string', title: 'One' } }, + additionalProperties: false, + }); + gts.register({ + $$id: newId, + $$schema: DRAFT7, + type: 'object', + properties: { a: { type: 'string', title: 'Two' } }, + additionalProperties: false, + }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('compatible'); + }); + + test('restating the same type across allOf branches does not narrow it', () => { + // `number` intersected with `number` must stay `number`; widening both + // sides collapsed it to `integer`. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.restate.t.v1.0~'; + const newId = 'gts.x.unit.restate.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, allOf: [{ type: 'number' }, { type: 'number' }] }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'number' }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('compatible'); + }); + + test.each([ + ['a non-array allOf', { type: 'object', allOf: { type: 'string' } }], + ['a non-string $$ref', { type: 'object', $$ref: 123 }], + ['a property schema that is not a schema', { type: 'object', properties: { name: 1 } }], + ])('%s makes the comparison inconclusive', (_label, body) => { + // These are dropped during resolution, so without an explicit check they + // read as "no constraint" and compare as compatible. + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.badcomp.t.v1.0~'; + const newId = 'gts.x.unit.badcomp.t.v1.1~'; + + gts.register({ $$id: oldId, $$schema: DRAFT7, ...(body as Record) }); + gts.register({ $$id: newId, $$schema: DRAFT7, type: 'object' }); + + expect(gts.checkCompatibility(oldId, newId).full_compatibility).toBe('unknown'); + }); +}); + +describe('OP#8 - identifiers and reference resolution', () => { + test('accepts gts:// URI form for either identifier', () => { + const gts = new GTS({ validateRefs: false }); + const oldId = 'gts.x.unit.uri.evt.v1.0~'; + const newId = 'gts.x.unit.uri.evt.v1.1~'; + + const body = { + type: 'object', + required: ['a'], + properties: { a: { type: 'string' } }, + additionalProperties: false, + }; + gts.register({ $$id: oldId, $$schema: DRAFT7, ...body }); + gts.register({ $$id: newId, $$schema: DRAFT7, ...body }); + + const result = gts.checkCompatibility(`gts://${oldId}`, `gts://${newId}`); + + expect(result.full_compatibility).toBe('compatible'); + expect(result.old).toBe(oldId); + expect(result.new).toBe(newId); + }); + + test('a widened $ref target makes the containing type backward-only compatible', () => { + const gts = new GTS({ validateRefs: false }); + + gts.register({ + $$id: 'gts.x.unit.ref.target.v1.0~', + $$schema: DRAFT7, + type: 'object', + required: ['code'], + properties: { code: { type: 'string', enum: ['a', 'b'] } }, + }); + gts.register({ + $$id: 'gts.x.unit.ref.target.v1.1~', + $$schema: DRAFT7, + type: 'object', + required: ['code'], + properties: { code: { type: 'string', enum: ['a', 'b', 'c'] } }, + }); + gts.register({ + $$id: 'gts.x.unit.ref.holder.v1.0~', + $$schema: DRAFT7, + type: 'object', + required: ['detail'], + properties: { detail: { $$ref: 'gts://gts.x.unit.ref.target.v1.0~' } }, + }); + gts.register({ + $$id: 'gts.x.unit.ref.holder.v1.1~', + $$schema: DRAFT7, + type: 'object', + required: ['detail'], + properties: { detail: { $$ref: 'gts://gts.x.unit.ref.target.v1.1~' } }, + }); + + const result = gts.checkCompatibility('gts.x.unit.ref.holder.v1.0~', 'gts.x.unit.ref.holder.v1.1~'); + + // The verdict follows the effective resolved schemas, not the identifiers. + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('incompatible'); + }); +}); + +describe('OP#8 - SchemaResolver.resolve() is bounded by a path-count budget', () => { + // `SchemaResolver.resolve()` does not cache resolved `$ref` targets across + // sibling `allOf` branches: a diamond ancestor reached through more than + // one path is re-resolved from scratch every time (caching by target id is + // unsound here - the same ancestor can legitimately be reached at + // different depths, and `resolve()`'s own `MAX_SCHEMA_DEPTH` bailout must + // be evaluated fresh at each). Without a cache, a diamond-shaped `allOf`/ + // `$ref` graph makes `resolve()` itself - independent of anything + // downstream - cost time exponential in the number of root-to-leaf paths + // through it. `resolve()` counts every `$ref` follow and `allOf` branch + // recursion against the same shared `MAX_SCHEMA_PATHS` budget (10,000) + // `resolveTraitSchemaRefs` uses, and bails out the same way this class + // already bails out on `MAX_SCHEMA_DEPTH`: marking the affected branch + // unresolved so the verdict fails closed (`unknown`, or an already- + // conservative `incompatible`), never returning a false `compatible`. + + const baseType = (id: string, extra: Record = {}) => ({ + $$id: id, + $$schema: DRAFT7, + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + ...extra, + }); + + test('a chain where every level doubles its composition paths is rejected fast, not with a multi-second/OOM resolve', () => { + // Each level's `allOf` is `[{$$ref: prev}, {$$ref: prev}]` - the same + // ancestor referenced twice - so composition paths double exactly once + // per level. 12 levels alone (2^12 = 4096 branch points, each also + // following a `$ref`) already clears the 10,000-path budget, so this + // stays small and fast even though, pre-fix, this exact shape measured + // in the tens of seconds by 30 levels. + const gts = new GTS({ validateRefs: false }); + + const prev = 'gts.x.unit.compatpathbudget.a0.v1~'; + gts.register(baseType(prev)); + + const DEPTH = 12; + let cur = prev; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.compatpathbudget.a${i}.v1~`; + gts.register(baseType(next, { allOf: [{ $$ref: `gts://${cur}` }, { $$ref: `gts://${cur}` }] })); + cur = next; + } + + const start = Date.now(); + const result = gts.checkCompatibility(cur, cur); + const elapsedMs = Date.now() - start; + + // Fail-closed: never a false `compatible` once the budget is exceeded. + expect(result.backward_compatibility).not.toBe('compatible'); + expect(result.forward_compatibility).not.toBe('compatible'); + // Well under a second - this must fail fast, not hang. + expect(elapsedMs).toBeLessThan(500); + }); + + test('a legitimate, well under-budget doubling chain still resolves to a genuine compatible verdict', () => { + // Control for the guard above, using the same doubling shape at a depth + // (8 levels, 256 paths) nowhere near the 10,000-path budget. + const gts = new GTS({ validateRefs: false }); + + const prev = 'gts.x.unit.compatpathbudgetok.a0.v1~'; + gts.register(baseType(prev)); + + const DEPTH = 8; + let cur = prev; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.compatpathbudgetok.a${i}.v1~`; + gts.register(baseType(next, { allOf: [{ $$ref: `gts://${cur}` }, { $$ref: `gts://${cur}` }] })); + cur = next; + } + + const start = Date.now(); + const result = gts.checkCompatibility(cur, cur); + const elapsedMs = Date.now() - start; + + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('compatible'); + expect(elapsedMs).toBeLessThan(500); + }); + + test('a legitimate, realistic two-ancestor diamond chain resolves correctly and quickly', () => { + // Control using the shape a real derivation hierarchy would actually + // take: level i's `allOf` reaches both level i-1 and level i-2. This + // grows far more slowly than the doubling shape above (it follows + // Fibonacci-rate growth in composition paths, not 2^n), so a + // meaningfully large hierarchy (14 levels) still resolves to a genuine + // answer well within the path budget. + const gts = new GTS({ validateRefs: false }); + + const prevA = 'gts.x.unit.compatdiamondok.a0.v1~'; + const prevB = 'gts.x.unit.compatdiamondok.b0.v1~'; + gts.register(baseType(prevA, { properties: { id: { type: 'string' }, p0: { type: 'string' } } })); + gts.register(baseType(prevB, { properties: { id: { type: 'string' }, q0: { type: 'string' } } })); + + const DEPTH = 14; + let a = prevA; + let b = prevB; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.compatdiamondok.a${i}.v1~`; + gts.register(baseType(next, { allOf: [{ $$ref: `gts://${a}` }, { $$ref: `gts://${b}` }] })); + b = a; + a = next; + } + + const start = Date.now(); + const result = gts.checkCompatibility(a, a); + const elapsedMs = Date.now() - start; + + expect(result.backward_compatibility).toBe('compatible'); + expect(result.forward_compatibility).toBe('compatible'); + expect(elapsedMs).toBeLessThan(500); + }); +}); diff --git a/tests/gts.test.ts b/tests/gts.test.ts index 82d1110..05e4b2c 100644 --- a/tests/gts.test.ts +++ b/tests/gts.test.ts @@ -1,4 +1,14 @@ -import { GTS, isValidGtsID, validateGtsID, parseGtsID, matchIDPattern, idToUUID, extractID } from '../src'; +import { + GTS, + GtsStore, + createJsonEntity, + isValidGtsID, + validateGtsID, + parseGtsID, + matchIDPattern, + idToUUID, + extractID, +} from '../src'; describe('GTS Core Operations', () => { describe('OP#1 - ID Validation', () => { @@ -46,7 +56,7 @@ describe('GTS Core Operations', () => { const result = extractID(instance); expect(result.id).toBe('gts.vendor.pkg.ns.type.v1.0'); - expect(result.is_schema).toBe(false); + expect(result.is_type_schema).toBe(false); }); test('extracts GTS ID from schema', () => { @@ -59,7 +69,7 @@ describe('GTS Core Operations', () => { const result = extractID(schema); expect(result.id).toBe('gts.vendor.pkg.ns.type.v1~'); - expect(result.is_schema).toBe(true); + expect(result.is_type_schema).toBe(true); }); test('handles GTS URI prefix', () => { @@ -71,7 +81,7 @@ describe('GTS Core Operations', () => { const result = extractID(schema); expect(result.id).toBe('gts.vendor.pkg.ns.type.v1~'); - expect(result.is_schema).toBe(true); + expect(result.is_type_schema).toBe(true); }); }); @@ -247,7 +257,7 @@ describe('GTS Store Operations', () => { }); describe('OP#8 - Compatibility Checking', () => { - test('checks backward compatibility', () => { + test('reports adding an optional property to an open model as forward-only', () => { const schemaV1 = { $$id: 'gts.test.pkg.ns.person.v1~', $$schema: 'http://json-schema.org/draft-07/schema#', @@ -275,6 +285,38 @@ describe('GTS Store Operations', () => { gts.register(schemaV2); const result = gts.checkCompatibility('gts.test.pkg.ns.person.v1~', 'gts.test.pkg.ns.person.v2~', 'backward'); + + // Spec 0.13 §4.5: the old open schema already accepted arbitrary values + // under `email`, so the added property schema is not backward compatible. + expect(result.backward_compatibility).toBe('incompatible'); + expect(result.forward_compatibility).toBe('compatible'); + expect(result.full_compatibility).toBe('incompatible'); + }); + + test('reports annotation-only changes as fully compatible', () => { + const schemaV1 = { + $$id: 'gts.test.pkg.ns.doc.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { name: { type: 'string', description: 'The name' } }, + required: ['name'], + additionalProperties: false, + }; + + const schemaV2 = { + $$id: 'gts.test.pkg.ns.doc.v2~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { name: { type: 'string', description: 'A better description' } }, + required: ['name'], + additionalProperties: false, + }; + + gts.register(schemaV1); + gts.register(schemaV2); + + const result = gts.checkCompatibility('gts.test.pkg.ns.doc.v1~', 'gts.test.pkg.ns.doc.v2~'); + expect(result.full_compatibility).toBe('compatible'); expect(result.is_fully_compatible).toBe(true); }); @@ -308,6 +350,150 @@ describe('GTS Store Operations', () => { }); }); + describe('OP#12 - derivation form', () => { + test('an allOf $ref to an unrelated type does not stand in for the chain parent', () => { + // Only a reference to the chain parent inherits its constraints. Without + // one, the derived schema has to restate them (ADR-0001 variant 2c), so + // dropping a required field and opening a closed base must fail. + gts.register({ + $$id: 'gts.test.pkg.ns.strict.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['a', 'b'], + properties: { a: { type: 'string' }, b: { type: 'string' } }, + additionalProperties: false, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.unrelated.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }); + gts.register({ + $$id: 'gts.test.pkg.ns.strict.v1~test.pkg._.lax.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['a'], + properties: { a: { type: 'string' } }, + additionalProperties: true, + allOf: [{ $$ref: 'gts://gts.test.pkg.ns.unrelated.v1~' }], + }); + + expect(gts.validateEntity('gts.test.pkg.ns.strict.v1~test.pkg._.lax.v1~').ok).toBe(false); + }); + }); + + describe('OP#12 - inheritance through a top-level $ref', () => { + test('a derived type that is exactly its parent via top-level $ref is valid', () => { + // ADR-0001 leaves the derivation body free; `{$ref: parent}` means + // "identical to the parent", which trivially satisfies derivation. + gts.register({ + $$id: 'gts.test.pkg.ns.tlbase.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['a', 'b'], + properties: { a: { type: 'string' }, b: { type: 'string' } }, + additionalProperties: false, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.tlbase.v1~test.pkg._.kid.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + $$ref: 'gts://gts.test.pkg.ns.tlbase.v1~', + }); + + expect(gts.validateEntity('gts.test.pkg.ns.tlbase.v1~test.pkg._.kid.v1~').ok).toBe(true); + }); + }); + + describe('OP#9 - a cast succeeds only if its result fits the target', () => { + beforeEach(() => { + gts.register({ + $$id: 'gts.test.pkg.ns.shape.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['a'], + properties: { a: { type: 'string' } }, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.shape.v2~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['a'], + properties: { a: { type: 'number' } }, + }); + }); + + test('fails when the casted value does not satisfy the target type', () => { + gts.register({ id: 'gts.test.pkg.ns.shape.v1~test.pkg._.bad.v1', a: 'not-a-number' }); + + const result = gts.castInstance('gts.test.pkg.ns.shape.v1~test.pkg._.bad.v1', 'gts.test.pkg.ns.shape.v2~'); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/must be number/); + }); + + test('succeeds when the casted value does satisfy the target type', () => { + gts.register({ id: 'gts.test.pkg.ns.shape.v1~test.pkg._.good.v1', a: 42 }); + + const result = gts.castInstance('gts.test.pkg.ns.shape.v1~test.pkg._.good.v1', 'gts.test.pkg.ns.shape.v2~'); + + expect(result.ok).toBe(true); + }); + }); + + describe('OP#9 - cast responses name the target consistently', () => { + test('a failed cast still reports to_type_id', () => { + const store = new GtsStore({ validateRefs: false }); + store.register( + createJsonEntity({ + $$id: 'gts.test.pkg.ns.castsrc.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ); + store.register(createJsonEntity({ id: 'gts.test.pkg.ns.castsrc.v1~test.pkg._.item.v1' })); + + // The target type is not registered, so this takes a failure path. + const result: Record = store.castInstance( + 'gts.test.pkg.ns.castsrc.v1~test.pkg._.item.v1', + 'gts.test.pkg.ns.missing.v2~' + ); + + expect(result.ok).toBe(false); + expect(result.to_type_id).toBe('gts.test.pkg.ns.missing.v2~'); + expect(result).not.toHaveProperty('to_schema_id'); + }); + }); + + describe('OP#9 - casting never lands on an abstract type', () => { + test('rejects a cast whose target is x-gts-abstract, mirroring direct instantiation', () => { + const store = new GtsStore({ validateRefs: false }); + store.register( + createJsonEntity({ + $$id: 'gts.test.pkg.ns.castabs.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ); + store.register( + createJsonEntity({ + $$id: 'gts.test.pkg.ns.castabs.v2~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + 'x-gts-abstract': true, + }) + ); + store.register(createJsonEntity({ id: 'gts.test.pkg.ns.castabs.v1~test.pkg._.item.v1' })); + + const result: Record = store.castInstance( + 'gts.test.pkg.ns.castabs.v1~test.pkg._.item.v1', + 'gts.test.pkg.ns.castabs.v2~' + ); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/abstract/i); + }); + }); + describe('OP#9 - Version Casting', () => { test('casts instance between compatible versions', () => { const schemaV1 = { @@ -333,9 +519,10 @@ describe('GTS Store Operations', () => { required: ['name'], }; + // A document carrying `$schema` is a schema, so an instance identifies + // its type through the chained `id` instead. const instance = { - gtsId: 'gts.test.pkg.ns.person.v1~test.pkg.ns.john.v1.0', - $schema: 'gts.test.pkg.ns.person.v1~', + id: 'gts.test.pkg.ns.person.v1~test.pkg.ns.john.v1.0', name: 'John', age: 30, }; @@ -348,8 +535,95 @@ describe('GTS Store Operations', () => { expect(result.ok).toBe(true); expect(result.result).toBeDefined(); - expect(result.result.gtsId).toContain('v2'); + // The target's default is materialized into the casted instance. expect(result.result.email).toBe(''); + expect(result.result.name).toBe('John'); + }); + + test('casts to a derived target that pulls its parent in through allOf', () => { + gts.register({ + $$id: 'gts.test.pkg.ns.staff.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + required: ['name'], + properties: { name: { type: 'string' }, age: { type: 'number' } }, + }); + // Derived types are `allOf: [{$ref: parent}, …]` by construction, so a + // cast that reads `properties` without resolving the ref sees nothing + // and drops every value. + gts.register({ + $$id: 'gts.test.pkg.ns.staff.v1~test.pkg._.employee.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + allOf: [ + { $$ref: 'gts://gts.test.pkg.ns.staff.v1~' }, + { type: 'object', properties: { dept: { type: 'string', default: 'unassigned' } } }, + ], + }); + gts.register({ id: 'gts.test.pkg.ns.staff.v1~test.pkg.ns.ann.v1.0', name: 'Ann', age: 41 }); + + const result = gts.castInstance( + 'gts.test.pkg.ns.staff.v1~test.pkg.ns.ann.v1.0', + 'gts.test.pkg.ns.staff.v1~test.pkg._.employee.v1~' + ); + + expect(result.ok).toBe(true); + expect(result.result).toMatchObject({ name: 'Ann', age: 41, dept: 'unassigned' }); + }); + + test('casts to a target whose allOf reaches the same shared ancestor through two branches', () => { + // Diamond-shaped hierarchy: `mid` and `sibling` both compose `ancestor`, + // and the target composes both `mid` and `sibling`. Flattening the + // target must revisit `ancestor` at most once so its property survives + // exactly once - not duplicated, not dropped - regardless of how many + // paths reach it. + gts.register({ + $$id: 'gts.test.pkg.ns.ancestor.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { shared: { type: 'string', default: 'from-ancestor' } }, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.mid.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + allOf: [{ $$ref: 'gts://gts.test.pkg.ns.ancestor.v1~' }], + properties: { fromMid: { type: 'string', default: 'mid' } }, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.sibling.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + allOf: [{ $$ref: 'gts://gts.test.pkg.ns.ancestor.v1~' }], + properties: { fromSibling: { type: 'string', default: 'sibling' } }, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.diamondtarget.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + allOf: [{ $$ref: 'gts://gts.test.pkg.ns.mid.v1~' }, { $$ref: 'gts://gts.test.pkg.ns.sibling.v1~' }], + properties: { direct: { type: 'string', default: 'direct' } }, + }); + gts.register({ + $$id: 'gts.test.pkg.ns.diamondsource.v1~', + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: {}, + }); + gts.register({ id: 'gts.test.pkg.ns.diamondsource.v1~test.pkg.ns.item.v1.0' }); + + const result = gts.castInstance( + 'gts.test.pkg.ns.diamondsource.v1~test.pkg.ns.item.v1.0', + 'gts.test.pkg.ns.diamondtarget.v1~' + ); + + expect(result.ok).toBe(true); + expect(result.result).toMatchObject({ + shared: 'from-ancestor', + fromMid: 'mid', + fromSibling: 'sibling', + direct: 'direct', + }); }); }); @@ -390,8 +664,12 @@ describe('GTS Store Operations', () => { describe('OP#11 - Attribute Access', () => { test('retrieves attribute values', () => { + // A bare, un-chained id (no `~`-marked type segment) is a prohibited + // single-segment instance id per `Gts.parseGtsID` - use the same + // chained shape as the other instance fixtures in this file. + const instanceId = 'gts.test.pkg.ns.person.v1~test.pkg.ns.john.v1.0'; const instance = { - gtsId: 'gts.test.pkg.ns.person.v1.0', + gtsId: instanceId, name: 'John Doe', address: { city: 'New York', @@ -401,19 +679,107 @@ describe('GTS Store Operations', () => { gts.register(instance); - const nameResult = gts.getAttribute('gts.test.pkg.ns.person.v1.0@name'); + const nameResult = gts.getAttribute(`${instanceId}@name`); expect(nameResult.resolved).toBe(true); expect(nameResult.value).toBe('John Doe'); - const cityResult = gts.getAttribute('gts.test.pkg.ns.person.v1.0@address.city'); + const cityResult = gts.getAttribute(`${instanceId}@address.city`); expect(cityResult.resolved).toBe(true); expect(cityResult.value).toBe('New York'); - const missingResult = gts.getAttribute('gts.test.pkg.ns.person.v1.0@missing'); + const missingResult = gts.getAttribute(`${instanceId}@missing`); expect(missingResult.resolved).toBe(false); }); }); + describe('register() rejects malformed entity ids', () => { + // A malformed id would otherwise silently break every ancestor-chain + // computation downstream (`buildSchemaChain` and friends), which then + // fail open by treating the entity as if it had no ancestors at all - + // so `register()` must reject it up front, for every entity kind and + // regardless of `validateRefs`. + test('rejects a schema id with an extra dot-segment before the version', () => { + // 5 dot-segments before `v1~` - GTS ids take exactly 4 + // (vendor.package.namespace.type). + const malformedId = 'gts.x.unit.tr.nestedorphanbug.base.v1~'; + expect(() => + gts.register({ + $$id: malformedId, + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ).toThrow(`Invalid GTS entity id: '${malformedId}'`); + }); + + test('rejects a version missing the leading v', () => { + const malformedId = 'gts.vendor.pkg.ns.type.1~'; + expect(() => + gts.register({ + $$id: malformedId, + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ).toThrow(`Invalid GTS entity id: '${malformedId}'`); + }); + + test('rejects a chained schema id missing the trailing tilde', () => { + const malformedId = 'gts.vendor.pkg.ns.type.v1'; + expect(() => + gts.register({ + $$id: malformedId, + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ).toThrow(`Invalid GTS entity id: '${malformedId}'`); + }); + + test('rejects an empty string id', () => { + expect(() => gts.register({ gtsId: '' })).toThrow("Invalid GTS entity id: ''"); + }); + }); + + describe('register() accepts anonymous instances by plain UUID (gts-spec §3.7)', () => { + // §3.7 permits a non-schema instance to be identified by a plain UUID + // in its `id` field, resolving its schema via a separate `type` field + // rather than by the id's own GTS-chain shape - register() must accept + // this shape instead of rejecting it as a malformed GTS id. + test('accepts a non-schema instance with a plain UUID id and a `type` field', () => { + const uuidId = '7a1d2f34-5678-49ab-9012-abcdef123456'; + expect(() => + gts.register({ + type: 'gts.x.test6anon.events.type.v1~x.commerce.orders.order_placed.v1.0~', + id: uuidId, + tenantId: '11111111-2222-3333-8444-555555555555', + occurredAt: '2025-09-20T18:35:00Z', + payload: { orderId: 'af0e3c1b-8f1e-4a27-9a9b-b7b9b70c1f01' }, + }) + ).not.toThrow(); + }); + + test('still rejects a SCHEMA whose id is a plain UUID (not a valid GTS Type id)', () => { + // The UUID exception is instance-only - a schema must always carry a + // well-formed GTS Type ID. + const uuidId = '7a1d2f34-5678-49ab-9012-abcdef123456'; + expect(() => + gts.register({ + $$id: uuidId, + $$schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }) + ).toThrow(`Invalid GTS entity id: '${uuidId}'`); + }); + + test('still rejects an id that is neither a valid GTS id nor a valid UUID', () => { + const malformedId = 'not-a-valid-id-at-all'; + expect(() => + gts.register({ + gtsId: malformedId, + name: 'irrelevant', + }) + ).toThrow(`Invalid GTS entity id: '${malformedId}'`); + }); + }); + // x-gts-ref combinator tests (oneOf/anyOf/allOf) are in the canonical gts-spec test suite describe('OP#12 - Wildcard Validation (v0.7)', () => { @@ -445,7 +811,7 @@ describe('GTS Store Operations', () => { type: 'object', }; const result = extractID(schema); - expect(result.is_schema).toBe(true); + expect(result.is_type_schema).toBe(true); }); test('does not detect schema without $schema field', () => { @@ -455,7 +821,7 @@ describe('GTS Store Operations', () => { properties: {}, }; const result = extractID(notSchema); - expect(result.is_schema).toBe(false); + expect(result.is_type_schema).toBe(false); }); test('detects schema with GTS $schema reference', () => { @@ -465,29 +831,29 @@ describe('GTS Store Operations', () => { type: 'object', }; const result = extractID(schema); - expect(result.is_schema).toBe(true); + expect(result.is_type_schema).toBe(true); }); }); describe('OP#14 - Schema ID Extraction (v0.7)', () => { - test('extracts schema_id from chain for instances without explicit schema field', () => { + test('extracts type_id from chain for instances without explicit schema field', () => { const instance = { gtsId: 'gts.vendor.pkg.ns.type.v1~vendor.pkg.ns.instance.v1.0', data: 'test', }; const result = extractID(instance); - // v0.7: schema_id is extracted from the chain - expect(result.schema_id).toBe('gts.vendor.pkg.ns.type.v1~'); + // type_id is extracted from the chain + expect(result.type_id).toBe('gts.vendor.pkg.ns.type.v1~'); }); - test('extracts schema_id from chained instance ID', () => { + test('extracts type_id from chained instance ID', () => { const instance = { gtsId: 'gts.vendor.pkg.ns.type.v1~vendor.pkg.ns.instance.v1.0', $schema: 'gts.vendor.pkg.ns.type.v1~', data: 'test', }; const result = extractID(instance); - expect(result.schema_id).toBe('gts.vendor.pkg.ns.type.v1~'); + expect(result.type_id).toBe('gts.vendor.pkg.ns.type.v1~'); }); test('extracts parent type from derived schema chain', () => { @@ -497,7 +863,7 @@ describe('GTS Store Operations', () => { type: 'object', }; const result = extractID(schema); - expect(result.schema_id).toBe('gts.x.core.events.type.v1~'); + expect(result.type_id).toBe('gts.x.core.events.type.v1~'); }); }); diff --git a/tests/id-patterns.test.ts b/tests/id-patterns.test.ts new file mode 100644 index 0000000..c91779b --- /dev/null +++ b/tests/id-patterns.test.ts @@ -0,0 +1,76 @@ +import { GTS, matchIDPattern, idToUUID } from '../src'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +describe('OP#4 - wildcard version matching', () => { + test('a major-only version wildcard matches any minor of that major', () => { + expect(matchIDPattern('gts.x.pkg.ns.type.v0.2~', 'gts.x.pkg.ns.type.v0.*').match).toBe(true); + }); + + test('a major-only version wildcard rejects a different major', () => { + // Regression guard: `v0` must not be read as "no version specified". + expect(matchIDPattern('gts.x.pkg.ns.type.v1.2~', 'gts.x.pkg.ns.type.v0.*').match).toBe(false); + }); + + test('an omitted minor version in the pattern matches any minor', () => { + expect(matchIDPattern('gts.x.pkg.ns.type.v1.5~', 'gts.x.pkg.ns.type.v1~').match).toBe(true); + expect(matchIDPattern('gts.x.pkg.ns.type.v2~', 'gts.x.pkg.ns.type.v1~').match).toBe(false); + }); +}); + +describe('OP#4 / OP#10 - chain-suffix wildcards', () => { + /* + * The two operations disagree in the gts-spec 0.13 suite and both verdicts + * are asserted there, so the divergence is deliberate and pinned here: + * + * OP#4 (§10 prose) `type.v1~*` matches `type.v1~` itself. + * OP#10 (§10 examples) a collection query returns only derived identifiers. + * + * In gts-spec 0.12 both were exclusive; 0.13 flipped only the OP#4 + * assertions and left the OP#10 expectations unchanged. + */ + test('pattern matching treats a chain-suffix wildcard as including the type itself', () => { + expect(matchIDPattern('gts.vendor.pkg.ns.type.v0~', 'gts.vendor.pkg.ns.type.v0~*').match).toBe(true); + expect(matchIDPattern('gts.vendor.pkg.ns.type.v0.1~', 'gts.vendor.pkg.ns.type.v0~*').match).toBe(true); + }); + + test('a collection query with a chain-suffix wildcard returns only derived identifiers', () => { + const gts = new GTS({ validateRefs: false }); + const ids = [ + 'gts.x.unit.wc.message.v1.0~', + 'gts.x.unit.wc.message.v1.0~x.unit._.system.v1.0~', + 'gts.x.unit.wc.message.v1.1~', + 'gts.x.unit.wc.message.v1.1~x.unit._.user.v1.1~', + ]; + ids.forEach((id) => gts.register({ $$id: id, $$schema: DRAFT7, type: 'object' })); + + // Only the type derived from v1.0, not v1.0 itself. + const derivedFromV10 = gts.query('gts.x.unit.wc.message.v1.0~*'); + expect(derivedFromV10.count).toBe(1); + expect(derivedFromV10.items[0].$$id).toBe('gts.x.unit.wc.message.v1.0~x.unit._.system.v1.0~'); + + // Any minor of v1: both derived types, neither base. + expect(gts.query('gts.x.unit.wc.message.v1~*').count).toBe(2); + + // A plain token wildcard is unaffected and still matches everything. + expect(gts.query('gts.x.unit.wc.message.*').count).toBe(4); + }); +}); + +describe('OP#5 - ID to UUID mapping', () => { + test('derives a deterministic UUID for an identifier with no UUID tail', () => { + const first = idToUUID('gts.x.test5.events.type.v1~abc.app._.custom_event.v1.2'); + const second = idToUUID('gts.x.test5.events.type.v1~abc.app._.custom_event.v1.2'); + + expect(first.uuid).toBe(second.uuid); + expect(first.uuid).toBe('c7f8cca7-3af6-58af-b72b-3febfd93f1a8'); + }); + + test('returns the embedded UUID of a combined anonymous instance verbatim', () => { + // The tail already is the instance identity; deriving a second UUID from + // the string would discard it. + const id = 'gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456'; + + expect(idToUUID(id).uuid).toBe('7a1d2f34-5678-49ab-9012-abcdef123456'); + }); +}); diff --git a/tests/modifiers.test.ts b/tests/modifiers.test.ts new file mode 100644 index 0000000..69d54e3 --- /dev/null +++ b/tests/modifiers.test.ts @@ -0,0 +1,299 @@ +import { GTS, GtsModifiers } from '../src'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +describe('GTS Type Schema Modifiers (spec §9.11)', () => { + describe('reading the modifiers', () => { + test('only the literal `true` enables a modifier', () => { + expect(GtsModifiers.isFinal({ 'x-gts-final': true })).toBe(true); + expect(GtsModifiers.isFinal({ 'x-gts-final': false })).toBe(false); + expect(GtsModifiers.isFinal({})).toBe(false); + // A non-boolean is invalid, and must not be read as truthy. + expect(GtsModifiers.isFinal({ 'x-gts-final': 'yes' })).toBe(false); + + expect(GtsModifiers.isAbstract({ 'x-gts-abstract': true })).toBe(true); + expect(GtsModifiers.isAbstract({ 'x-gts-abstract': false })).toBe(false); + expect(GtsModifiers.isAbstract({})).toBe(false); + }); + }); + + describe('validateDeclaration', () => { + test('accepts absent, false and true declarations', () => { + expect(GtsModifiers.validateDeclaration({})).toBeNull(); + expect(GtsModifiers.validateDeclaration({ 'x-gts-final': true })).toBeNull(); + expect(GtsModifiers.validateDeclaration({ 'x-gts-abstract': true })).toBeNull(); + expect(GtsModifiers.validateDeclaration({ 'x-gts-final': true, 'x-gts-abstract': false })).toBeNull(); + }); + + test('rejects non-boolean values', () => { + expect(GtsModifiers.validateDeclaration({ 'x-gts-final': 'yes' })).toContain('x-gts-final'); + expect(GtsModifiers.validateDeclaration({ 'x-gts-abstract': 1 })).toContain('x-gts-abstract'); + }); + + test('rejects the meaningless final + abstract combination', () => { + const error = GtsModifiers.validateDeclaration({ 'x-gts-final': true, 'x-gts-abstract': true }); + expect(error).toMatch(/must not declare both/); + }); + }); + + describe('findMisplacedKeywords', () => { + test('accepts all four keywords at the document top level', () => { + expect( + GtsModifiers.findMisplacedKeywords({ + $$id: 'gts.x.unit.mod.top.v1~', + type: 'object', + 'x-gts-final': true, + 'x-gts-traits-schema': { type: 'object', properties: { a: { type: 'string' } } }, + 'x-gts-traits': { a: 'value' }, + }) + ).toEqual([]); + }); + + test('rejects a modifier nested in an allOf entry', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + allOf: [{ $$ref: 'gts://gts.x.unit.mod.base.v1~' }, { type: 'object', 'x-gts-final': true }], + }); + expect(found).toEqual(['allOf[1]/x-gts-final']); + }); + + test('rejects a keyword nested in a property subschema', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + properties: { nested: { type: 'object', 'x-gts-traits': { topicRef: 'x' } } }, + }); + expect(found).toEqual(['properties/nested/x-gts-traits']); + }); + + test('rejects a keyword nested in a definitions entry', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + definitions: { Sub: { type: 'object', 'x-gts-abstract': true } }, + }); + expect(found).toEqual(['definitions/Sub/x-gts-abstract']); + }); + + test('reports every misplacement, not just the first', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + allOf: [{ 'x-gts-abstract': true }], + properties: { nested: { 'x-gts-final': true } }, + }); + expect(found).toHaveLength(2); + }); + + test('rejects a keyword nested under contains', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'array', + contains: { 'x-gts-final': true }, + }); + expect(found).toEqual(['contains/x-gts-final']); + }); + + test('rejects a keyword nested under propertyNames', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + propertyNames: { 'x-gts-final': true }, + }); + expect(found).toEqual(['propertyNames/x-gts-final']); + }); + + test('rejects a keyword nested under additionalItems', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'array', + additionalItems: { 'x-gts-final': true }, + }); + expect(found).toEqual(['additionalItems/x-gts-final']); + }); + + test('rejects a keyword nested in a dependencies entry using the schema-dependency form', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + dependencies: { n: { 'x-gts-final': true } }, + }); + expect(found).toEqual(['dependencies/n/x-gts-final']); + }); + + test('does not scan a dependencies entry using the property-dependency (array) form', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + dependencies: { n: ['a', 'b'] }, + }); + expect(found).toEqual([]); + }); + + test('rejects a keyword nested in a dependentSchemas entry', () => { + const found = GtsModifiers.findMisplacedKeywords({ + type: 'object', + dependentSchemas: { creditCard: { 'x-gts-final': true } }, + }); + expect(found).toEqual(['dependentSchemas/creditCard/x-gts-final']); + }); + + test('fails closed when a document is nested too deeply to scan', () => { + // The recursion guard must not let a subtree through unchecked: a + // keyword hidden below the limit would otherwise be silently accepted. + let deep: Record = { 'x-gts-final': true }; + for (let i = 0; i < 80; i++) { + deep = { properties: { nested: deep } }; + } + + const found = GtsModifiers.findMisplacedKeywords({ type: 'object', ...deep }); + expect(found.length).toBeGreaterThan(0); + expect(found[0]).toMatch(/nesting exceeds/); + }); + + test('does not descend into the values of the top-level keywords', () => { + // A trait *value* that happens to be keyed like a keyword is ordinary + // data, and a trait-schema body may legitimately carry x-gts-* members. + expect( + GtsModifiers.findMisplacedKeywords({ + type: 'object', + 'x-gts-traits': { 'x-gts-final': 'just a string value' }, + 'x-gts-traits-schema': { type: 'object', properties: { 'x-gts-abstract': { type: 'boolean' } } }, + }) + ).toEqual([]); + }); + + test('does not flag a property literally named like a document-level keyword', () => { + // `x-gts-abstract` here is a *property name* chosen by the schema + // author, not an occurrence of the keyword - it sits in a data position + // (a `properties` map key), not a schema position. + expect( + GtsModifiers.findMisplacedKeywords({ + type: 'object', + properties: { 'x-gts-abstract': { type: 'string' } }, + }) + ).toEqual([]); + }); + + test('does not flag a property named like a keyword nested in a definitions/$defs map', () => { + expect( + GtsModifiers.findMisplacedKeywords({ + type: 'object', + definitions: { Sub: { type: 'object', properties: { 'x-gts-final': { type: 'boolean' } } } }, + }) + ).toEqual([]); + + expect( + GtsModifiers.findMisplacedKeywords({ + type: 'object', + $defs: { Sub: { type: 'object', properties: { 'x-gts-traits': { type: 'string' } } } }, + }) + ).toEqual([]); + }); + }); +}); + +describe('x-gts-final / x-gts-abstract enforcement through the registry', () => { + const base = (id: string, extra: Record = {}) => ({ + $$id: id, + $$schema: DRAFT7, + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + ...extra, + }); + + const derived = (id: string, baseRef: string, extra: Record = {}) => ({ + $$id: id, + $$schema: DRAFT7, + type: 'object', + allOf: [{ $$ref: `gts://${baseRef}` }, { type: 'object' }], + ...extra, + }); + + test('a final base cannot be extended', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.fin.v1~', { 'x-gts-final': true })); + gts.register(derived('gts.x.unit.fa.fin.v1~x.unit._.kid.v1~', 'gts.x.unit.fa.fin.v1~')); + + const result = gts.validateEntity('gts.x.unit.fa.fin.v1~x.unit._.kid.v1~'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/final/); + }); + + test('finality does not propagate to siblings of the final type', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.sib.v1~')); + gts.register(derived('gts.x.unit.fa.sib.v1~x.unit._.fin.v1~', 'gts.x.unit.fa.sib.v1~', { 'x-gts-final': true })); + gts.register(derived('gts.x.unit.fa.sib.v1~x.unit._.other.v1~', 'gts.x.unit.fa.sib.v1~')); + + expect(gts.validateEntity('gts.x.unit.fa.sib.v1~x.unit._.other.v1~').ok).toBe(true); + }); + + test('a mid-chain final type blocks its own descendants', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.mid.v1~')); + gts.register(derived('gts.x.unit.fa.mid.v1~x.unit._.m.v1~', 'gts.x.unit.fa.mid.v1~', { 'x-gts-final': true })); + gts.register( + derived('gts.x.unit.fa.mid.v1~x.unit._.m.v1~x.unit._.leaf.v1~', 'gts.x.unit.fa.mid.v1~x.unit._.m.v1~') + ); + + expect(gts.validateEntity('gts.x.unit.fa.mid.v1~x.unit._.m.v1~x.unit._.leaf.v1~').ok).toBe(false); + }); + + test('x-gts-final: false is a no-op', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.nofin.v1~', { 'x-gts-final': false })); + gts.register(derived('gts.x.unit.fa.nofin.v1~x.unit._.kid.v1~', 'gts.x.unit.fa.nofin.v1~')); + + expect(gts.validateEntity('gts.x.unit.fa.nofin.v1~x.unit._.kid.v1~').ok).toBe(true); + }); + + test('an abstract type rejects direct instances but allows derivation', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.abs.v1~', { 'x-gts-abstract': true })); + gts.register(derived('gts.x.unit.fa.abs.v1~x.unit._.concrete.v1~', 'gts.x.unit.fa.abs.v1~')); + + // Derivation from an abstract base is exactly what it is for. + expect(gts.validateEntity('gts.x.unit.fa.abs.v1~x.unit._.concrete.v1~').ok).toBe(true); + + gts.register({ id: 'gts.x.unit.fa.abs.v1~x.unit._.direct.v1' }); + const direct = gts.validateInstance('gts.x.unit.fa.abs.v1~x.unit._.direct.v1'); + expect(direct.ok).toBe(false); + expect(direct.error).toMatch(/abstract/); + + // An instance of the concrete derived type is fine. + gts.register({ id: 'gts.x.unit.fa.abs.v1~x.unit._.concrete.v1~x.unit._.ok.v1' }); + expect(gts.validateInstance('gts.x.unit.fa.abs.v1~x.unit._.concrete.v1~x.unit._.ok.v1').ok).toBe(true); + }); + + test('an abstract type rejects a combined anonymous instance', () => { + const gts = new GTS({ validateRefs: false }); + gts.register(base('gts.x.unit.fa.anon.v1~', { 'x-gts-abstract': true })); + const anonId = 'gts.x.unit.fa.anon.v1~c1d2e3f4-5678-4abc-8def-aabbccddeeff'; + gts.register({ id: anonId, type: 'gts.x.unit.fa.anon.v1~' }); + + expect(gts.validateInstance(anonId).ok).toBe(false); + }); + + test('validateEntity enforces keyword placement, like registration does', () => { + // §9.11.5: placement is always enforced on the explicit validation + // endpoints, so /validate-type-schema must not accept what + // /entities?validate=true rejects. + const gts = new GTS({ validateRefs: false }); + gts.register({ + $$id: 'gts.x.unit.fa.place.v1~', + $$schema: DRAFT7, + type: 'object', + allOf: [{ type: 'object', 'x-gts-abstract': true }], + }); + + const result = gts.validateEntity('gts.x.unit.fa.place.v1~'); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/top level/); + }); + + test('a malformed modifier declaration is rejected synchronously at registration, not only at validateEntity', () => { + // §9.11.1 unqualifiedly requires registration itself to reject this - the + // CLI's only ingestion path is `register()`, which never called + // `validateEntity()`, so this must fail here rather than needing a + // separate validation step to be caught. + const gts = new GTS({ validateRefs: false }); + + expect(() => gts.register(base('gts.x.unit.fa.bad2.v1~', { 'x-gts-final': true, 'x-gts-abstract': true }))).toThrow( + /must not declare both/ + ); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..9a2d5e8 --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,102 @@ +import { GtsServer } from '../src/server/server'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +describe('POST /type-schemas', () => { + test('rejects a type_id that does not end with the required "~" (spec 2.1 / 11.1 Rule C.1)', async () => { + const server = new GtsServer({ host: '127.0.0.1', port: 0, verbose: 0 }); + + const response = await server.instance.inject({ + method: 'POST', + url: '/type-schemas', + payload: { + // Missing the trailing '~' that a GTS Type Identifier must have. + type_id: 'gts.x.unit.srv.notype.v1', + type_schema: { $$schema: DRAFT7, type: 'object' }, + }, + }); + + expect(response.statusCode).toBe(422); + const body = JSON.parse(response.body); + expect(body.ok).toBe(false); + expect(body.error).toMatch(/type_id/); + + await server.stop(); + }); + + test('accepts a well-formed type_id ending with "~"', async () => { + const server = new GtsServer({ host: '127.0.0.1', port: 0, verbose: 0 }); + + const response = await server.instance.inject({ + method: 'POST', + url: '/type-schemas', + payload: { + type_id: 'gts.x.unit.srv.goodtype.v1~', + type_schema: { $$schema: DRAFT7, type: 'object' }, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.ok).toBe(true); + + await server.stop(); + }); +}); + +describe('GET /openapi', () => { + test('documents every route actually registered on the server', async () => { + const server = new GtsServer({ host: '127.0.0.1', port: 0, verbose: 0 }); + await server.instance.ready(); + + // Fastify (v5) does not expose a plain list-of-routes API: `printRoutes()` + // only returns a pretty-printed tree, and `hasRoute()` checks one route + // at a time. So this list is hard-coded and MUST be kept in sync with the + // `this.fastify.get/post(...)` calls in `GtsServer.registerRoutes()` + // (src/server/server.ts). Each entry is cross-checked against + // `hasRoute()` below, so a stale entry here fails the test rather than + // silently drifting from the real route table. + const registeredRoutes: Array<{ method: 'GET' | 'POST'; url: string; openApiPath: string }> = [ + { method: 'GET', url: '/health', openApiPath: '/health' }, + { method: 'GET', url: '/entities', openApiPath: '/entities' }, + { method: 'POST', url: '/entities', openApiPath: '/entities' }, + { method: 'GET', url: '/entities/:id', openApiPath: '/entities/{id}' }, + { method: 'POST', url: '/entities/bulk', openApiPath: '/entities/bulk' }, + { method: 'POST', url: '/type-schemas', openApiPath: '/type-schemas' }, + { method: 'GET', url: '/validate-id', openApiPath: '/validate-id' }, + { method: 'POST', url: '/extract-id', openApiPath: '/extract-id' }, + { method: 'GET', url: '/parse-id', openApiPath: '/parse-id' }, + { method: 'GET', url: '/match-id-pattern', openApiPath: '/match-id-pattern' }, + { method: 'GET', url: '/uuid', openApiPath: '/uuid' }, + { method: 'POST', url: '/validate-instance', openApiPath: '/validate-instance' }, + { method: 'GET', url: '/resolve-relationships', openApiPath: '/resolve-relationships' }, + { method: 'GET', url: '/compatibility', openApiPath: '/compatibility' }, + { method: 'POST', url: '/cast', openApiPath: '/cast' }, + { method: 'GET', url: '/query', openApiPath: '/query' }, + { method: 'GET', url: '/attr', openApiPath: '/attr' }, + { method: 'POST', url: '/validate-type-schema', openApiPath: '/validate-type-schema' }, + { method: 'POST', url: '/validate-entity', openApiPath: '/validate-entity' }, + { method: 'GET', url: '/openapi', openApiPath: '/openapi' }, + ]; + + for (const route of registeredRoutes) { + expect(server.instance.hasRoute({ method: route.method, url: route.url })).toBe(true); + } + + const response = await server.instance.inject({ method: 'GET', url: '/openapi' }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + const paths = body.paths; + + for (const route of registeredRoutes) { + expect(paths).toHaveProperty(route.openApiPath); + } + + // Regression: the reported version must track package.json, not a + // hard-coded literal that can drift from the published version. + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(body.info.version).toBe(require('../package.json').version); + + await server.stop(); + }); +}); diff --git a/tests/traits.test.ts b/tests/traits.test.ts new file mode 100644 index 0000000..ccf021f --- /dev/null +++ b/tests/traits.test.ts @@ -0,0 +1,1716 @@ +import { GTS } from '../src'; + +const DRAFT7 = 'http://json-schema.org/draft-07/schema#'; + +/** + * OP#13 - trait merge and completeness (spec §9.7.5, ADR-0002/0003/0004). + * + * The document-level trait keywords always sit at the schema top level, so the + * helpers below place them there rather than inside the `allOf` overlay. + */ +function baseType(id: string, topLevel: Record = {}) { + return { + $$id: id, + $$schema: DRAFT7, + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + ...topLevel, + }; +} + +function derivedType(id: string, baseId: string, topLevel: Record = {}) { + return { + $$id: id, + $$schema: DRAFT7, + type: 'object', + allOf: [{ $$ref: `gts://${baseId}` }, { type: 'object' }], + ...topLevel, + }; +} + +describe('OP#13 - trait value merge is RFC 7396 JSON Merge Patch', () => { + test('null deletes an inherited value, and the schema default re-applies', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nulldef.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { retention: { type: 'string', default: 'P7D' } }, + required: ['retention'], + }, + 'x-gts-traits': { retention: 'P30D' }, + }) + ); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { retention: null } })); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('null deleting a required trait with no default fails for a concrete type', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nullreq.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { topicRef: { type: 'string' } }, + required: ['topicRef'], + }, + 'x-gts-traits': { topicRef: 'events' }, + }) + ); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { topicRef: null } })); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('object-valued traits merge recursively, preserving keys the descendant omits', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nested.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { + routing: { + type: 'object', + properties: { topic: { type: 'string' }, partitionKey: { type: 'string' } }, + required: ['topic', 'partitionKey'], + }, + }, + required: ['routing'], + }, + 'x-gts-traits': { routing: { topic: 'events', partitionKey: 'userId' } }, + }) + ); + // Overrides only `topic`; `partitionKey` must survive or `required` fails. + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { routing: { topic: 'orders' } } })); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('arrays replace wholesale rather than concatenating', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.arr.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + // maxItems 3 admits the base value and the descendant value, but not a + // concatenation of the two - so passing proves replacement. + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { tags: { type: 'array', items: { type: 'string' }, maxItems: 3 } }, + required: ['tags'], + }, + 'x-gts-traits': { tags: ['a', 'b', 'c'] }, + }) + ); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { tags: ['only'] } })); + + expect(gts.validateEntity(baseId).ok).toBe(true); + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('a descendant may restate an inherited value idempotently', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.idem.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { retention: { type: 'string' } }, + required: ['retention'], + }, + 'x-gts-traits': { retention: 'P30D' }, + }) + ); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { retention: 'P30D' } })); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); +}); + +describe('OP#13 - locking is `const`, not a bespoke immutability rule (ADR-0004)', () => { + const schemaWithLock = { + type: 'object', + properties: { indexed: { type: 'boolean', const: true }, topicRef: { type: 'string' } }, + required: ['indexed'], + }; + + test('a descendant overriding a const-locked trait fails', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.lock.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': schemaWithLock, 'x-gts-traits': { indexed: true } })); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { indexed: false } })); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('a descendant may freely override a trait that is not locked', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.free.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': schemaWithLock, + 'x-gts-traits': { indexed: true, topicRef: 'audit' }, + }) + ); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { topicRef: 'notification' } })); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); +}); + +describe('OP#13 - completeness is keyed on x-gts-abstract (ADR-0003)', () => { + const requiresPriority = { + 'x-gts-traits-schema': { + type: 'object', + properties: { priority: { type: 'integer' } }, + required: ['priority'], + }, + }; + + test('a concrete type with an unresolved required trait fails', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.concrete.v1~'; + gts.register(baseType(baseId, requiresPriority)); + + expect(gts.validateEntity(baseId).ok).toBe(false); + }); + + test('an abstract type with the same unresolved trait passes', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.abstract.v1~'; + gts.register(baseType(baseId, { ...requiresPriority, 'x-gts-abstract': true })); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('a concrete descendant of an abstract base must close the gap', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.closegap.v1~'; + const openKid = `${baseId}x.unit._.open.v1~`; + const closedKid = `${baseId}x.unit._.closed.v1~`; + + gts.register(baseType(baseId, { ...requiresPriority, 'x-gts-abstract': true })); + gts.register(derivedType(openKid, baseId)); + gts.register(derivedType(closedKid, baseId, { 'x-gts-traits': { priority: 5 } })); + + expect(gts.validateEntity(openKid).ok).toBe(false); + expect(gts.validateEntity(closedKid).ok).toBe(true); + }); + + test('a trait-schema default satisfies the completeness check', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.default.v1~'; + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { priority: { type: 'integer', default: 3 } }, + required: ['priority'], + }, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); +}); + +describe('OP#13 - boolean trait schemas (ADR-0002)', () => { + test('`false` permits a descendant that declares no traits', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.false.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': false })); + gts.register(derivedType(kidId, baseId)); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('`false` rejects any descendant that declares traits', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.falsetr.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': false })); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { retention: 'P30D' } })); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('`false` rejects traits on an abstract descendant too', () => { + // Prohibition bans traits across the whole subtree; it is not a + // completeness rule, so the abstract exemption must not bypass it. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.falseabs.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': false })); + gts.register(derivedType(kidId, baseId, { 'x-gts-abstract': true, 'x-gts-traits': { retention: 'P30D' } })); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('`true` permits arbitrary traits', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.true.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': true })); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { anything: 42, other: 'value' } })); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('an array-shaped `x-gts-traits-schema` is rejected as malformed', () => { + // A JSON Schema subschema must be an object or a boolean; an array is + // neither. `typeof [] === 'object'` lets it slip past a naive object + // check, and Ajv would otherwise silently treat it as a permissive + // object-shaped schema with no recognized keywords. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.arrayschema.v1~'; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': [1, 2], 'x-gts-traits': { anything: 'whatever' } })); + + const result = gts.validateEntity(baseId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/x-gts-traits-schema.*must be an object subschema or a boolean/); + }); + + test('a string-shaped `x-gts-traits-schema` is rejected as malformed too', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.stringschema.v1~'; + + gts.register(baseType(baseId, { 'x-gts-traits-schema': 'not-a-schema' })); + + const result = gts.validateEntity(baseId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/x-gts-traits-schema.*must be an object subschema or a boolean/); + }); + + test('trait values with no trait-schema anywhere in the chain are rejected', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.noschema.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register(baseType(baseId)); + gts.register(derivedType(kidId, baseId, { 'x-gts-traits': { retention: 'P30D' } })); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); +}); + +describe('OP#13 - the effective trait schema must stay satisfiable', () => { + test('a descendant may narrow an inherited trait', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.narrow.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string' } } }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string', maxLength: 8 } } }, + 'x-gts-abstract': true, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('a descendant redeclaring a trait with a disjoint type fails, even when abstract', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.conflict.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['retention'], + properties: { retention: { type: 'string' } }, + }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'integer' } } }, + 'x-gts-abstract': true, + }) + ); + + // Satisfiability is a property of the composed schema, so the abstract + // exemption (which covers completeness only) does not hide it. The base + // branch requires `retention`, so `allOf` semantics make it mandatory + // overall even though the descendant branch does not restate `required`. + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/cannot be satisfied/); + }); + + test('a redeclared trait with a disjoint type is unsatisfiable even though the property is optional in every branch', () => { + // Neither branch requires `retention` - but gts-rust's own + // `declared_schema`/`check_accepted_set_inclusion` never gates on + // required-ness: redeclaring `retention` replaces its base declaration + // wholesale, and `Valid(descendant) subset-of Valid(ancestor)` fails once + // any instance carrying `retention` as an integer is admitted by the + // descendant conjunct but rejected by the ancestor's `string` conjunct. + // (An earlier round of this refactor gated this check on required-ness + // and asserted `ok: true` here; that gate was an unfaithful divergence + // from gts-rust and has been removed - see ADR/session notes.) + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.optconflict.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string' } } }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'integer' } } }, + 'x-gts-abstract': true, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/cannot be satisfied|not a valid narrowing/); + }); + + test('sibling allOf branches may reference the same trait schema', () => { + // Cycle detection tracks the active recursion path; two siblings pointing + // at one common trait schema is reuse, not recursion. + const gts = new GTS({ validateRefs: false }); + const commonId = 'gts.x.unit.tr.common.v1~'; + const baseId = 'gts.x.unit.tr.siblings.v1~'; + + gts.register(baseType(commonId, { type: 'object', properties: { k: { type: 'string' } } })); + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { allOf: [{ $$ref: `gts://${commonId}` }, { $$ref: `gts://${commonId}` }] }, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('a genuinely recursive trait schema is still rejected', () => { + const gts = new GTS({ validateRefs: false }); + const selfId = 'gts.x.unit.tr.selfref.v1~'; + + gts.register(baseType(selfId, { 'x-gts-traits-schema': { $$ref: `gts://${selfId}` } })); + + expect(gts.validateEntity(selfId).ok).toBe(false); + }); + + test('abstract types are not exempt from an impossible const across the chain', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.constclash.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', required: ['k'], properties: { k: { const: 'a' } } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { k: { const: 'b' } } }, + }) + ); + + // The base branch requires `k`, so `allOf` semantics make it mandatory + // overall even though the descendant branch does not restate `required`. + // The message now comes from the declared-schema accepted-set-inclusion + // check (gts-rust's `check_accepted_set_inclusion`, reused here via + // `GtsCompatibility.compareSchemas`) rather than the old bespoke + // `findValueConflict` walker, so the wording changed from "no value + // satisfies" to this check's own "not a valid narrowing" phrasing - the + // rejected outcome is unchanged. + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not a valid narrowing/); + }); + + test('a crossed const across the chain is unsatisfiable even though the property is optional in every branch', () => { + // Neither branch requires `k` - but as with the disjoint-type case above, + // gts-rust's accepted-set-inclusion check compares the declared schema of + // every property either side declares, regardless of required-ness: the + // descendant conjunct's `const: 'b'` is not included in the ancestor + // conjunct's `const: 'a'`, so inclusion fails. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.optconstclash.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { k: { const: 'a' } } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { k: { const: 'b' } } }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + }); + + test('abstract types are not exempt from crossed bounds across the chain', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.boundclash.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['n'], + properties: { n: { type: 'integer', minimum: 10 } }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { n: { type: 'integer', maximum: 5 } } }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('exclusive and inclusive bounds that cross are detected', () => { + // `exclusiveMinimum: 10` and `maximum: 10` share no value; comparing raw + // minimum against raw maximum missed it. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.exclbound.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', required: ['n'], properties: { n: { exclusiveMinimum: 10 } } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', required: ['n'], properties: { n: { maximum: 10 } } }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('bounds that merely narrow are still satisfiable', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.okbound.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { n: { minimum: 0, maximum: 100 } } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { n: { minimum: 10, maximum: 20 } } }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('a genuine cross-branch bound crossing on a required property is still rejected', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.genuinecrossing.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['score'], + properties: { score: { type: 'number', minimum: 60 } }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { score: { type: 'number', maximum: 50 } } }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/cannot be satisfied/); + }); + + test('defaults nested under an object trait are materialized', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nesteddefault.v1~'; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['routing'], + properties: { + routing: { + type: 'object', + required: ['topic'], + properties: { topic: { type: 'string', default: 'orders' } }, + }, + }, + }, + 'x-gts-traits': { routing: {} }, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('an optional trait object with a partly-defaulted subtree stays absent', () => { + // ADR-0003 licenses materializing declared defaults, not inventing values. + // Conjuring an absent *optional* object validates a subtree the author never + // supplied, which rejected a type that is legitimately silent there. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.optsubtree.v1~'; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { + routing: { + type: 'object', + required: ['topic', 'partitionKey'], + properties: { topic: { type: 'string', default: 'orders' }, partitionKey: { type: 'string' } }, + }, + }, + }, + 'x-gts-traits': {}, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('a required trait object is materialized from its subtree defaults', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.reqsubtree.v1~'; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['routing'], + properties: { + routing: { + type: 'object', + required: ['topic'], + properties: { topic: { type: 'string', default: 'orders' } }, + }, + }, + }, + 'x-gts-traits': {}, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('a required trait object whose subtree cannot be completed still fails', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.reqgap.v1~'; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['routing'], + properties: { + routing: { + type: 'object', + required: ['topic', 'key'], + properties: { topic: { type: 'string', default: 'orders' }, key: { type: 'string' } }, + }, + }, + }, + 'x-gts-traits': {}, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(false); + }); + + test('a closed descendant trait-schema must not orphan a required ancestor trait', () => { + // `retention` is required on the base branch, so it's guaranteed present + // overall - the closed descendant branch that doesn't restate it really + // does reject every value, unlike the merely-optional case covered below. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.orphan.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['retention'], + properties: { retention: { type: 'string' } }, + }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { + type: 'object', + additionalProperties: false, + properties: { topicRef: { type: 'string' } }, + }, + 'x-gts-abstract': true, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('restating the ancestor trait makes the closed descendant valid', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.restate.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string' } } }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { + type: 'object', + additionalProperties: false, + properties: { retention: { type: 'string' }, topicRef: { type: 'string' } }, + }, + 'x-gts-abstract': true, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('a closed descendant that drops a merely-optional ancestor trait is unsatisfiable', () => { + // Intentional semantic flip (this refactor): the closed-branch orphan + // check is NOT gated on required-ness, matching gts-rust and this + // codebase's own OP#12 `compareOverlayToBase` precedent - an `allOf` + // branch is evaluated independently, so a closed branch that never + // restates `retention` rejects every value the base branch allows for + // it, regardless of whether `retention` happens to be required anywhere. + // (Before this refactor, the now-removed `findUnsatisfiableTrait` only + // flagged this when the property was `requiredAnywhere`, so this case + // used to report `ok: true`.) + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.optorphan.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string' } } }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { + type: 'object', + additionalProperties: false, + properties: { topicRef: { type: 'string' } }, + }, + 'x-gts-abstract': true, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('a conflict between two allOf branches within one trait-schema level is not caught before concretization', () => { + // Reflects a genuine, faithfully-ported gts-rust limitation rather than a + // bug: `validate_trait_schema_compatibility` (ported here as + // `validateTraitChainSatisfiability`'s declared-schema-fold + + // accepted-set-inclusion loop) only compares *consecutive chain levels* + // (`chain[0..i]` vs `chain[0..i+1]`) - it never inspects a single level's + // OWN internal `allOf` composition. `declared_schema`'s own fold of that + // single level's branches is last-branch-wins (see `absorbProperty`), so + // `branchA`'s `const: 'a'` is silently overwritten by `branchB`'s + // `const: 'b'` before any comparison happens, and no chain-level + // comparison ever re-examines it. Since this base type is abstract, it + // is also exempt from the completeness check (§9.7.5's "descendants + // close the gaps"), so the conflict stays latent until some concrete + // descendant actually materializes `k` and AJV validates the real, + // unfolded `allOf` against it. (An earlier round of this refactor used a + // bespoke recursive walker - not part of gts-rust's actual algorithm - + // that caught this eagerly and asserted `ok: false` here; per this + // session's zero-divergence mandate, that extra check was removed rather + // than re-derived, so this scenario is now `ok: true` at the abstract + // level, matching gts-rust exactly.) + const gts = new GTS({ validateRefs: false }); + // A GTS id has exactly 4 dot-segments (vendor.package.namespace.type) + // before the version - `nestedconflict.a`/`.b` as a 5th segment is + // malformed, so the disambiguator is folded into the type token instead. + const commonAId = 'gts.x.unit.tr.nestedconflicta.v1~'; + const commonBId = 'gts.x.unit.tr.nestedconflictb.v1~'; + const baseId = 'gts.x.unit.tr.nestedconflict.v1~'; + + gts.register(baseType(commonAId, { type: 'object', required: ['k'], properties: { k: { const: 'a' } } })); + gts.register(baseType(commonBId, { type: 'object', required: ['k'], properties: { k: { const: 'b' } } })); + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + allOf: [{ allOf: [{ $$ref: `gts://${commonAId}` }] }, { allOf: [{ $$ref: `gts://${commonBId}` }] }], + }, + }) + ); + + const result = gts.validateEntity(baseId); + expect(result.ok).toBe(true); + }); + + test('a conflict nested inside an optional property still makes the schema unsatisfiable', () => { + // Neither branch requires `x` itself at the outer level - but, as with + // the top-level disjoint-type/const cases above, gts-rust's + // accepted-set-inclusion check does not gate on required-ness anywhere in + // the recursion: it compares the schema declared for every property name + // either side declares, at every depth, so `x`'s own optionality does not + // shield the `a: string` vs `a: number` conflict nested inside it. + // (An earlier round of this refactor asserted `ok: true` here on the + // theory that AJV validates `{}` against `allOf: [base, kid]` regardless + // of what conflicts exist inside an absent optional property - true for + // JSON Schema *instance* validation, but not the question gts-rust's + // trait-chain *satisfiability* check answers.) + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.optnested.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { + x: { + type: 'object', + required: ['a'], + additionalProperties: false, + properties: { a: { type: 'string' } }, + }, + }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { + x: { type: 'object', required: ['a'], properties: { a: { type: 'number' } } }, + }, + }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + }); + + test('a closed sub-schema nested inside a descendant branch its own allOf still orphans a required ancestor trait', () => { + // `retention` is required on the base branch, so it's guaranteed present + // overall. The descendant does not close its own top-level branch, but + // its own `allOf` nests a closed sub-schema (the shape a `$ref`-to- + // reusable-trait-schema produces) that never restates `retention` - that + // nested closed node still constrains the very same object instance + // once `allOf` is flattened, so it must be caught just like a directly + // closed branch would be. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nestedorphan.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['retention'], + properties: { retention: { type: 'string' } }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + allOf: [{ additionalProperties: false, properties: { topicRef: { type: 'string' } } }], + }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + // Wording changed with this refactor's move to `compareOverlayToBase` + // (its own closed-branch-orphan message), but the outcome - and the + // fact that a *nested* closed branch is still caught - is unchanged. + expect(result.error).toMatch(/excluded by additionalProperties: false/); + }); + + test('a closed branch nested one level inside a shared property still orphans a required ancestor field', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nestedorphanbug.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['config'], + properties: { + config: { type: 'object', required: ['field'], properties: { field: { type: 'string' } } }, + }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { + config: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/excluded by additionalProperties: false/); + }); + + test('restating the orphaned nested field alongside the closed branch keeps it satisfiable', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nestedorphanbugrestate.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['config'], + properties: { + config: { type: 'object', required: ['field'], properties: { field: { type: 'string' } } }, + }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { + config: { + type: 'object', + properties: { field: { type: 'string' } }, + additionalProperties: false, + }, + }, + }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(true); + }); + + test('a closed branch two levels deep inside nested property values still orphans a required ancestor field', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nestedorphanbugdeep.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['config'], + properties: { + config: { + type: 'object', + required: ['nested'], + properties: { + nested: { type: 'object', required: ['field'], properties: { field: { type: 'string' } } }, + }, + }, + }, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { + config: { + type: 'object', + properties: { + nested: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, + }, + }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/excluded by additionalProperties: false/); + }); + + test('a descendant narrowing an inherited trait keeps the ancestor default', () => { + // The base declares `retention`'s `default`; the descendant narrows the + // same property with `maxLength` but does not repeat the default. Real + // `allOf` semantics combine both branches' constraints on one property, + // so the default must still materialize - losing it here would then fail + // completeness on a trait the schema itself already answered. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.narrowdefault.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['retention'], + properties: { retention: { type: 'string', default: 'p30d' } }, + }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { type: 'object', properties: { retention: { type: 'string', maxLength: 8 } } }, + 'x-gts-abstract': true, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test("a descendant's own default overrides the ancestor's default for the same property", () => { + // Both the base and the descendant declare a `default` for `retention`. + // `allOf` semantics still require a single materialized value, and the + // descendant's own declaration is the one that must win - matching the + // descendant-overrides-ancestor convention used everywhere else (e.g. + // `x-gts-traits`'s RFC 7396 merge). The descendant also constrains the + // property with a `maxLength` that only its own (shorter) default value + // satisfies, so this is a black-box check: if the ancestor's longer + // default won instead, the materialized value would violate `maxLength` + // and validation would report an error rather than succeed. (Uses + // `maxLength` rather than `pattern`: `pattern` is a JSON Schema + // "unmodeled" keyword for `GtsCompatibility.compareSchemas`'s + // subsumption engine - introducing one where the ancestor has none makes + // that comparison `unknown`, which the satisfiability gate added by this + // refactor now fails closed on. `maxLength` is a modeled bound keyword, + // so tightening it stays `compatible` and does not trip that gate.) + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.defaultoverride.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['retention'], + properties: { retention: { type: 'string', default: 'ANCESTOR-DEFAULT' } }, + }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { retention: { type: 'string', default: 'short', maxLength: 5 } }, + }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test("a descendant's own default overrides the ancestor's default one level deeper (nested object property)", () => { + // Same conflict as above, but the property carrying the conflicting + // defaults (`q`) sits one level under a required object property (`p`), + // exercising the fix through `applyTraitDefaults`'s recursion into a + // required-but-absent object's subtree defaults. Uses `maxLength` rather + // than `pattern` for the same reason as the test above - see its comment. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.nesteddefaultoverride.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['p'], + properties: { + p: { + type: 'object', + required: ['q'], + properties: { q: { type: 'string', default: 'ANCESTOR-DEFAULT' } }, + }, + }, + }, + 'x-gts-abstract': true, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-traits-schema': { + type: 'object', + properties: { + p: { + type: 'object', + properties: { q: { type: 'string', default: 'short', maxLength: 5 } }, + }, + }, + }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); +}); + +/** + * `validateTraitChainSatisfiability`'s declared-schema-fold + + * accepted-set-inclusion check (§9.7.5) is a faithful TS port of gts-rust's + * `schema_traits::validate_trait_schema_compatibility`, which in turn calls + * `schema_derivation::validate_derivation` - so every fixture below is a + * direct 2-level trait-schema-chain translation of a scenario from + * gts-rust's own `schema_derivation_test.rs`, with the same expected + * pass/fail outcome. `x-gts-abstract: true` on both levels keeps these tests + * focused purely on satisfiability, independent of the separate completeness + * check (§9.7.5's "descendants close the gaps"). + */ +describe('OP#13 - trait-chain satisfiability mirrors gts-rust schema_derivation_test.rs', () => { + function traitChain(baseId: string, kidId: string, baseProperty: any, kidProperty: any) { + return () => { + const gts = new GTS({ validateRefs: false }); + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { v: baseProperty } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { v: kidProperty } }, + }) + ); + return gts.validateEntity(kidId); + }; + } + + test('test_compatible_tightening: a tighter maxLength is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rusttighten.v1~', + 'gts.x.unit.tr.rusttighten.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100 }, + { type: 'string', maxLength: 50 } + ); + expect(validate().ok).toBe(true); + }); + + test('test_incompatible_loosening_max_length: a looser maxLength is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustloosenmaxlen.v1~', + 'gts.x.unit.tr.rustloosenmaxlen.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100 }, + { type: 'string', maxLength: 200 } + ); + expect(validate().ok).toBe(false); + }); + + test('test_incompatible_loosening_maximum: a looser maximum is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustloosenmax.v1~', + 'gts.x.unit.tr.rustloosenmax.v1~x.unit._.kid.v1~', + { type: 'integer', maximum: 100 }, + { type: 'integer', maximum: 200 } + ); + expect(validate().ok).toBe(false); + }); + + test('test_incompatible_loosening_minimum: a looser minimum is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustloosenmin.v1~', + 'gts.x.unit.tr.rustloosenmin.v1~x.unit._.kid.v1~', + { type: 'integer', minimum: 10 }, + { type: 'integer', minimum: 5 } + ); + expect(validate().ok).toBe(false); + }); + + test('test_enum_expansion_fails: widening an enum is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumwiden.v1~', + 'gts.x.unit.tr.rustenumwiden.v1~x.unit._.kid.v1~', + { type: 'string', enum: ['a', 'b'] }, + { type: 'string', enum: ['a', 'b', 'c'] } + ); + expect(validate().ok).toBe(false); + }); + + test('test_enum_subset_ok: narrowing to an enum subset is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumsubset.v1~', + 'gts.x.unit.tr.rustenumsubset.v1~x.unit._.kid.v1~', + { type: 'string', enum: ['a', 'b', 'c'] }, + { type: 'string', enum: ['a'] } + ); + expect(validate().ok).toBe(true); + }); + + test('test_omitting_bounds_without_enum_or_const_still_fails: dropping a bound with no replacement is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustdropbound.v1~', + 'gts.x.unit.tr.rustdropbound.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100 }, + { type: 'string' } + ); + expect(validate().ok).toBe(false); + }); + + test('test_enum_tightening_allows_omitting_bounds: an enum within the inherited maxLength is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumtighten.v1~', + 'gts.x.unit.tr.rustenumtighten.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100 }, + { type: 'string', enum: ['gold', 'platinum'] } + ); + expect(validate().ok).toBe(true); + }); + + // Direct translation of gts-rust's `test_const_tightening_allows_omitting_ + // bounds_and_pattern`: the base declares both `maxLength` and `pattern`, + // the descendant declares only a `const` that already satisfies both - + // `compareBounds`'s fixed-value carve-out covers the `maxLength` half, + // and `compareUnmodeled`'s `pattern`-specific carve-out covers the + // `pattern` half. + test('test_const_tightening_allows_omitting_bounds_and_pattern: a const within the inherited maxLength and pattern is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustconsttighten.v1~', + 'gts.x.unit.tr.rustconsttighten.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100, pattern: '^[a-z]+$' }, + { type: 'string', const: 'hello' } + ); + expect(validate().ok).toBe(true); + }); + + test('test_const_implies_type: a const narrowing a type-only ancestor is satisfiable', () => { + // `retention` (via the `v` property) declares no `type`, only `const: + // 'P30D'` - whose value IS a string, so it plainly narrows the + // ancestor's `type: 'string'` even though the descendant never restates + // `type` literally. + const validate = traitChain( + 'gts.x.unit.tr.constimpliestype.v1~', + 'gts.x.unit.tr.constimpliestype.v1~x.unit._.kid.v1~', + { type: 'string' }, + { const: 'P30D' } + ); + expect(validate().ok).toBe(true); + }); + + test('test_enum_implies_type: an enum narrowing a type-only ancestor is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.enumimpliestype.v1~', + 'gts.x.unit.tr.enumimpliestype.v1~x.unit._.kid.v1~', + { type: 'string' }, + { enum: ['a', 'b'] } + ); + expect(validate().ok).toBe(true); + }); + + test('test_const_implied_type_conflict_still_fails: a const of the wrong runtime type is not a valid narrowing', () => { + // Negative control: 42 is a number, not a string, so this is a genuine + // type conflict - the fix must derive the const's own implied type + // rather than unconditionally accepting any const/enum as compatible. + const validate = traitChain( + 'gts.x.unit.tr.constimpliedtypeconflict.v1~', + 'gts.x.unit.tr.constimpliedtypeconflict.v1~x.unit._.kid.v1~', + { type: 'string' }, + { const: 42 } + ); + expect(validate().ok).toBe(false); + }); + + test('test_const_tightening_violates_pattern_still_fails: a const that does not match the inherited pattern is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustconsttightenbadpattern.v1~', + 'gts.x.unit.tr.rustconsttightenbadpattern.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 100, pattern: '^[a-z]+$' }, + { type: 'string', const: 'HELLO' } + ); + expect(validate().ok).toBe(false); + }); + + test('test_enum_tightening_allows_omitting_pattern: an enum whose members all match the inherited pattern is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumtightenpattern.v1~', + 'gts.x.unit.tr.rustenumtightenpattern.v1~x.unit._.kid.v1~', + { type: 'string', pattern: '^[a-z]+$' }, + { type: 'string', enum: ['gold', 'platinum'] } + ); + expect(validate().ok).toBe(true); + }); + + test('test_enum_tightening_allows_omitting_numeric_bounds: an enum within the inherited minimum/maximum is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumtightennum.v1~', + 'gts.x.unit.tr.rustenumtightennum.v1~x.unit._.kid.v1~', + { type: 'integer', minimum: 0, maximum: 100 }, + { type: 'integer', enum: [1, 5, 10] } + ); + expect(validate().ok).toBe(true); + }); + + test('test_enum_tightening_still_rejects_out_of_bound_values: an enum with a member outside the inherited maxLength is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustenumtightenviolate.v1~', + 'gts.x.unit.tr.rustenumtightenviolate.v1~x.unit._.kid.v1~', + { type: 'string', maxLength: 5 }, + { type: 'string', enum: ['short', 'way-too-long-value'] } + ); + expect(validate().ok).toBe(false); + }); + + test('test_derived_const_must_be_in_base_enum: a const inside the base enum is satisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustconstinenum.v1~', + 'gts.x.unit.tr.rustconstinenum.v1~x.unit._.kid.v1~', + { type: 'string', enum: ['active', 'inactive'] }, + { type: 'string', const: 'active' } + ); + expect(validate().ok).toBe(true); + }); + + test('test_derived_const_must_be_in_base_enum: a const outside the base enum is unsatisfiable', () => { + const validate = traitChain( + 'gts.x.unit.tr.rustconstnotinenum.v1~', + 'gts.x.unit.tr.rustconstnotinenum.v1~x.unit._.kid.v1~', + { type: 'string', enum: ['active', 'inactive'] }, + { type: 'string', const: 'deleted' } + ); + expect(validate().ok).toBe(false); + }); + + test('test_property_disabled_fails: disabling an ancestor-declared property is unsatisfiable', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.rustdisableprop.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { x: { type: 'string' } } }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { type: 'object', properties: { x: false } }, + }) + ); + + const result = gts.validateEntity(kidId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/disables a property/); + }); + + test('test_additional_properties_false_blocks_new_prop: a closed ancestor rejects a new descendant property', () => { + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.rustapclosednew.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'string' } }, + }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(false); + }); + + test('test_additional_properties_inherited_via_allof_not_loosening: omitting additionalProperties inherits ancestor closedness', () => { + // The descendant conjunct omits `additionalProperties` entirely (rather + // than explicitly declaring it `true`), so `mergeAdditionalPropertiesConstraint` + // folds forward the ancestor's closed constraint rather than reopening + // it - not loosening. + const gts = new GTS({ validateRefs: false }); + const baseId = 'gts.x.unit.tr.rustapinherited.v1~'; + const kidId = `${baseId}x.unit._.kid.v1~`; + + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: false, + }, + }) + ); + gts.register( + derivedType(kidId, baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + properties: { a: { type: 'string' } }, + }, + }) + ); + + expect(gts.validateEntity(kidId).ok).toBe(true); + }); + + test('a malformed base id no longer hides the ancestor chain and silently passes trait validation (regression)', () => { + // Regression for the `buildSchemaChain` fail-open bug: a malformed base + // id (5 dot-segments before `v1~` instead of the required 4) used to + // make `buildSchemaChain` throw internally and get caught by a bare + // `catch { return [schemaId] }`, silently truncating the chain to a + // single element with no ancestors - so every trait-schema/parent + // constraint on the (now-invisible) base type was skipped and + // `validateEntity` wrongly reported `ok: true`. `register()` now rejects + // the malformed id outright, so the entity never enters the store. + const gts = new GTS({ validateRefs: false }); + const malformedBaseId = 'gts.x.unit.tr.nestedorphanbug.base.v1~'; + + const traitsSchema = { + type: 'object', + required: ['config'], + properties: { + config: { + type: 'object', + required: ['field'], + properties: { field: { type: 'string' } }, + }, + }, + }; + const kidTraitsSchema = { + type: 'object', + properties: { config: { type: 'object', properties: {}, additionalProperties: false } }, + }; + + expect(() => + gts.register(baseType(malformedBaseId, { 'x-gts-abstract': true, 'x-gts-traits-schema': traitsSchema })) + ).toThrow(`Invalid GTS entity id: '${malformedBaseId}'`); + + // The identical trait-schema content, on a well-formed 4-segment base id, + // correctly detects the same nested conflict and returns `ok: false` - + // confirming the fix only closes the id-well-formedness hole and does + // not affect the underlying nested-orphan detection logic itself. + const wellFormedBaseId = 'gts.x.unit.tr.nestedorphanbug.v1~'; + const wellFormedKidId = `${wellFormedBaseId}x.unit._.kid.v1~`; + + gts.register(baseType(wellFormedBaseId, { 'x-gts-abstract': true, 'x-gts-traits-schema': traitsSchema })); + gts.register( + derivedType(wellFormedKidId, wellFormedBaseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': kidTraitsSchema, + }) + ); + + expect(gts.validateEntity(wellFormedKidId).ok).toBe(false); + }); +}); + +describe('OP#13 - a diamond-shaped x-gts-traits-schema chain is bounded by a path-count budget', () => { + // `resolveTraitSchemaRefs` does not memoize `$ref` resolution across + // sibling `allOf` branches (a diamond ancestor is re-resolved from scratch + // every time a different path reaches it): a schema compiler like Ajv + // walks the resulting inlined schema by structure, not by object identity, + // so even a memoized-but-still-inlined tree would still be exponential to + // compile and, worse, exponential for Ajv's *compiled validator* to run on + // every subsequent `validateEntity()` call. Rather than chase that + // algorithmic cost, `resolveTraitSchemaRefs` counts every `$ref` follow and + // `allOf` branch recursion against a shared, generous `MAX_SCHEMA_PATHS` + // budget (10,000) and fails fast and loud once a trait-schema graph has + // too many composition paths to be worth resolving - the same "bounded + // rejection instead of a full algorithmic fix" already used elsewhere in + // this file for `MAX_SCHEMA_DEPTH`. + + test('a chain where every level doubles its composition paths exceeds the budget and fails fast, not with a hang', () => { + // Each level's `x-gts-traits-schema` is `{allOf: [{$$ref: prev}, {$$ref: + // prev}]}` - the same ancestor referenced twice - so the number of + // composition paths doubles exactly once per level. 12 levels already + // clears the 10,000-path budget (2^12 = 4096 branch points, each also + // following a `$ref`, comfortably exceeds it well before the chain + // bottoms out), so this test stays small, fast, and nowhere near a size + // that could hang or OOM the test runner even without the guard. + const gts = new GTS({ validateRefs: false }); + + const prev = 'gts.x.unit.pathbudget.a0.v1~'; + gts.register(baseType(prev, { 'x-gts-traits-schema': { type: 'object', properties: { p0: { type: 'string' } } } })); + + const DEPTH = 12; + let cur = prev; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.pathbudget.a${i}.v1~`; + gts.register( + baseType(next, { + 'x-gts-traits-schema': { allOf: [{ $$ref: `gts://${cur}` }, { $$ref: `gts://${cur}` }] }, + }) + ); + cur = next; + } + + const start = Date.now(); + const result = gts.validateEntity(cur); + const elapsedMs = Date.now() - start; + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/too many composition paths/); + expect(result.error).toMatch(/exceeds 10000/); + // Well under a second - this must fail fast, not hang. + expect(elapsedMs).toBeLessThan(500); + }); + + test('a legitimate, shallow (well under-budget) diamond chain still resolves and validates correctly', () => { + // Control for the guard above: a modest, realistic diamond - the same + // "two immediate ancestors" shape real derivation hierarchies use - must + // not be rejected by the new budget. + const gts = new GTS({ validateRefs: false }); + + const prevA = 'gts.x.unit.pathbudgetok.a0.v1~'; + const prevB = 'gts.x.unit.pathbudgetok.b0.v1~'; + gts.register( + baseType(prevA, { 'x-gts-traits-schema': { type: 'object', properties: { p0: { type: 'string' } } } }) + ); + gts.register( + baseType(prevB, { 'x-gts-traits-schema': { type: 'object', properties: { q0: { type: 'string' } } } }) + ); + + const DEPTH = 6; + let a = prevA; + let b = prevB; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.pathbudgetok.a${i}.v1~`; + gts.register( + baseType(next, { + allOf: [{ $$ref: `gts://${a}` }, { $$ref: `gts://${b}` }], + 'x-gts-traits-schema': { allOf: [{ $$ref: `gts://${a}` }, { $$ref: `gts://${b}` }] }, + }) + ); + b = a; + a = next; + } + + const start = Date.now(); + const result = gts.validateEntity(a); + const elapsedMs = Date.now() - start; + + // No `x-gts-traits` value was supplied, so completeness fails (the base + // ancestors' trait properties, and the entity's own `id`, are never + // satisfied) - that failure is expected and orthogonal to this test. + // What matters is that resolution actually ran to that verdict instead + // of being rejected by the path-count budget. + expect(result.ok).toBe(false); + expect(result.error).not.toMatch(/too many composition paths/); + expect(result.error).not.toMatch(/nests deeper than/); + expect(elapsedMs).toBeLessThan(500); + }); + + test('a legitimate, non-branching trait-schema chain resolves correctly and quickly regardless of depth', () => { + // Control for the guard's depth-independence: a purely linear chain (no + // `allOf` branching at all) never accumulates more than one composition + // path per level, so it must sail through the 10,000-path budget + // regardless of how deep it goes. Depth is kept within `MAX_SCHEMA_DEPTH` + // (64) - `resolveTraitSchemaRefs` recurses through a referenced entity's + // whole content (not only its `x-gts-traits-schema`), so its own + // depth-per-level cost is a separate, pre-existing property of this + // walker, unrelated to (and unchanged by) the path-count budget this + // test guards. + const gts = new GTS({ validateRefs: false }); + + const prev = 'gts.x.unit.linearchain.a0.v1~'; + gts.register(baseType(prev, { 'x-gts-traits-schema': { type: 'object', properties: { p0: { type: 'string' } } } })); + + const DEPTH = 15; + let cur = prev; + for (let i = 1; i <= DEPTH; i++) { + const next = `gts.x.unit.linearchain.a${i}.v1~`; + gts.register( + baseType(next, { + 'x-gts-traits-schema': { allOf: [{ $$ref: `gts://${cur}` }] }, + }) + ); + cur = next; + } + + const start = Date.now(); + const result = gts.validateEntity(cur); + const elapsedMs = Date.now() - start; + + expect(result.error).not.toMatch(/too many composition paths/); + expect(result.error).not.toMatch(/nests deeper than/); + expect(elapsedMs).toBeLessThan(500); + }); +}); + +describe('OP#13 - x-gts-ref is enforced against materialized trait values (§9.6)', () => { + test('a materialized trait value that violates x-gts-ref fails completeness', () => { + const gts = new GTS({ validateRefs: false }); + const topicSchemaId = 'gts.x.unit.trxrefbad.topic.v1~'; + gts.register(baseType(topicSchemaId)); + + const baseId = 'gts.x.unit.trxrefbad.base.v1~'; + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['topicRef'], + properties: { topicRef: { type: 'string', 'x-gts-ref': topicSchemaId } }, + }, + 'x-gts-traits': { topicRef: 'not-a-valid-gts-ref-at-all' }, + }) + ); + + const result = gts.validateEntity(baseId); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/x-gts-ref/); + }); + + test('a materialized trait value that matches x-gts-ref passes completeness', () => { + const gts = new GTS({ validateRefs: false }); + const topicSchemaId = 'gts.x.unit.trxrefgood.topic.v1~'; + gts.register(baseType(topicSchemaId)); + + const baseId = 'gts.x.unit.trxrefgood.base.v1~'; + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['topicRef'], + properties: { topicRef: { type: 'string', 'x-gts-ref': topicSchemaId } }, + }, + // A registered entity id that matches the x-gts-ref pattern is a valid + // reference value; the schema's own id qualifies. + 'x-gts-traits': { topicRef: topicSchemaId }, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); + + test('a materialized trait value matching x-gts-ref passes completeness even when the referenced entity is not registered', () => { + const gts = new GTS({ validateRefs: false }); + // Deliberately never registered: `x-gts-traits` values are schema-level + // example/default data documenting a type's shape, not live references + // that must already exist in the registry at schema-authoring time. + const topicSchemaId = 'gts.x.unit.trxrefunreg.topic.v1~'; + + const baseId = 'gts.x.unit.trxrefunreg.base.v1~'; + gts.register( + baseType(baseId, { + 'x-gts-traits-schema': { + type: 'object', + required: ['topicRef'], + properties: { topicRef: { type: 'string', 'x-gts-ref': topicSchemaId } }, + }, + 'x-gts-traits': { topicRef: `${topicSchemaId}x.unit._.orders.v1` }, + }) + ); + + const result = gts.validateEntity(baseId); + expect(result.ok).toBe(true); + expect(result.error).toBe(''); + }); + + test('an abstract type with an unresolved x-gts-ref-constrained trait is still exempt', () => { + const gts = new GTS({ validateRefs: false }); + const topicSchemaId = 'gts.x.unit.trxrefabs.topic.v1~'; + gts.register(baseType(topicSchemaId)); + + const baseId = 'gts.x.unit.trxrefabs.base.v1~'; + gts.register( + baseType(baseId, { + 'x-gts-abstract': true, + 'x-gts-traits-schema': { + type: 'object', + required: ['topicRef'], + properties: { topicRef: { type: 'string', 'x-gts-ref': topicSchemaId } }, + }, + }) + ); + + expect(gts.validateEntity(baseId).ok).toBe(true); + }); +}); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..4acf502 --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,9 @@ +import { PACKAGE_VERSION } from '../src/version'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageJson = require('../package.json'); + +describe('PACKAGE_VERSION', () => { + test('reflects package.json, so the CLI/server binaries cannot drift from a stale hard-coded string', () => { + expect(PACKAGE_VERSION).toBe(packageJson.version); + }); +});