Skip to content

XGtsRefValidator does not traverse oneOf/anyOf/allOf subschemas #10

@GeraBart

Description

@GeraBart

Bug Description

XGtsRefValidator.visitInstance() only recurses into properties (for objects) and items (for arrays), but does not traverse into JSON Schema combinators (oneOf, anyOf, allOf). This means x-gts-ref constraints nested inside these combinators are silently ignored during instance validation.

Additionally, since Ajv (used internally by gts-ts) does not recognize x-gts-ref as a keyword, subschemas containing only x-gts-ref are treated as empty schemas {} by Ajv. When placed inside oneOf, both empty subschemas match any value, causing oneOf to always fail (it requires exactly 1 match out of N).

The combined effect: x-gts-ref inside oneOf/anyOf/allOf is broken at both validation layers.

Reproduction

Schema (action.v1.json)

{
  "$id": "gts://gts.hai3.mfes.comm.action.v1~",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "type": {
      "x-gts-ref": "/$id"
    },
    "target": {
      "type": "string",
      "oneOf": [
        { "x-gts-ref": "gts.hai3.mfes.ext.domain.v1~*" },
        { "x-gts-ref": "gts.hai3.mfes.ext.extension.v1~*" }
      ]
    }
  },
  "required": ["type", "target"]
}

Instance (valid action targeting a domain)

{
  "type": "gts.hai3.mfes.comm.action.v1~hai3.mfes.ext.mount_ext.v1",
  "target": "gts.hai3.mfes.ext.domain.v1~hai3.screensets.layout.screen.v1"
}

Expected behavior

Validation passes — target matches the first x-gts-ref pattern (gts.hai3.mfes.ext.domain.v1~*).

Actual behavior

Validation fails with:

/target must match exactly one schema in oneOf

Ajv sees both oneOf subschemas as {} (since x-gts-ref is unknown to Ajv), both match the string value, and oneOf fails because 2 subschemas matched instead of 1.

Meanwhile, XGtsRefValidator never sees the x-gts-ref constraints at all because visitInstance() does not recurse into oneOf branches.

Root Cause

In src/x-gts-ref.ts, visitInstance() (lines 44-84) handles:

  • schema.properties — recurses into object properties
  • schema.items — recurses into array items
  • schema.oneOfnot traversed
  • schema.anyOfnot traversed
  • schema.allOfnot traversed

Proposed Test

import { GtsStore, createJsonEntity } from '@globaltypesystem/gts-ts';

describe('XGtsRefValidator with oneOf', () => {
  let store: GtsStore;

  beforeEach(() => {
    store = new GtsStore();

    // Register the action schema
    store.register(createJsonEntity({
      "$id": "gts://gts.test.action.v1~",
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "type": "object",
      "properties": {
        "type": { "x-gts-ref": "/$id" },
        "target": {
          "type": "string",
          "oneOf": [
            { "x-gts-ref": "gts.test.domain.v1~*" },
            { "x-gts-ref": "gts.test.extension.v1~*" }
          ]
        }
      },
      "required": ["type", "target"]
    }));

    // Register domain schema (so domain IDs are resolvable)
    store.register(createJsonEntity({
      "$id": "gts://gts.test.domain.v1~",
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "type": "object",
      "properties": { "id": { "x-gts-ref": "/$id" } },
      "required": ["id"]
    }));

    // Register extension schema
    store.register(createJsonEntity({
      "$id": "gts://gts.test.extension.v1~",
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "type": "object",
      "properties": { "id": { "x-gts-ref": "/$id" } },
      "required": ["id"]
    }));

    // Register a domain instance
    store.register(createJsonEntity({
      "id": "gts.test.domain.v1~my.screen.domain.v1"
    }));

    // Register an extension instance
    store.register(createJsonEntity({
      "id": "gts.test.extension.v1~my.widget.v1"
    }));
  });

  it('should validate action targeting a domain (oneOf branch 1)', () => {
    store.register(createJsonEntity({
      "type": "gts.test.action.v1~my.mount.action.v1",
      "target": "gts.test.domain.v1~my.screen.domain.v1"
    }));

    const result = store.validateInstance(
      "gts.test.action.v1~my.mount.action.v1"
    );

    expect(result.ok).toBe(true);
    expect(result.valid).toBe(true);
  });

  it('should validate action targeting an extension (oneOf branch 2)', () => {
    store.register(createJsonEntity({
      "type": "gts.test.action.v1~my.notify.action.v1",
      "target": "gts.test.extension.v1~my.widget.v1"
    }));

    const result = store.validateInstance(
      "gts.test.action.v1~my.notify.action.v1"
    );

    expect(result.ok).toBe(true);
    expect(result.valid).toBe(true);
  });

  it('should reject action targeting an invalid GTS ID', () => {
    store.register(createJsonEntity({
      "type": "gts.test.action.v1~my.bad.action.v1",
      "target": "not-a-gts-id"
    }));

    const result = store.validateInstance(
      "gts.test.action.v1~my.bad.action.v1"
    );

    expect(result.ok).toBe(false);
  });
});

Suggested Fix

visitInstance() should recurse into combinator subschemas. Sketch:

// In visitInstance(), after handling properties and items:

// Recurse into oneOf/anyOf/allOf subschemas
for (const combinator of ['oneOf', 'anyOf', 'allOf']) {
  if (Array.isArray(schema[combinator])) {
    for (const subSchema of schema[combinator]) {
      this.visitInstance(instance, subSchema, path, rootSchema, errors);
    }
  }
}

For oneOf semantics specifically, the validator should check that the instance matches exactly one x-gts-ref pattern among the subschemas (mirroring JSON Schema oneOf semantics).

Impact

This bug blocks any schema that uses x-gts-ref inside JSON Schema combinators. The workaround is to flatten x-gts-ref to the property level (e.g., using a broader wildcard pattern), but this loses the ability to express "target must be one of these specific type families."

Environment

  • @globaltypesystem/gts-ts (latest)
  • Node.js 20+
  • TypeScript 5.x

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions