@globaltypesystem/gts-ts 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +10 -0
- package/NOTICE +15 -0
- package/README.md +14 -18
- package/dist/cast.js +2 -2
- package/dist/cast.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/compatibility.d.ts +3 -1
- package/dist/compatibility.d.ts.map +1 -1
- package/dist/compatibility.js +79 -70
- package/dist/compatibility.js.map +1 -1
- package/dist/server/server.d.ts +2 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +31 -0
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +7 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/store.d.ts +9 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +408 -0
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +14 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/x-gts-ref.d.ts +1 -0
- package/dist/x-gts-ref.d.ts.map +1 -1
- package/dist/x-gts-ref.js +65 -0
- package/dist/x-gts-ref.js.map +1 -1
- package/package.json +4 -5
- package/src/cast.ts +2 -2
- package/src/cli/index.ts +1 -1
- package/src/compatibility.ts +90 -72
- package/src/server/server.ts +44 -0
- package/src/server/types.ts +9 -0
- package/src/store.ts +450 -2
- package/src/types.ts +14 -6
- package/src/x-gts-ref.ts +63 -0
- package/tests/gts.test.ts +5 -3
package/src/store.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import Ajv from 'ajv';
|
|
2
|
-
import { GtsConfig, JsonEntity, ValidationResult, GTS_URI_PREFIX } from './types';
|
|
2
|
+
import { GtsConfig, JsonEntity, ValidationResult, CompatibilityResult, GTS_URI_PREFIX } from './types';
|
|
3
3
|
import { Gts } from './gts';
|
|
4
4
|
import { GtsExtractor } from './extract';
|
|
5
5
|
import { XGtsRefValidator } from './x-gts-ref';
|
|
6
6
|
|
|
7
|
+
interface ResolvedSchema {
|
|
8
|
+
properties: Record<string, any>;
|
|
9
|
+
required: string[];
|
|
10
|
+
additionalProperties?: boolean;
|
|
11
|
+
type?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export class GtsStore {
|
|
8
15
|
private byId: Map<string, JsonEntity> = new Map();
|
|
9
16
|
private config: GtsConfig;
|
|
@@ -194,6 +201,9 @@ export class GtsStore {
|
|
|
194
201
|
const normalized: any = {};
|
|
195
202
|
|
|
196
203
|
for (const [key, value] of Object.entries(obj)) {
|
|
204
|
+
// Strip x-gts-ref so Ajv never sees the unknown keyword
|
|
205
|
+
if (key === 'x-gts-ref') continue;
|
|
206
|
+
|
|
197
207
|
let newKey = key;
|
|
198
208
|
let newValue = value;
|
|
199
209
|
|
|
@@ -221,6 +231,25 @@ export class GtsStore {
|
|
|
221
231
|
normalized[newKey] = newValue;
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
// Clean up combinator arrays: remove subschemas that were x-gts-ref-only (now empty after stripping)
|
|
235
|
+
for (const combinator of ['oneOf', 'anyOf', 'allOf']) {
|
|
236
|
+
if (Array.isArray(normalized[combinator])) {
|
|
237
|
+
normalized[combinator] = normalized[combinator].filter((_sub: any, idx: number) => {
|
|
238
|
+
const original = (obj as any)[combinator]?.[idx];
|
|
239
|
+
const isXGtsRefOnly =
|
|
240
|
+
original &&
|
|
241
|
+
typeof original === 'object' &&
|
|
242
|
+
!Array.isArray(original) &&
|
|
243
|
+
Object.keys(original).length === 1 &&
|
|
244
|
+
original['x-gts-ref'] !== undefined;
|
|
245
|
+
return !isXGtsRefOnly;
|
|
246
|
+
});
|
|
247
|
+
if (normalized[combinator].length === 0) {
|
|
248
|
+
delete normalized[combinator];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
// Normalize $id values
|
|
225
254
|
if (normalized['$id'] && typeof normalized['$id'] === 'string') {
|
|
226
255
|
if (normalized['$id'].startsWith(GTS_URI_PREFIX)) {
|
|
@@ -344,7 +373,7 @@ export class GtsStore {
|
|
|
344
373
|
return (s.startsWith('http://') || s.startsWith('https://')) && s.includes('json-schema.org');
|
|
345
374
|
}
|
|
346
375
|
|
|
347
|
-
checkCompatibility(oldSchemaId: string, newSchemaId: string, _mode?: string):
|
|
376
|
+
checkCompatibility(oldSchemaId: string, newSchemaId: string, _mode?: string): CompatibilityResult {
|
|
348
377
|
const oldEntity = this.get(oldSchemaId);
|
|
349
378
|
const newEntity = this.get(newSchemaId);
|
|
350
379
|
|
|
@@ -1053,6 +1082,425 @@ export class GtsStore {
|
|
|
1053
1082
|
return unique.sort();
|
|
1054
1083
|
}
|
|
1055
1084
|
|
|
1085
|
+
validateSchemaAgainstParent(schemaId: string): ValidationResult {
|
|
1086
|
+
const entity = this.get(schemaId);
|
|
1087
|
+
if (!entity) {
|
|
1088
|
+
return { id: schemaId, ok: false, error: `Entity not found: ${schemaId}` };
|
|
1089
|
+
}
|
|
1090
|
+
if (!entity.isSchema) {
|
|
1091
|
+
return { id: schemaId, ok: false, error: `Entity is not a schema: ${schemaId}` };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const content = entity.content;
|
|
1095
|
+
|
|
1096
|
+
// Find parent reference in allOf
|
|
1097
|
+
const parentRef = this.findParentRef(content);
|
|
1098
|
+
if (!parentRef) {
|
|
1099
|
+
// Base schema with no parent → always valid
|
|
1100
|
+
return { id: schemaId, ok: true, error: '' };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Resolve parent entity
|
|
1104
|
+
const parentId = parentRef.startsWith(GTS_URI_PREFIX) ? parentRef.substring(GTS_URI_PREFIX.length) : parentRef;
|
|
1105
|
+
const parentEntity = this.get(parentId);
|
|
1106
|
+
if (!parentEntity) {
|
|
1107
|
+
return { id: schemaId, ok: false, error: `Parent schema not found: ${parentId}` };
|
|
1108
|
+
}
|
|
1109
|
+
if (!parentEntity.isSchema || !parentEntity.content) {
|
|
1110
|
+
return { id: schemaId, ok: false, error: `Parent entity is not a schema: ${parentId}` };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Resolve parent's effective (fully flattened) schema
|
|
1114
|
+
const resolvedParent = this.resolveSchemaFully(parentEntity.content);
|
|
1115
|
+
|
|
1116
|
+
// Extract overlay from derived schema (non-$ref subschemas in allOf + top-level)
|
|
1117
|
+
const overlay = this.extractOverlay(content);
|
|
1118
|
+
|
|
1119
|
+
// Compare overlay against resolved parent
|
|
1120
|
+
const errors = this.compareOverlayToBase(overlay, resolvedParent, '');
|
|
1121
|
+
if (errors.length > 0) {
|
|
1122
|
+
return { id: schemaId, ok: false, error: errors.join('; ') };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return { id: schemaId, ok: true, error: '' };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private findParentRef(schema: any): string | null {
|
|
1129
|
+
if (!schema || !schema.allOf || !Array.isArray(schema.allOf)) {
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
for (const sub of schema.allOf) {
|
|
1133
|
+
if (sub && typeof sub === 'object') {
|
|
1134
|
+
const ref = sub['$$ref'] || sub['$ref'];
|
|
1135
|
+
if (typeof ref === 'string') {
|
|
1136
|
+
return ref;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private resolveSchemaFully(schema: any, visited: Set<string> = new Set()): ResolvedSchema {
|
|
1144
|
+
const result: ResolvedSchema = {
|
|
1145
|
+
properties: {},
|
|
1146
|
+
required: [],
|
|
1147
|
+
additionalProperties: undefined,
|
|
1148
|
+
type: schema.type,
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
// If this schema has allOf, resolve each part
|
|
1152
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
1153
|
+
for (const sub of schema.allOf) {
|
|
1154
|
+
const ref = sub['$$ref'] || sub['$ref'];
|
|
1155
|
+
if (typeof ref === 'string') {
|
|
1156
|
+
// Resolve referenced schema
|
|
1157
|
+
const refId = ref.startsWith(GTS_URI_PREFIX) ? ref.substring(GTS_URI_PREFIX.length) : ref;
|
|
1158
|
+
if (visited.has(refId)) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
visited.add(refId);
|
|
1162
|
+
const refEntity = this.get(refId);
|
|
1163
|
+
if (refEntity && refEntity.content) {
|
|
1164
|
+
const resolved = this.resolveSchemaFully(refEntity.content, visited);
|
|
1165
|
+
Object.assign(result.properties, resolved.properties);
|
|
1166
|
+
if (resolved.required) {
|
|
1167
|
+
result.required.push(...resolved.required);
|
|
1168
|
+
}
|
|
1169
|
+
if (resolved.additionalProperties !== undefined) {
|
|
1170
|
+
result.additionalProperties = resolved.additionalProperties;
|
|
1171
|
+
}
|
|
1172
|
+
if (resolved.type && !result.type) {
|
|
1173
|
+
result.type = resolved.type;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
} else {
|
|
1177
|
+
// Non-ref subschema - merge it
|
|
1178
|
+
const resolved = this.resolveSchemaFully(sub, visited);
|
|
1179
|
+
// For overlay properties, merge them (they override)
|
|
1180
|
+
for (const [propName, propSchema] of Object.entries(resolved.properties || {})) {
|
|
1181
|
+
if (result.properties[propName]) {
|
|
1182
|
+
// Merge property constraints - overlay tightens base
|
|
1183
|
+
result.properties[propName] = this.mergePropertySchemas(result.properties[propName], propSchema);
|
|
1184
|
+
} else {
|
|
1185
|
+
result.properties[propName] = propSchema;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (resolved.required) {
|
|
1189
|
+
result.required.push(...resolved.required);
|
|
1190
|
+
}
|
|
1191
|
+
if (resolved.additionalProperties !== undefined) {
|
|
1192
|
+
result.additionalProperties = resolved.additionalProperties;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Add direct properties
|
|
1199
|
+
if (schema.properties) {
|
|
1200
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
1201
|
+
if (result.properties[propName]) {
|
|
1202
|
+
result.properties[propName] = this.mergePropertySchemas(result.properties[propName], propSchema);
|
|
1203
|
+
} else {
|
|
1204
|
+
result.properties[propName] = propSchema;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Add direct required
|
|
1210
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
1211
|
+
result.required.push(...schema.required);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Direct additionalProperties
|
|
1215
|
+
if (schema.additionalProperties !== undefined) {
|
|
1216
|
+
result.additionalProperties = schema.additionalProperties;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Deduplicate required
|
|
1220
|
+
result.required = Array.from(new Set(result.required));
|
|
1221
|
+
|
|
1222
|
+
return result;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
private mergePropertySchemas(base: any, overlay: any): any {
|
|
1226
|
+
if (base === false || overlay === false) {
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
if (typeof base !== 'object' || typeof overlay !== 'object') {
|
|
1230
|
+
return overlay;
|
|
1231
|
+
}
|
|
1232
|
+
const merged: any = { ...base };
|
|
1233
|
+
for (const [key, val] of Object.entries(overlay)) {
|
|
1234
|
+
if (key === 'properties' && merged.properties) {
|
|
1235
|
+
merged.properties = { ...merged.properties, ...(val as any) };
|
|
1236
|
+
} else if (key === 'required' && merged.required) {
|
|
1237
|
+
const mergedReq = new Set([...(merged.required as string[]), ...(val as string[])]);
|
|
1238
|
+
merged.required = Array.from(mergedReq);
|
|
1239
|
+
} else {
|
|
1240
|
+
merged[key] = val;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return merged;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private extractOverlay(schema: any): ResolvedSchema {
|
|
1247
|
+
const overlay: ResolvedSchema = {
|
|
1248
|
+
properties: {},
|
|
1249
|
+
required: [],
|
|
1250
|
+
additionalProperties: undefined,
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
1254
|
+
for (const sub of schema.allOf) {
|
|
1255
|
+
const ref = sub['$$ref'] || sub['$ref'];
|
|
1256
|
+
if (typeof ref === 'string') {
|
|
1257
|
+
continue; // Skip ref subschemas
|
|
1258
|
+
}
|
|
1259
|
+
// This is a non-ref overlay subschema
|
|
1260
|
+
if (sub.properties) {
|
|
1261
|
+
for (const [propName, propSchema] of Object.entries(sub.properties)) {
|
|
1262
|
+
overlay.properties[propName] = overlay.properties[propName]
|
|
1263
|
+
? this.mergePropertySchemas(overlay.properties[propName], propSchema)
|
|
1264
|
+
: propSchema;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (sub.required && Array.isArray(sub.required)) {
|
|
1268
|
+
overlay.required.push(...sub.required);
|
|
1269
|
+
}
|
|
1270
|
+
if (sub.additionalProperties !== undefined) {
|
|
1271
|
+
overlay.additionalProperties = sub.additionalProperties;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Add top-level properties (outside allOf)
|
|
1277
|
+
if (schema.properties) {
|
|
1278
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
1279
|
+
overlay.properties[propName] = overlay.properties[propName]
|
|
1280
|
+
? this.mergePropertySchemas(overlay.properties[propName], propSchema)
|
|
1281
|
+
: propSchema;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (schema.required && Array.isArray(schema.required)) {
|
|
1285
|
+
overlay.required.push(...schema.required);
|
|
1286
|
+
}
|
|
1287
|
+
if (schema.additionalProperties !== undefined && overlay.additionalProperties === undefined) {
|
|
1288
|
+
overlay.additionalProperties = schema.additionalProperties;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
return overlay;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private compareOverlayToBase(overlay: ResolvedSchema, baseResolved: ResolvedSchema, path: string): string[] {
|
|
1295
|
+
const errors: string[] = [];
|
|
1296
|
+
const overlayProps = overlay.properties || {};
|
|
1297
|
+
const baseProps = baseResolved.properties || {};
|
|
1298
|
+
|
|
1299
|
+
for (const [propName, propSchema] of Object.entries(overlayProps)) {
|
|
1300
|
+
const propPath = path ? `${path}.${propName}` : propName;
|
|
1301
|
+
|
|
1302
|
+
// Property schema set to false
|
|
1303
|
+
if (propSchema === false) {
|
|
1304
|
+
if (baseProps[propName] !== undefined) {
|
|
1305
|
+
errors.push(`Property '${propPath}' is set to false but exists in base`);
|
|
1306
|
+
}
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const baseProp = baseProps[propName];
|
|
1311
|
+
|
|
1312
|
+
if (baseProp === undefined || baseProp === null) {
|
|
1313
|
+
// New property not in base
|
|
1314
|
+
if (baseResolved.additionalProperties === false) {
|
|
1315
|
+
errors.push(`Property '${propPath}' not in base and base has additionalProperties: false`);
|
|
1316
|
+
}
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (baseProp === false) {
|
|
1321
|
+
// Base already set property to false, overlay can't use it
|
|
1322
|
+
errors.push(`Property '${propPath}' is forbidden in base`);
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Both base and overlay have this property — compare constraints
|
|
1327
|
+
if (typeof propSchema === 'object' && propSchema !== null) {
|
|
1328
|
+
errors.push(...this.comparePropertyConstraints(propSchema, baseProp, propPath));
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Check additionalProperties
|
|
1333
|
+
if (baseResolved.additionalProperties === false) {
|
|
1334
|
+
if (overlay.additionalProperties === true) {
|
|
1335
|
+
errors.push('Cannot loosen additionalProperties from false to true');
|
|
1336
|
+
} else if (overlay.additionalProperties === undefined) {
|
|
1337
|
+
errors.push('Base has additionalProperties: false but derived does not restate it');
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
return errors;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private comparePropertyConstraints(derived: any, base: any, propPath: string): string[] {
|
|
1345
|
+
const errors: string[] = [];
|
|
1346
|
+
|
|
1347
|
+
if (typeof base !== 'object' || base === null) {
|
|
1348
|
+
return errors;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Type check
|
|
1352
|
+
const baseType = base.type;
|
|
1353
|
+
const derivedType = derived.type;
|
|
1354
|
+
if (baseType !== undefined && derivedType !== undefined) {
|
|
1355
|
+
if (Array.isArray(derivedType)) {
|
|
1356
|
+
// Derived has array type — widening (fail)
|
|
1357
|
+
if (!Array.isArray(baseType)) {
|
|
1358
|
+
errors.push(`Property '${propPath}' widens type from '${baseType}' to array`);
|
|
1359
|
+
return errors;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (Array.isArray(baseType)) {
|
|
1363
|
+
if (!Array.isArray(derivedType)) {
|
|
1364
|
+
// Could be narrowing from array type
|
|
1365
|
+
if (!baseType.includes(derivedType)) {
|
|
1366
|
+
errors.push(`Property '${propPath}' type '${derivedType}' not in base types [${baseType}]`);
|
|
1367
|
+
return errors;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
} else if (!Array.isArray(derivedType)) {
|
|
1371
|
+
// Both scalar types
|
|
1372
|
+
if (baseType !== derivedType) {
|
|
1373
|
+
errors.push(`Property '${propPath}' type changed from '${baseType}' to '${derivedType}'`);
|
|
1374
|
+
return errors;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Determine if the overlay adds any NEW constraint keywords not in the base.
|
|
1380
|
+
// Under allOf semantics, base constraints are preserved. Drops are only flagged
|
|
1381
|
+
// when the overlay doesn't introduce any new tightening constraints.
|
|
1382
|
+
const CONSTRAINT_KEYWORDS = [
|
|
1383
|
+
'maxLength',
|
|
1384
|
+
'minLength',
|
|
1385
|
+
'maximum',
|
|
1386
|
+
'minimum',
|
|
1387
|
+
'maxItems',
|
|
1388
|
+
'minItems',
|
|
1389
|
+
'enum',
|
|
1390
|
+
'const',
|
|
1391
|
+
'pattern',
|
|
1392
|
+
'items',
|
|
1393
|
+
];
|
|
1394
|
+
const baseConstraintKeys = new Set(CONSTRAINT_KEYWORDS.filter((kw) => base[kw] !== undefined));
|
|
1395
|
+
const derivedConstraintKeys = new Set(CONSTRAINT_KEYWORDS.filter((kw) => derived[kw] !== undefined));
|
|
1396
|
+
const hasNewConstraints = [...derivedConstraintKeys].some((kw) => !baseConstraintKeys.has(kw));
|
|
1397
|
+
|
|
1398
|
+
// Max constraints (tightening = lower value OK; loosening = higher value FAIL)
|
|
1399
|
+
for (const kw of ['maxLength', 'maximum', 'maxItems']) {
|
|
1400
|
+
if (base[kw] !== undefined) {
|
|
1401
|
+
if (derived[kw] === undefined) {
|
|
1402
|
+
if (!hasNewConstraints) {
|
|
1403
|
+
errors.push(`Property '${propPath}' drops constraint '${kw}'`);
|
|
1404
|
+
}
|
|
1405
|
+
} else if (derived[kw] > base[kw]) {
|
|
1406
|
+
errors.push(`Property '${propPath}' loosens '${kw}' from ${base[kw]} to ${derived[kw]}`);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Min constraints (tightening = higher value OK; loosening = lower value FAIL)
|
|
1412
|
+
for (const kw of ['minLength', 'minimum', 'minItems']) {
|
|
1413
|
+
if (base[kw] !== undefined) {
|
|
1414
|
+
if (derived[kw] === undefined) {
|
|
1415
|
+
if (!hasNewConstraints) {
|
|
1416
|
+
errors.push(`Property '${propPath}' drops constraint '${kw}'`);
|
|
1417
|
+
}
|
|
1418
|
+
} else if (derived[kw] < base[kw]) {
|
|
1419
|
+
errors.push(`Property '${propPath}' loosens '${kw}' from ${base[kw]} to ${derived[kw]}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Enum check
|
|
1425
|
+
if (base.enum !== undefined) {
|
|
1426
|
+
if (derived.enum === undefined) {
|
|
1427
|
+
if (!hasNewConstraints) {
|
|
1428
|
+
errors.push(`Property '${propPath}' drops constraint 'enum'`);
|
|
1429
|
+
}
|
|
1430
|
+
} else {
|
|
1431
|
+
const baseSet = new Set(base.enum.map((v: any) => JSON.stringify(v)));
|
|
1432
|
+
for (const val of derived.enum) {
|
|
1433
|
+
if (!baseSet.has(JSON.stringify(val))) {
|
|
1434
|
+
errors.push(`Property '${propPath}' enum value '${val}' not in base enum`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Const check
|
|
1441
|
+
if (base.const !== undefined) {
|
|
1442
|
+
if (derived.const === undefined) {
|
|
1443
|
+
if (!hasNewConstraints) {
|
|
1444
|
+
errors.push(`Property '${propPath}' drops constraint 'const'`);
|
|
1445
|
+
}
|
|
1446
|
+
} else if (JSON.stringify(base.const) !== JSON.stringify(derived.const)) {
|
|
1447
|
+
errors.push(
|
|
1448
|
+
`Property '${propPath}' const conflict: ${JSON.stringify(derived.const)} vs base ${JSON.stringify(base.const)}`
|
|
1449
|
+
);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
// Check const in derived against base numeric constraints
|
|
1453
|
+
if (derived.const !== undefined && typeof derived.const === 'number') {
|
|
1454
|
+
if (base.minimum !== undefined && derived.const < base.minimum) {
|
|
1455
|
+
errors.push(`Property '${propPath}' const ${derived.const} violates base minimum ${base.minimum}`);
|
|
1456
|
+
}
|
|
1457
|
+
if (base.maximum !== undefined && derived.const > base.maximum) {
|
|
1458
|
+
errors.push(`Property '${propPath}' const ${derived.const} violates base maximum ${base.maximum}`);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Pattern check
|
|
1463
|
+
if (base.pattern !== undefined) {
|
|
1464
|
+
if (derived.pattern === undefined) {
|
|
1465
|
+
if (!hasNewConstraints) {
|
|
1466
|
+
errors.push(`Property '${propPath}' drops constraint 'pattern'`);
|
|
1467
|
+
}
|
|
1468
|
+
} else if (base.pattern !== derived.pattern) {
|
|
1469
|
+
errors.push(`Property '${propPath}' pattern changed from '${base.pattern}' to '${derived.pattern}'`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Items check (array items)
|
|
1474
|
+
if (base.items !== undefined) {
|
|
1475
|
+
if (derived.items === undefined) {
|
|
1476
|
+
if (!hasNewConstraints) {
|
|
1477
|
+
errors.push(`Property '${propPath}' drops constraint 'items'`);
|
|
1478
|
+
}
|
|
1479
|
+
} else if (typeof base.items === 'object' && typeof derived.items === 'object') {
|
|
1480
|
+
errors.push(...this.comparePropertyConstraints(derived.items, base.items, `${propPath}.items`));
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Nested object: recursively compare
|
|
1485
|
+
if (base.type === 'object' && derived.type === 'object') {
|
|
1486
|
+
if (base.properties || derived.properties) {
|
|
1487
|
+
const nestedOverlay = {
|
|
1488
|
+
properties: derived.properties || {},
|
|
1489
|
+
required: derived.required || [],
|
|
1490
|
+
additionalProperties: derived.additionalProperties,
|
|
1491
|
+
};
|
|
1492
|
+
const nestedBase = {
|
|
1493
|
+
properties: base.properties || {},
|
|
1494
|
+
required: base.required || [],
|
|
1495
|
+
additionalProperties: base.additionalProperties,
|
|
1496
|
+
};
|
|
1497
|
+
errors.push(...this.compareOverlayToBase(nestedOverlay, nestedBase, propPath));
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
return errors;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1056
1504
|
getAttribute(gtsId: string, path: string): any {
|
|
1057
1505
|
const entity = this.get(gtsId);
|
|
1058
1506
|
if (!entity) {
|
package/src/types.ts
CHANGED
|
@@ -82,12 +82,20 @@ export interface RelationshipResult {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export interface CompatibilityResult {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
from: string;
|
|
86
|
+
to: string;
|
|
87
|
+
old: string;
|
|
88
|
+
new: string;
|
|
89
|
+
direction: string;
|
|
90
|
+
added_properties: string[];
|
|
91
|
+
removed_properties: string[];
|
|
92
|
+
changed_properties: Array<Record<string, string>>;
|
|
93
|
+
is_fully_compatible: boolean;
|
|
94
|
+
is_backward_compatible: boolean;
|
|
95
|
+
is_forward_compatible: boolean;
|
|
96
|
+
incompatibility_reasons: string[];
|
|
97
|
+
backward_errors: string[];
|
|
98
|
+
forward_errors: string[];
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
export interface CastResult {
|
package/src/x-gts-ref.ts
CHANGED
|
@@ -81,6 +81,56 @@ export class XGtsRefValidator {
|
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
// Recurse into combinator subschemas
|
|
86
|
+
if (Array.isArray(schema.allOf)) {
|
|
87
|
+
for (const subSchema of schema.allOf) {
|
|
88
|
+
this.visitInstance(instance, subSchema, path, rootSchema, errors);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(schema.anyOf)) {
|
|
93
|
+
// Only enforce when all branches have x-gts-ref; mixed branches may be valid via non-x-gts-ref path (Ajv handles that)
|
|
94
|
+
const refBranches = schema.anyOf.filter((s: any) => this.containsXGtsRef(s));
|
|
95
|
+
if (refBranches.length > 0 && refBranches.length === schema.anyOf.length) {
|
|
96
|
+
const branchResults = refBranches.map((subSchema: any) => {
|
|
97
|
+
const branchErrors: XGtsRefValidationError[] = [];
|
|
98
|
+
this.visitInstance(instance, subSchema, path, rootSchema, branchErrors);
|
|
99
|
+
return branchErrors;
|
|
100
|
+
});
|
|
101
|
+
const anyPassed = branchResults.some((errs: XGtsRefValidationError[]) => errs.length === 0);
|
|
102
|
+
if (!anyPassed) {
|
|
103
|
+
for (const branchErrors of branchResults) {
|
|
104
|
+
errors.push(...branchErrors);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(schema.oneOf)) {
|
|
111
|
+
// Only enforce when all branches have x-gts-ref; mixed branches can't be coordinated with Ajv's branch selection
|
|
112
|
+
const refBranches = schema.oneOf.filter((s: any) => this.containsXGtsRef(s));
|
|
113
|
+
if (refBranches.length > 0 && refBranches.length === schema.oneOf.length) {
|
|
114
|
+
const branchResults = refBranches.map((subSchema: any) => {
|
|
115
|
+
const branchErrors: XGtsRefValidationError[] = [];
|
|
116
|
+
this.visitInstance(instance, subSchema, path, rootSchema, branchErrors);
|
|
117
|
+
return branchErrors;
|
|
118
|
+
});
|
|
119
|
+
const passingCount = branchResults.filter((errs: XGtsRefValidationError[]) => errs.length === 0).length;
|
|
120
|
+
if (passingCount === 0) {
|
|
121
|
+
for (const branchErrors of branchResults) {
|
|
122
|
+
errors.push(...branchErrors);
|
|
123
|
+
}
|
|
124
|
+
} else if (passingCount > 1) {
|
|
125
|
+
errors.push({
|
|
126
|
+
fieldPath: path || '/',
|
|
127
|
+
value: instance,
|
|
128
|
+
refPattern: '',
|
|
129
|
+
reason: `Value matches ${passingCount} oneOf branches but must match exactly one`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
private visitSchema(schema: any, path: string, rootSchema: any, errors: XGtsRefValidationError[]): void {
|
|
@@ -300,6 +350,19 @@ export class XGtsRefValidator {
|
|
|
300
350
|
return null;
|
|
301
351
|
}
|
|
302
352
|
|
|
353
|
+
private containsXGtsRef(schema: any): boolean {
|
|
354
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
355
|
+
if (schema['x-gts-ref'] !== undefined) return true;
|
|
356
|
+
for (const value of Object.values(schema)) {
|
|
357
|
+
if (Array.isArray(value)) {
|
|
358
|
+
if (value.some((item) => this.containsXGtsRef(item))) return true;
|
|
359
|
+
} else if (value && typeof value === 'object') {
|
|
360
|
+
if (this.containsXGtsRef(value)) return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
303
366
|
/**
|
|
304
367
|
* Strip the "gts://" prefix from a value if present
|
|
305
368
|
*/
|
package/tests/gts.test.ts
CHANGED
|
@@ -275,7 +275,7 @@ describe('GTS Store Operations', () => {
|
|
|
275
275
|
gts.register(schemaV2);
|
|
276
276
|
|
|
277
277
|
const result = gts.checkCompatibility('gts.test.pkg.ns.person.v1~', 'gts.test.pkg.ns.person.v2~', 'backward');
|
|
278
|
-
expect(result.
|
|
278
|
+
expect(result.is_fully_compatible).toBe(true);
|
|
279
279
|
});
|
|
280
280
|
|
|
281
281
|
test('detects incompatible changes', () => {
|
|
@@ -303,8 +303,8 @@ describe('GTS Store Operations', () => {
|
|
|
303
303
|
gts.register(schemaV2);
|
|
304
304
|
|
|
305
305
|
const result = gts.checkCompatibility('gts.test.pkg.ns.person.v1~', 'gts.test.pkg.ns.person.v2~', 'backward');
|
|
306
|
-
expect(result.
|
|
307
|
-
expect(result.
|
|
306
|
+
expect(result.is_fully_compatible).toBe(false);
|
|
307
|
+
expect(result.incompatibility_reasons.length).toBeGreaterThan(0);
|
|
308
308
|
});
|
|
309
309
|
});
|
|
310
310
|
|
|
@@ -414,6 +414,8 @@ describe('GTS Store Operations', () => {
|
|
|
414
414
|
});
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
// x-gts-ref combinator tests (oneOf/anyOf/allOf) are in the canonical gts-spec test suite
|
|
418
|
+
|
|
417
419
|
describe('OP#12 - Wildcard Validation (v0.7)', () => {
|
|
418
420
|
test('validates wildcard patterns', () => {
|
|
419
421
|
const result = validateGtsID('gts.vendor.pkg.*');
|