From ca774c238fad4e7dee6a4b0a82151645b3e0a82a Mon Sep 17 00:00:00 2001 From: GeraBart <246844849+GeraBart@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:51:42 +0800 Subject: [PATCH 1/3] feat: support of new OP#6 tests related to anonymous instances --- .gts-spec | 2 +- src/store.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gts-spec b/.gts-spec index 4c929a2..e088287 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 4c929a2f3a2fecdb2d226096ac52a50459bdea7c +Subproject commit e0882879577e7427f759677e9cf2eac7031d978c diff --git a/src/store.ts b/src/store.ts index 1b6bb0e..a07196b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -95,9 +95,13 @@ export class GtsStore { validateInstance(gtsId: string): ValidationResult { try { - const gid = Gts.parseGtsID(gtsId); + let objId: string = gtsId; + if (Gts.isValidGtsID(gtsId)) { + const gid = Gts.parseGtsID(gtsId); + objId = gid.id; + } - const obj = this.get(gid.id); + const obj = this.get(objId); if (!obj) { return { id: gtsId, From d062f253a6c44798d68cf66462e968325157a978 Mon Sep 17 00:00:00 2001 From: GeraBart <246844849+GeraBart@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:23:53 +0800 Subject: [PATCH 2/3] feat: support combined anonymous instances and OP#13 schema traits validation Signed-off-by: GeraBart <246844849+GeraBart@users.noreply.github.com> --- src/gts.ts | 45 ++++- src/index.ts | 22 +++ src/server/server.ts | 13 +- src/store.ts | 409 ++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 1 + 5 files changed, 471 insertions(+), 19 deletions(-) diff --git a/src/gts.ts b/src/gts.ts index 0837051..fa8b9b0 100644 --- a/src/gts.ts +++ b/src/gts.ts @@ -15,6 +15,7 @@ import { const GTS_NAMESPACE = uuidv5('gts', uuidv5.URL); const SEGMENT_TOKEN_REGEX = /^[a-z_][a-z0-9_]*$/; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; export class Gts { static parseGtsID(id: string): GtsID { @@ -40,6 +41,7 @@ export class Gts { } // Add any remaining content (instance without trailing ~) + // This could be a regular instance segment or a UUID tail if (current) { parts.push(current); } @@ -60,6 +62,7 @@ export class Gts { verMinor: undefined, isType: false, isWildcard: false, + isUuidTail: false, }; let workingSegment = seg.segment; @@ -385,10 +388,6 @@ export class Gts { throw new InvalidGtsIDError(id, 'Must be lower case'); } - if (raw.includes('-')) { - throw new InvalidGtsIDError(id, "Must not contain '-'"); - } - if (!raw.startsWith(GTS_PREFIX)) { throw new InvalidGtsIDError(id, `Does not start with '${GTS_PREFIX}'`); } @@ -426,6 +425,33 @@ export class Gts { continue; } + // Check if this is a UUID tail (last part, no tilde, matches UUID format) + if (i > 0 && i === parts.length - 1 && !part.endsWith('~') && UUID_REGEX.test(part)) { + // UUID tail segment for combined anonymous instances + const seg: GtsIDSegment = { + num: i + 1, + offset, + segment: part, + vendor: '', + package: '', + namespace: '', + type: '', + verMajor: 0, + verMinor: undefined, + isType: false, + isWildcard: false, + isUuidTail: true, + }; + gtsId.segments.push(seg); + offset += part.length; + continue; + } + + // Regular segments must not contain hyphens + if (part.includes('-')) { + throw new InvalidGtsIDError(id, "Must not contain '-'"); + } + const segment = this.parseSegment(i + 1, offset, part); gtsId.segments.push(segment); offset += part.length; @@ -437,9 +463,10 @@ export class Gts { } // v0.7: Single-segment instance IDs are prohibited (skip for wildcard patterns) + // Exception: combined anonymous instances (UUID tail) are always valid if (!allowWildcard && !raw.includes('*')) { const lastSegment = gtsId.segments[gtsId.segments.length - 1]; - if (!lastSegment.isType && gtsId.segments.length === 1) { + if (!lastSegment.isType && !lastSegment.isUuidTail && gtsId.segments.length === 1) { throw new InvalidGtsIDError( id, 'Single-segment instance IDs are prohibited. Instance IDs must be chained with a type segment (e.g., gts.vendor.pkg.ns.type.v1~instance.segment.v1)' @@ -509,6 +536,14 @@ export class Gts { return true; } + // Non-wildcard UUID tail - compare raw segment string + if (pSeg.isUuidTail) { + if (pSeg.segment !== cSeg.segment) { + return false; + } + continue; + } + // Non-wildcard segment - all fields must match if (pSeg.vendor !== cSeg.vendor) { return false; diff --git a/src/index.ts b/src/index.ts index 7158bab..005a891 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,28 @@ export class GTS { castInstance(fromId: string, toSchemaId: string): CastResult { return GtsCast.castInstance(this.store, fromId, toSchemaId); } + + validateEntity(id: string): ValidationResult & { entity_type: string } { + const entity = this.store.get(id); + if (!entity) { + return { id, ok: false, error: `Entity not found: ${id}`, entity_type: 'unknown' }; + } + + if (entity.isSchema) { + 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); + return { ...result, entity_type: 'instance' }; + } + } } export default GTS; diff --git a/src/server/server.ts b/src/server/server.ts index 822ba8b..3ad636f 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -614,18 +614,7 @@ export class GtsServer { return { ok: false, error: 'Missing required field: entity_id or gts_id' }; } - const entity = this.store['store'].get(id); - if (!entity) { - return { ok: false, error: `Entity not found: ${id}` }; - } - - if (entity.isSchema) { - const result = this.store['store'].validateSchemaAgainstParent(id); - return { ...result, entity_type: 'schema' }; - } else { - const result = this.store.validateInstance(id); - return { ...result, entity_type: 'instance' }; - } + return this.store.validateEntity(id); } // OpenAPI Specification diff --git a/src/store.ts b/src/store.ts index a07196b..2193554 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1100,8 +1100,8 @@ export class GtsStore { // Find parent reference in allOf const parentRef = this.findParentRef(content); if (!parentRef) { - // Base schema with no parent → always valid - return { id: schemaId, ok: true, error: '' }; + // Base schema with no parent → still validate traits + return this.validateSchemaTraits(schemaId); } // Resolve parent entity @@ -1114,6 +1114,12 @@ export class GtsStore { return { id: schemaId, ok: false, error: `Parent entity is not a schema: ${parentId}` }; } + // Detect cyclic $$ref / $ref references in the schema's own content + const cycleError = this.detectRefCycle(schemaId, content, new Set([schemaId])); + if (cycleError) { + return { id: schemaId, ok: false, error: cycleError }; + } + // Resolve parent's effective (fully flattened) schema const resolvedParent = this.resolveSchemaFully(parentEntity.content); @@ -1126,9 +1132,408 @@ export class GtsStore { return { id: schemaId, ok: false, error: errors.join('; ') }; } + // OP#13: Validate schema traits across the inheritance chain + const traitsResult = this.validateSchemaTraits(schemaId); + if (!traitsResult.ok) { + return traitsResult; + } + return { id: schemaId, ok: true, error: '' }; } + // OP#13: Validate schema traits across the inheritance chain + private validateSchemaTraits(schemaId: string): ValidationResult { + // Build the chain of schema IDs from base to leaf + const chain = this.buildSchemaChain(schemaId); + + // 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(); + + 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); + } + } + } + } + } + + // 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)) { + return { + id: schemaId, + ok: false, + error: `trait '${k}' in '${chainSchemaId}' overrides value set by ancestor`, + }; + } + } + + // 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) { + return { + id: schemaId, + ok: false, + error: 'x-gts-traits values provided but no x-gts-traits-schema is defined in the inheritance chain', + }; + } + return { id: schemaId, ok: true, error: '' }; + } + + // Validate each trait schema + for (let i = 0; i < traitSchemas.length; i++) { + const ts = traitSchemas[i]; + + // 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', + }; + } + } + + // 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), + }; + } + } + + // Build effective trait schema (allOf composition) + let effectiveSchema: any; + if (resolvedTraitSchemas.length === 1) { + effectiveSchema = resolvedTraitSchemas[0]; + } else { + effectiveSchema = { + type: 'object', + allOf: resolvedTraitSchemas, + }; + } + + // Apply defaults from trait schema to merged traits + const effectiveTraits = this.applyTraitDefaults(effectiveSchema, mergedTraits); + + // 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 errors = + validate.errors?.map((e) => `${e.instancePath} ${e.message}`).join('; ') || 'Trait validation failed'; + return { id: schemaId, ok: false, error: `trait validation: ${errors}` }; + } + } catch (e) { + return { + id: schemaId, + ok: false, + error: `failed to compile trait schema: ${e instanceof Error ? e.message : String(e)}`, + }; + } + + // 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`, + }; + } + } + + 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}` }; + } + + if (!entity.isSchema) { + return { id: entityId, ok: true, error: '' }; + } + + // Build the chain for this schema + const chain = this.buildSchemaChain(entityId); + + const traitSchemas: any[] = []; + let hasTraitValues = false; + + for (const chainSchemaId of chain) { + const chainEntity = this.get(chainSchemaId); + if (!chainEntity || !chainEntity.content) continue; + + this.collectTraitSchemas(chainEntity.content, traitSchemas); + + const levelTraits: Record = {}; + this.collectTraitValues(chainEntity.content, levelTraits); + if (Object.keys(levelTraits).length > 0) { + hasTraitValues = true; + } + } + + if (traitSchemas.length === 0) { + return { id: entityId, ok: true, error: '' }; + } + + // 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', + }; + } + + // 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', + }; + } + } + } + + return { id: entityId, ok: true, error: '' }; + } + + // Build the schema chain from base to leaf for a given schema ID + private buildSchemaChain(schemaId: string): string[] { + // Parse the schema ID to get segments + try { + const gtsId = Gts.parseGtsID(schemaId); + const segments = gtsId.segments; + const chain: string[] = []; + + for (let i = 0; i < segments.length; i++) { + const id = + 'gts.' + + segments + .slice(0, i + 1) + .map((s) => s.segment) + .join(''); + chain.push(id); + } + + 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); + } + } + } + + // Resolve $ref inside a trait schema, detecting cycles + private resolveTraitSchemaRefs(schema: any, visited: Set, depth: number = 0): any { + if (depth > 64) return schema; + if (typeof schema !== 'object' || schema === null) return schema; + + const result: any = {}; + + for (const [key, value] of Object.entries(schema)) { + if (key === '$$ref' || key === '$ref') { + const refUri = value as string; + const refId = refUri.startsWith(GTS_URI_PREFIX) ? refUri.substring(GTS_URI_PREFIX.length) : refUri; + + if (visited.has(refId)) { + throw new Error(`Cyclic reference detected in trait schema: ${refId}`); + } + visited.add(refId); + + 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); + // Merge resolved content into result + for (const [rk, rv] of Object.entries(resolved)) { + if (rk !== '$id' && rk !== '$$id' && rk !== '$schema' && rk !== '$$schema') { + result[rk] = rv; + } + } + continue; + } + + if (key === 'allOf' && Array.isArray(value)) { + result.allOf = (value as any[]).map((item) => this.resolveTraitSchemaRefs(item, visited, depth + 1)); + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + result[key] = this.resolveTraitSchemaRefs(value, new Set(visited), depth + 1); + } else { + result[key] = value; + } + } + + return result; + } + + // Apply defaults from trait schema to trait values + private applyTraitDefaults(schema: any, traits: Record): Record { + const result = { ...traits }; + const props = this.collectAllTraitProperties(schema); + + for (const [propName, propSchema] of Object.entries(props)) { + if (!(propName in result) && typeof propSchema === 'object' && propSchema !== null && 'default' in propSchema) { + result[propName] = propSchema.default; + } + } + + 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; + + if (typeof schema.properties === 'object' && schema.properties !== null) { + Object.assign(props, schema.properties); + } + + if (Array.isArray(schema.allOf)) { + for (const item of schema.allOf) { + Object.assign(props, this.collectAllTraitProperties(item, depth + 1)); + } + } + + return props; + } + + // 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; + + // Check direct ref on this object + const ref = content['$$ref'] || content['$ref']; + if (typeof ref === 'string') { + const refId = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref; + if (visited.has(refId)) { + return `Cyclic reference detected: ${refId}`; + } + const refEntity = this.get(refId); + if (refEntity && refEntity.content) { + visited.add(refId); + const inner = this.detectRefCycle(originId, refEntity.content, visited, depth + 1); + if (inner) return inner; + } + } + + // Recurse into allOf + if (Array.isArray(content.allOf)) { + for (const sub of content.allOf) { + const inner = this.detectRefCycle(originId, sub, visited, depth + 1); + if (inner) return inner; + } + } + + return null; + } + private findParentRef(schema: any): string | null { if (!schema || !schema.allOf || !Array.isArray(schema.allOf)) { return null; diff --git a/src/types.ts b/src/types.ts index 67e5d22..a19bd7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface GtsIDSegment { verMinor?: number; isType: boolean; isWildcard: boolean; + isUuidTail: boolean; } export interface GtsID { From 74710d45cd42a7a369576605dd3365fded917362 Mon Sep 17 00:00:00 2001 From: GeraBart <246844849+GeraBart@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:54:23 +0800 Subject: [PATCH 3/3] chore: minor version bump Signed-off-by: GeraBart <246844849+GeraBart@users.noreply.github.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 450b833..207fff4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@globaltypesystem/gts-ts", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "ajv": "^8.18.0", diff --git a/package.json b/package.json index d83034c..b23da32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@globaltypesystem/gts-ts", - "version": "0.2.0", + "version": "0.3.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",