@featurevisor/core 2.17.0 → 2.18.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/CHANGELOG.md +11 -0
- package/coverage/clover.xml +798 -764
- package/coverage/coverage-final.json +5 -5
- package/coverage/lcov-report/builder/allocator.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
- package/coverage/lcov-report/builder/index.html +1 -1
- package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
- package/coverage/lcov-report/builder/mutator.ts.html +18 -18
- package/coverage/lcov-report/builder/revision.ts.html +1 -1
- package/coverage/lcov-report/builder/traffic.ts.html +1 -1
- package/coverage/lcov-report/config/index.html +1 -1
- package/coverage/lcov-report/config/index.ts.html +1 -1
- package/coverage/lcov-report/config/projectConfig.ts.html +1 -1
- package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
- package/coverage/lcov-report/datasource/datasource.ts.html +1 -1
- package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +1 -1
- package/coverage/lcov-report/datasource/index.html +1 -1
- package/coverage/lcov-report/datasource/index.ts.html +1 -1
- package/coverage/lcov-report/index.html +18 -18
- package/coverage/lcov-report/linter/attributeSchema.ts.html +1 -1
- package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
- package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
- package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
- package/coverage/lcov-report/linter/featureSchema.ts.html +365 -188
- package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
- package/coverage/lcov-report/linter/index.html +34 -34
- package/coverage/lcov-report/linter/lintProject.ts.html +1 -1
- package/coverage/lcov-report/linter/mutationNotation.ts.html +99 -66
- package/coverage/lcov-report/linter/printError.ts.html +1 -1
- package/coverage/lcov-report/linter/schema.ts.html +170 -80
- package/coverage/lcov-report/linter/segmentSchema.ts.html +1 -1
- package/coverage/lcov-report/linter/testSchema.ts.html +1 -1
- package/coverage/lcov-report/list/index.html +1 -1
- package/coverage/lcov-report/list/matrix.ts.html +1 -1
- package/coverage/lcov-report/parsers/index.html +1 -1
- package/coverage/lcov-report/parsers/index.ts.html +1 -1
- package/coverage/lcov-report/parsers/json.ts.html +1 -1
- package/coverage/lcov-report/parsers/yml.ts.html +1 -1
- package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
- package/coverage/lcov-report/tester/helpers.ts.html +1 -1
- package/coverage/lcov-report/tester/index.html +1 -1
- package/coverage/lcov-report/utils/git.ts.html +1 -1
- package/coverage/lcov-report/utils/index.html +1 -1
- package/coverage/lcov.info +1653 -1567
- package/json-schema/attribute.json +25 -9
- package/json-schema/feature.json +319 -238
- package/json-schema/segment.json +76 -45
- package/lib/generate-code/typescript.js +63 -20
- package/lib/generate-code/typescript.js.map +1 -1
- package/lib/linter/featureSchema.d.ts +9 -0
- package/lib/linter/featureSchema.js +38 -8
- package/lib/linter/featureSchema.js.map +1 -1
- package/lib/linter/featureSchema.spec.js +93 -0
- package/lib/linter/featureSchema.spec.js.map +1 -1
- package/lib/linter/mutationNotation.js +21 -11
- package/lib/linter/mutationNotation.js.map +1 -1
- package/lib/linter/mutationNotation.spec.js +18 -0
- package/lib/linter/mutationNotation.spec.js.map +1 -1
- package/lib/linter/schema.d.ts +1 -0
- package/lib/linter/schema.js +13 -0
- package/lib/linter/schema.js.map +1 -1
- package/lib/linter/schema.spec.js +51 -0
- package/lib/linter/schema.spec.js.map +1 -1
- package/package.json +5 -5
- package/src/generate-code/typescript.ts +87 -20
- package/src/linter/featureSchema.spec.ts +118 -0
- package/src/linter/featureSchema.ts +65 -6
- package/src/linter/mutationNotation.spec.ts +23 -0
- package/src/linter/mutationNotation.ts +18 -7
- package/src/linter/schema.spec.ts +72 -0
- package/src/linter/schema.ts +30 -0
|
@@ -48,6 +48,63 @@ function formatObjectKey(key: string): string {
|
|
|
48
48
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function buildObjectTypeFromSchema(
|
|
52
|
+
schema: Schema,
|
|
53
|
+
schemasByKey: Record<string, Schema>,
|
|
54
|
+
schemaTypeNames?: Record<string, string>,
|
|
55
|
+
): string {
|
|
56
|
+
const props = schema.properties;
|
|
57
|
+
const additional = schema.additionalProperties;
|
|
58
|
+
const hasProps = props && typeof props === "object" && Object.keys(props).length > 0;
|
|
59
|
+
const hasAdditional = additional && typeof additional === "object";
|
|
60
|
+
|
|
61
|
+
if (hasProps && hasAdditional) {
|
|
62
|
+
const requiredSet = new Set(schema.required || []);
|
|
63
|
+
const propEntries = Object.entries(props as Record<string, Schema>).map(([k, v]) => {
|
|
64
|
+
const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
|
|
65
|
+
const optional = !requiredSet.has(k);
|
|
66
|
+
return optional
|
|
67
|
+
? `${formatObjectKey(k)}?: ${propType}`
|
|
68
|
+
: `${formatObjectKey(k)}: ${propType}`;
|
|
69
|
+
});
|
|
70
|
+
const propUnion = Object.entries(props as Record<string, Schema>)
|
|
71
|
+
.map(([, v]) => schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames))
|
|
72
|
+
.join(" | ");
|
|
73
|
+
const additionalType = schemaToTypeScriptType(
|
|
74
|
+
additional as Schema,
|
|
75
|
+
schemasByKey,
|
|
76
|
+
schemaTypeNames,
|
|
77
|
+
);
|
|
78
|
+
const indexType = [additionalType, propUnion].filter(Boolean).join(" | ");
|
|
79
|
+
return `{ ${propEntries.join("; ")}; [key: string]: ${indexType} }`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (hasProps) {
|
|
83
|
+
const requiredSet = new Set(schema.required || []);
|
|
84
|
+
const entries = Object.entries(props as Record<string, Schema>)
|
|
85
|
+
.map(([k, v]) => {
|
|
86
|
+
const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
|
|
87
|
+
const optional = !requiredSet.has(k);
|
|
88
|
+
return optional
|
|
89
|
+
? `${formatObjectKey(k)}?: ${propType}`
|
|
90
|
+
: `${formatObjectKey(k)}: ${propType}`;
|
|
91
|
+
})
|
|
92
|
+
.join("; ");
|
|
93
|
+
return `{ ${entries} }`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (hasAdditional) {
|
|
97
|
+
const additionalType = schemaToTypeScriptType(
|
|
98
|
+
additional as Schema,
|
|
99
|
+
schemasByKey,
|
|
100
|
+
schemaTypeNames,
|
|
101
|
+
);
|
|
102
|
+
return `Record<string, ${additionalType}>`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return "Record<string, unknown>";
|
|
106
|
+
}
|
|
107
|
+
|
|
51
108
|
function formatTypeImport(typeNames: string[], fromPath: string): string {
|
|
52
109
|
if (typeNames.length === 0) {
|
|
53
110
|
return "";
|
|
@@ -132,19 +189,7 @@ function schemaToTypeScriptType(
|
|
|
132
189
|
}
|
|
133
190
|
return "string[]";
|
|
134
191
|
case "object": {
|
|
135
|
-
|
|
136
|
-
if (props && typeof props === "object" && Object.keys(props).length > 0) {
|
|
137
|
-
const requiredSet = new Set(resolved.required || []);
|
|
138
|
-
const entries = Object.entries(props)
|
|
139
|
-
.map(([k, v]) => {
|
|
140
|
-
const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
|
|
141
|
-
const optional = !requiredSet.has(k);
|
|
142
|
-
return optional ? `${k}?: ${propType}` : `${k}: ${propType}`;
|
|
143
|
-
})
|
|
144
|
-
.join("; ");
|
|
145
|
-
return `{ ${entries} }`;
|
|
146
|
-
}
|
|
147
|
-
return "Record<string, unknown>";
|
|
192
|
+
return buildObjectTypeFromSchema(resolved, schemasByKey, schemaTypeNames);
|
|
148
193
|
}
|
|
149
194
|
default:
|
|
150
195
|
return "unknown";
|
|
@@ -265,13 +310,17 @@ function generateVariableTypeDeclarations(
|
|
|
265
310
|
|
|
266
311
|
if (type === "object") {
|
|
267
312
|
const resolvedEffective =
|
|
268
|
-
effective && "properties" in effective
|
|
313
|
+
effective && ("properties" in effective || "additionalProperties" in effective)
|
|
269
314
|
? (resolveSchema(effective as Schema, schemasByKey) as Schema)
|
|
270
315
|
: undefined;
|
|
271
316
|
const props = resolvedEffective?.properties;
|
|
272
|
-
|
|
317
|
+
const additional = resolvedEffective?.additionalProperties;
|
|
318
|
+
if (
|
|
319
|
+
(props && typeof props === "object" && Object.keys(props).length > 0) ||
|
|
320
|
+
(additional && typeof additional === "object")
|
|
321
|
+
) {
|
|
273
322
|
const requiredSet = new Set(resolvedEffective?.required || []);
|
|
274
|
-
const entries = Object.entries(props)
|
|
323
|
+
const entries = Object.entries(props ?? {})
|
|
275
324
|
.map(([k, v]) => {
|
|
276
325
|
const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
|
|
277
326
|
if (schemaTypeNames)
|
|
@@ -280,11 +329,29 @@ function generateVariableTypeDeclarations(
|
|
|
280
329
|
});
|
|
281
330
|
const optional = !requiredSet.has(k);
|
|
282
331
|
return optional
|
|
283
|
-
? `${INDENT_NS_BODY}${k}?: ${propType};`
|
|
284
|
-
: `${INDENT_NS_BODY}${k}: ${propType};`;
|
|
332
|
+
? `${INDENT_NS_BODY}${formatObjectKey(k)}?: ${propType};`
|
|
333
|
+
: `${INDENT_NS_BODY}${formatObjectKey(k)}: ${propType};`;
|
|
285
334
|
})
|
|
286
|
-
.
|
|
287
|
-
|
|
335
|
+
.filter(Boolean);
|
|
336
|
+
if (additional && typeof additional === "object") {
|
|
337
|
+
const additionalType = schemaToTypeScriptType(
|
|
338
|
+
additional as Schema,
|
|
339
|
+
schemasByKey,
|
|
340
|
+
schemaTypeNames,
|
|
341
|
+
);
|
|
342
|
+
if (schemaTypeNames)
|
|
343
|
+
Object.values(schemaTypeNames).forEach((n) => {
|
|
344
|
+
if (additionalType.includes(n)) addSchemaUsed(n);
|
|
345
|
+
});
|
|
346
|
+
const propUnion = Object.entries(props ?? {})
|
|
347
|
+
.map(([, v]) => schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames))
|
|
348
|
+
.join(" | ");
|
|
349
|
+
const indexType = [additionalType, propUnion].filter(Boolean).join(" | ");
|
|
350
|
+
entries.push(`${INDENT_NS_BODY}[key: string]: ${indexType};`);
|
|
351
|
+
}
|
|
352
|
+
declarations.push(
|
|
353
|
+
`${INDENT_NS}export interface ${typeName} {\n${entries.join("\n")}\n${INDENT_NS}}`,
|
|
354
|
+
);
|
|
288
355
|
return { declarations, returnTypeName: typeName, genericArg: typeName, schemaTypesUsed };
|
|
289
356
|
}
|
|
290
357
|
declarations.push(`${INDENT_NS}export type ${typeName} = Record<string, unknown>;`);
|
|
@@ -852,6 +852,89 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
|
|
|
852
852
|
);
|
|
853
853
|
});
|
|
854
854
|
|
|
855
|
+
it("accepts object variable with additionalProperties only and arbitrary keys", () => {
|
|
856
|
+
expectParseSuccess(
|
|
857
|
+
baseFeature({
|
|
858
|
+
variablesSchema: {
|
|
859
|
+
labels: {
|
|
860
|
+
type: "object",
|
|
861
|
+
additionalProperties: { type: "string" },
|
|
862
|
+
defaultValue: { title: "Welcome", subtitle: "Hello" },
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
}),
|
|
866
|
+
);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("accepts object variable with properties and additionalProperties together", () => {
|
|
870
|
+
expectParseSuccess(
|
|
871
|
+
baseFeature({
|
|
872
|
+
variablesSchema: {
|
|
873
|
+
metadata: {
|
|
874
|
+
type: "object",
|
|
875
|
+
properties: {
|
|
876
|
+
fixed: { type: "integer" },
|
|
877
|
+
},
|
|
878
|
+
additionalProperties: { type: "string" },
|
|
879
|
+
required: ["fixed"],
|
|
880
|
+
defaultValue: { fixed: 1, dynamicKey: "value" },
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
}),
|
|
884
|
+
);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("rejects object variable when unknown key is present and additionalProperties is not defined", () => {
|
|
888
|
+
expectParseFailure(
|
|
889
|
+
baseFeature({
|
|
890
|
+
variablesSchema: {
|
|
891
|
+
settings: {
|
|
892
|
+
type: "object",
|
|
893
|
+
properties: {
|
|
894
|
+
theme: { type: "string" },
|
|
895
|
+
},
|
|
896
|
+
defaultValue: { theme: "light", subtitle: "hello" },
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
}),
|
|
900
|
+
"Unknown property",
|
|
901
|
+
);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("accepts object variable with additionalProperties that references reusable schema", () => {
|
|
905
|
+
expectParseSuccess(
|
|
906
|
+
baseFeature({
|
|
907
|
+
variablesSchema: {
|
|
908
|
+
linksByLocale: {
|
|
909
|
+
type: "object",
|
|
910
|
+
additionalProperties: { schema: "link" },
|
|
911
|
+
defaultValue: {
|
|
912
|
+
en: { title: "Home", url: "/" },
|
|
913
|
+
de: { title: "Start", url: "/de" },
|
|
914
|
+
},
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
}),
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it("rejects object variable when additionalProperties value does not match schema", () => {
|
|
922
|
+
expectParseFailure(
|
|
923
|
+
baseFeature({
|
|
924
|
+
variablesSchema: {
|
|
925
|
+
linksByLocale: {
|
|
926
|
+
type: "object",
|
|
927
|
+
additionalProperties: { schema: "link" },
|
|
928
|
+
defaultValue: {
|
|
929
|
+
en: { title: "Home" },
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
}),
|
|
934
|
+
"Missing required property",
|
|
935
|
+
);
|
|
936
|
+
});
|
|
937
|
+
|
|
855
938
|
it("rejects inline object variable when defaultValue is missing required property", () => {
|
|
856
939
|
expectParseFailure(
|
|
857
940
|
baseFeature({
|
|
@@ -1417,5 +1500,40 @@ describe("featureSchema.ts :: getFeatureZodSchema (variablesSchema and variable
|
|
|
1417
1500
|
},
|
|
1418
1501
|
);
|
|
1419
1502
|
});
|
|
1503
|
+
|
|
1504
|
+
it("additionalProperties type mismatch: error path includes dynamic object key", () => {
|
|
1505
|
+
expectErrorSurfaces(
|
|
1506
|
+
baseFeature({
|
|
1507
|
+
variablesSchema: {
|
|
1508
|
+
labels: {
|
|
1509
|
+
type: "object",
|
|
1510
|
+
additionalProperties: { type: "string" },
|
|
1511
|
+
defaultValue: {
|
|
1512
|
+
title: 123,
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
}),
|
|
1517
|
+
{
|
|
1518
|
+
pathContains: ["variablesSchema", "labels", "defaultValue", "title"],
|
|
1519
|
+
messageContains: "type string",
|
|
1520
|
+
},
|
|
1521
|
+
);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it("rejects variable schema with schema reference mixed with inline additionalProperties", () => {
|
|
1525
|
+
expectParseFailure(
|
|
1526
|
+
baseFeature({
|
|
1527
|
+
variablesSchema: {
|
|
1528
|
+
myLink: {
|
|
1529
|
+
schema: "link",
|
|
1530
|
+
additionalProperties: { type: "string" },
|
|
1531
|
+
defaultValue: { title: "Home", url: "/" },
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
}),
|
|
1535
|
+
"additionalProperties",
|
|
1536
|
+
);
|
|
1537
|
+
});
|
|
1420
1538
|
});
|
|
1421
1539
|
});
|
|
@@ -51,6 +51,7 @@ function resolveVariableSchema(
|
|
|
51
51
|
type?: string;
|
|
52
52
|
items?: unknown;
|
|
53
53
|
properties?: unknown;
|
|
54
|
+
additionalProperties?: unknown;
|
|
54
55
|
required?: string[];
|
|
55
56
|
enum?: unknown[];
|
|
56
57
|
const?: unknown;
|
|
@@ -69,6 +70,7 @@ function resolveVariableSchema(
|
|
|
69
70
|
type?: string;
|
|
70
71
|
items?: unknown;
|
|
71
72
|
properties?: unknown;
|
|
73
|
+
additionalProperties?: unknown;
|
|
72
74
|
required?: string[];
|
|
73
75
|
enum?: unknown[];
|
|
74
76
|
const?: unknown;
|
|
@@ -89,6 +91,7 @@ function resolveVariableSchema(
|
|
|
89
91
|
type?: string;
|
|
90
92
|
items?: unknown;
|
|
91
93
|
properties?: unknown;
|
|
94
|
+
additionalProperties?: unknown;
|
|
92
95
|
required?: string[];
|
|
93
96
|
enum?: unknown[];
|
|
94
97
|
const?: unknown;
|
|
@@ -133,6 +136,7 @@ function valueMatchesSchema(
|
|
|
133
136
|
enum?: unknown[];
|
|
134
137
|
oneOf?: unknown[];
|
|
135
138
|
properties?: Record<string, unknown>;
|
|
139
|
+
additionalProperties?: unknown;
|
|
136
140
|
required?: string[];
|
|
137
141
|
items?: unknown;
|
|
138
142
|
minimum?: number;
|
|
@@ -195,8 +199,16 @@ function valueMatchesSchema(
|
|
|
195
199
|
if (type === "object") {
|
|
196
200
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
197
201
|
const props = resolved.properties;
|
|
198
|
-
|
|
202
|
+
const additional = resolved.additionalProperties;
|
|
199
203
|
const obj = value as Record<string, unknown>;
|
|
204
|
+
if ((!props || typeof props !== "object") && (!additional || typeof additional !== "object")) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (!props || typeof props !== "object") {
|
|
208
|
+
return Object.keys(obj).every((key) =>
|
|
209
|
+
valueMatchesSchema(additional as { [k: string]: unknown }, obj[key], schemasByKey),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
200
212
|
const required = new Set(resolved.required || []);
|
|
201
213
|
for (const key of required) {
|
|
202
214
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) return false;
|
|
@@ -205,9 +217,15 @@ function valueMatchesSchema(
|
|
|
205
217
|
}
|
|
206
218
|
for (const key of Object.keys(obj)) {
|
|
207
219
|
const propSchema = props[key];
|
|
208
|
-
if (
|
|
209
|
-
|
|
220
|
+
if (propSchema) {
|
|
221
|
+
if (!valueMatchesSchema(propSchema as { [k: string]: unknown }, obj[key], schemasByKey))
|
|
222
|
+
return false;
|
|
223
|
+
} else if (additional && typeof additional === "object") {
|
|
224
|
+
if (!valueMatchesSchema(additional as { [k: string]: unknown }, obj[key], schemasByKey))
|
|
225
|
+
return false;
|
|
226
|
+
} else {
|
|
210
227
|
return false;
|
|
228
|
+
}
|
|
211
229
|
}
|
|
212
230
|
return true;
|
|
213
231
|
}
|
|
@@ -259,6 +277,7 @@ function valueDeepEqual(a: unknown, b: unknown): boolean {
|
|
|
259
277
|
type SchemaLikeForRequired = {
|
|
260
278
|
type?: string;
|
|
261
279
|
properties?: Record<string, unknown>;
|
|
280
|
+
additionalProperties?: unknown;
|
|
262
281
|
required?: string[];
|
|
263
282
|
items?: unknown;
|
|
264
283
|
schema?: string;
|
|
@@ -325,6 +344,19 @@ function refineRequiredKeysInSchema(
|
|
|
325
344
|
}
|
|
326
345
|
}
|
|
327
346
|
}
|
|
347
|
+
const additionalProperties = (current as { additionalProperties?: unknown }).additionalProperties;
|
|
348
|
+
if (
|
|
349
|
+
additionalProperties &&
|
|
350
|
+
typeof additionalProperties === "object" &&
|
|
351
|
+
!Array.isArray(additionalProperties)
|
|
352
|
+
) {
|
|
353
|
+
refineRequiredKeysInSchema(
|
|
354
|
+
additionalProperties as SchemaLikeForRequired,
|
|
355
|
+
[...pathPrefix, "additionalProperties"],
|
|
356
|
+
ctx,
|
|
357
|
+
schemasByKey,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
328
360
|
|
|
329
361
|
if (items && typeof items === "object" && !Array.isArray(items)) {
|
|
330
362
|
refineRequiredKeysInSchema(
|
|
@@ -453,6 +485,7 @@ function refineVariableValueObject(
|
|
|
453
485
|
projectConfig: ProjectConfig,
|
|
454
486
|
variableSchema: {
|
|
455
487
|
properties?: Record<string, unknown>;
|
|
488
|
+
additionalProperties?: unknown;
|
|
456
489
|
required?: string[];
|
|
457
490
|
type: string;
|
|
458
491
|
},
|
|
@@ -464,6 +497,7 @@ function refineVariableValueObject(
|
|
|
464
497
|
): void {
|
|
465
498
|
const label = getVariableLabel(variableSchema, variableKey, path);
|
|
466
499
|
const schemaProperties = variableSchema.properties;
|
|
500
|
+
const additionalProperties = variableSchema.additionalProperties;
|
|
467
501
|
|
|
468
502
|
if (schemaProperties && typeof schemaProperties === "object") {
|
|
469
503
|
const requiredKeys =
|
|
@@ -485,7 +519,17 @@ function refineVariableValueObject(
|
|
|
485
519
|
|
|
486
520
|
for (const key of Object.keys(variableValue)) {
|
|
487
521
|
const propSchema = schemaProperties[key];
|
|
488
|
-
if (!propSchema) {
|
|
522
|
+
if (!propSchema && additionalProperties && typeof additionalProperties === "object") {
|
|
523
|
+
superRefineVariableValue(
|
|
524
|
+
projectConfig,
|
|
525
|
+
additionalProperties as Parameters<typeof superRefineVariableValue>[1],
|
|
526
|
+
variableValue[key],
|
|
527
|
+
[...path, key],
|
|
528
|
+
ctx,
|
|
529
|
+
key,
|
|
530
|
+
schemasByKey,
|
|
531
|
+
);
|
|
532
|
+
} else if (!propSchema) {
|
|
489
533
|
ctx.addIssue({
|
|
490
534
|
code: z.ZodIssueCode.custom,
|
|
491
535
|
message: `Unknown property "${key}" in variable "${label}" (not in schema)`,
|
|
@@ -503,6 +547,18 @@ function refineVariableValueObject(
|
|
|
503
547
|
);
|
|
504
548
|
}
|
|
505
549
|
}
|
|
550
|
+
} else if (additionalProperties && typeof additionalProperties === "object") {
|
|
551
|
+
for (const key of Object.keys(variableValue)) {
|
|
552
|
+
superRefineVariableValue(
|
|
553
|
+
projectConfig,
|
|
554
|
+
additionalProperties as Parameters<typeof superRefineVariableValue>[1],
|
|
555
|
+
variableValue[key],
|
|
556
|
+
[...path, key],
|
|
557
|
+
ctx,
|
|
558
|
+
key,
|
|
559
|
+
schemasByKey,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
506
562
|
} else {
|
|
507
563
|
for (const key of Object.keys(variableValue)) {
|
|
508
564
|
const propValue = variableValue[key];
|
|
@@ -1280,6 +1336,7 @@ export function getFeatureZodSchema(
|
|
|
1280
1336
|
type: z.union([z.literal("json"), propertyTypeEnum]).optional(),
|
|
1281
1337
|
items: schemaZodSchema.optional(),
|
|
1282
1338
|
properties: z.record(schemaZodSchema).optional(),
|
|
1339
|
+
additionalProperties: schemaZodSchema.optional(),
|
|
1283
1340
|
required: z.array(z.string()).optional(),
|
|
1284
1341
|
enum: z.array(variableValueZodSchema).optional(),
|
|
1285
1342
|
const: variableValueZodSchema.optional(),
|
|
@@ -1315,7 +1372,7 @@ export function getFeatureZodSchema(
|
|
|
1315
1372
|
ctx.addIssue({
|
|
1316
1373
|
code: z.ZodIssueCode.custom,
|
|
1317
1374
|
message:
|
|
1318
|
-
"Variable schema cannot have both `schema` (reference) and inline properties (`type`, `oneOf`, `properties`, `required`, `items`). Use one or the other.",
|
|
1375
|
+
"Variable schema cannot have both `schema` (reference) and inline properties (`type`, `oneOf`, `properties`, `additionalProperties`, `required`, `items`). Use one or the other.",
|
|
1319
1376
|
path: [],
|
|
1320
1377
|
});
|
|
1321
1378
|
return;
|
|
@@ -1324,6 +1381,8 @@ export function getFeatureZodSchema(
|
|
|
1324
1381
|
const hasInlineStructure =
|
|
1325
1382
|
("type" in variableSchema && variableSchema.type != null) ||
|
|
1326
1383
|
("properties" in variableSchema && variableSchema.properties != null) ||
|
|
1384
|
+
("additionalProperties" in variableSchema &&
|
|
1385
|
+
variableSchema.additionalProperties != null) ||
|
|
1327
1386
|
("required" in variableSchema && variableSchema.required != null) ||
|
|
1328
1387
|
("items" in variableSchema && variableSchema.items != null) ||
|
|
1329
1388
|
("oneOf" in variableSchema && variableSchema.oneOf != null);
|
|
@@ -1340,7 +1399,7 @@ export function getFeatureZodSchema(
|
|
|
1340
1399
|
ctx.addIssue({
|
|
1341
1400
|
code: z.ZodIssueCode.custom,
|
|
1342
1401
|
message:
|
|
1343
|
-
"When `schema` is set, do not set `type`, `oneOf`, `properties`, `required`, or `items`.",
|
|
1402
|
+
"When `schema` is set, do not set `type`, `oneOf`, `properties`, `additionalProperties`, `required`, or `items`.",
|
|
1344
1403
|
path: [],
|
|
1345
1404
|
});
|
|
1346
1405
|
}
|
|
@@ -257,6 +257,17 @@ describe("mutationNotation.ts", () => {
|
|
|
257
257
|
expect(result).toBeNull();
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
it("resolves unknown object key via additionalProperties", () => {
|
|
261
|
+
const result = resolveSchemaAtPath(
|
|
262
|
+
{
|
|
263
|
+
type: "object",
|
|
264
|
+
additionalProperties: { type: "string" },
|
|
265
|
+
},
|
|
266
|
+
[{ key: "dynamicKey" }],
|
|
267
|
+
);
|
|
268
|
+
expect(result).toEqual({ type: "string" });
|
|
269
|
+
});
|
|
270
|
+
|
|
260
271
|
it("returns null when stepping into non-object", () => {
|
|
261
272
|
const result = resolveSchemaAtPath(objectSchema, [{ key: "width" }, { key: "foo" }]);
|
|
262
273
|
expect(result).toBeNull();
|
|
@@ -446,6 +457,18 @@ describe("mutationNotation.ts", () => {
|
|
|
446
457
|
expect(r.error).toContain("path does not exist");
|
|
447
458
|
});
|
|
448
459
|
|
|
460
|
+
it("valid when path uses additionalProperties", () => {
|
|
461
|
+
const schemas: Record<string, Schema> = {
|
|
462
|
+
labels: {
|
|
463
|
+
type: "object",
|
|
464
|
+
additionalProperties: { type: "string" },
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const r = validateMutationKey("labels.headline", schemas);
|
|
468
|
+
expect(r.valid).toBe(true);
|
|
469
|
+
expect(r.valueSchema).toEqual({ type: "string" });
|
|
470
|
+
});
|
|
471
|
+
|
|
449
472
|
it("invalid when target schema is oneOf", () => {
|
|
450
473
|
const schemas: Record<string, Schema> = {
|
|
451
474
|
x: {
|
|
@@ -88,9 +88,14 @@ export function resolveSchemaAtPath(
|
|
|
88
88
|
if (seg.key) {
|
|
89
89
|
if (current.type !== "object") return null;
|
|
90
90
|
const props = current.properties;
|
|
91
|
-
|
|
92
|
-
const next = props[seg.key];
|
|
93
|
-
if (next === undefined)
|
|
91
|
+
const additional = current.additionalProperties;
|
|
92
|
+
const next = props && typeof props === "object" ? props[seg.key] : undefined;
|
|
93
|
+
if (next === undefined) {
|
|
94
|
+
if (!additional || typeof additional !== "object") return null;
|
|
95
|
+
current = resolveSchemaRef(additional, schemasByKey);
|
|
96
|
+
if (!current) return null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
94
99
|
current = resolveSchemaRef(next, schemasByKey);
|
|
95
100
|
if (!current) return null;
|
|
96
101
|
}
|
|
@@ -135,9 +140,14 @@ function getContainerSchemaAtPath(
|
|
|
135
140
|
} else {
|
|
136
141
|
if (current.type !== "object") return null;
|
|
137
142
|
const props = current.properties;
|
|
138
|
-
|
|
139
|
-
const next = props[seg.key];
|
|
140
|
-
if (next === undefined)
|
|
143
|
+
const additional = current.additionalProperties;
|
|
144
|
+
const next = props && typeof props === "object" ? props[seg.key] : undefined;
|
|
145
|
+
if (next === undefined) {
|
|
146
|
+
if (!additional || typeof additional !== "object") return null;
|
|
147
|
+
current = resolveSchemaRef(additional, schemasByKey);
|
|
148
|
+
if (!current) return null;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
141
151
|
current = resolveSchemaRef(next, schemasByKey);
|
|
142
152
|
}
|
|
143
153
|
if (!current) return null;
|
|
@@ -147,7 +157,8 @@ function getContainerSchemaAtPath(
|
|
|
147
157
|
if ("index" in lastSegment || "selector" in lastSegment) {
|
|
148
158
|
return { containerSchema: parentSchema, lastSegment, parentSchema };
|
|
149
159
|
}
|
|
150
|
-
const propSchema =
|
|
160
|
+
const propSchema =
|
|
161
|
+
parentSchema.properties?.[lastSegment.key] ?? parentSchema.additionalProperties;
|
|
151
162
|
const resolvedProp =
|
|
152
163
|
propSchema && typeof propSchema === "object"
|
|
153
164
|
? resolveSchemaRef(propSchema, schemasByKey)
|
|
@@ -142,6 +142,20 @@ describe("schema.ts :: refineEnumMatchesType", () => {
|
|
|
142
142
|
expect(issues[0].path).toEqual(["oneOf", 1, "enum", 1]);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
it("recurses into additionalProperties", () => {
|
|
146
|
+
const { issues, ctx } = createRefinementCtx();
|
|
147
|
+
refineEnumMatchesType(
|
|
148
|
+
{
|
|
149
|
+
type: "object",
|
|
150
|
+
additionalProperties: { type: "string", enum: ["a", 1] },
|
|
151
|
+
},
|
|
152
|
+
[],
|
|
153
|
+
ctx,
|
|
154
|
+
);
|
|
155
|
+
expect(issues).toHaveLength(1);
|
|
156
|
+
expect(issues[0].path).toEqual(["additionalProperties", "enum", 1]);
|
|
157
|
+
});
|
|
158
|
+
|
|
145
159
|
it("does nothing when schema is null or not an object", () => {
|
|
146
160
|
const { issues: i1, ctx: c1 } = createRefinementCtx();
|
|
147
161
|
refineEnumMatchesType(null as any, [], c1);
|
|
@@ -215,6 +229,20 @@ describe("schema.ts :: refineMinimumMaximum", () => {
|
|
|
215
229
|
expect(issues).toHaveLength(1);
|
|
216
230
|
expect(issues[0].path).toEqual(["items", "minimum"]);
|
|
217
231
|
});
|
|
232
|
+
|
|
233
|
+
it("recurses into additionalProperties", () => {
|
|
234
|
+
const { issues, ctx } = createRefinementCtx();
|
|
235
|
+
refineMinimumMaximum(
|
|
236
|
+
{
|
|
237
|
+
type: "object",
|
|
238
|
+
additionalProperties: { type: "integer", minimum: 10, maximum: 5 },
|
|
239
|
+
},
|
|
240
|
+
[],
|
|
241
|
+
ctx,
|
|
242
|
+
);
|
|
243
|
+
expect(issues).toHaveLength(1);
|
|
244
|
+
expect(issues[0].path).toEqual(["additionalProperties", "minimum"]);
|
|
245
|
+
});
|
|
218
246
|
});
|
|
219
247
|
|
|
220
248
|
describe("schema.ts :: refineStringLengthPattern", () => {
|
|
@@ -288,6 +316,20 @@ describe("schema.ts :: refineStringLengthPattern", () => {
|
|
|
288
316
|
expect(issues).toHaveLength(1);
|
|
289
317
|
expect(issues[0].path).toEqual(["properties", "code", "minLength"]);
|
|
290
318
|
});
|
|
319
|
+
|
|
320
|
+
it("recurses into additionalProperties", () => {
|
|
321
|
+
const { issues, ctx } = createRefinementCtx();
|
|
322
|
+
refineStringLengthPattern(
|
|
323
|
+
{
|
|
324
|
+
type: "object",
|
|
325
|
+
additionalProperties: { type: "string", minLength: 10, maxLength: 5 },
|
|
326
|
+
},
|
|
327
|
+
[],
|
|
328
|
+
ctx,
|
|
329
|
+
);
|
|
330
|
+
expect(issues).toHaveLength(1);
|
|
331
|
+
expect(issues[0].path).toEqual(["additionalProperties", "minLength"]);
|
|
332
|
+
});
|
|
291
333
|
});
|
|
292
334
|
|
|
293
335
|
describe("schema.ts :: refineArrayItems", () => {
|
|
@@ -357,6 +399,25 @@ describe("schema.ts :: refineArrayItems", () => {
|
|
|
357
399
|
expect(issues).toHaveLength(1);
|
|
358
400
|
expect(issues[0].path).toEqual(["items", "minItems"]);
|
|
359
401
|
});
|
|
402
|
+
|
|
403
|
+
it("recurses into additionalProperties", () => {
|
|
404
|
+
const { issues, ctx } = createRefinementCtx();
|
|
405
|
+
refineArrayItems(
|
|
406
|
+
{
|
|
407
|
+
type: "object",
|
|
408
|
+
additionalProperties: {
|
|
409
|
+
type: "array",
|
|
410
|
+
minItems: 5,
|
|
411
|
+
maxItems: 2,
|
|
412
|
+
items: { type: "string" },
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
[],
|
|
416
|
+
ctx,
|
|
417
|
+
);
|
|
418
|
+
expect(issues).toHaveLength(1);
|
|
419
|
+
expect(issues[0].path).toEqual(["additionalProperties", "minItems"]);
|
|
420
|
+
});
|
|
360
421
|
});
|
|
361
422
|
|
|
362
423
|
describe("schema.ts :: getSchemaZodSchema", () => {
|
|
@@ -491,6 +552,17 @@ describe("schema.ts :: getSchemaZodSchema", () => {
|
|
|
491
552
|
expect(result.success).toBe(true);
|
|
492
553
|
});
|
|
493
554
|
|
|
555
|
+
it("accepts object schema with additionalProperties", () => {
|
|
556
|
+
const Schema = getSchemaZodSchema([]);
|
|
557
|
+
const result = Schema.safeParse({
|
|
558
|
+
type: "object",
|
|
559
|
+
additionalProperties: {
|
|
560
|
+
type: "string",
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
expect(result.success).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
494
566
|
it("accepts array schema with items that reference another schema", () => {
|
|
495
567
|
const Schema = getSchemaZodSchema(["link"]);
|
|
496
568
|
const result = Schema.safeParse({
|