@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/coverage/clover.xml +798 -764
  3. package/coverage/coverage-final.json +5 -5
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  8. package/coverage/lcov-report/builder/index.html +1 -1
  9. package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
  10. package/coverage/lcov-report/builder/mutator.ts.html +18 -18
  11. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  12. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  13. package/coverage/lcov-report/config/index.html +1 -1
  14. package/coverage/lcov-report/config/index.ts.html +1 -1
  15. package/coverage/lcov-report/config/projectConfig.ts.html +1 -1
  16. package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
  17. package/coverage/lcov-report/datasource/datasource.ts.html +1 -1
  18. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +1 -1
  19. package/coverage/lcov-report/datasource/index.html +1 -1
  20. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  21. package/coverage/lcov-report/index.html +18 -18
  22. package/coverage/lcov-report/linter/attributeSchema.ts.html +1 -1
  23. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  24. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  25. package/coverage/lcov-report/linter/conditionSchema.ts.html +7 -7
  26. package/coverage/lcov-report/linter/featureSchema.ts.html +365 -188
  27. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  28. package/coverage/lcov-report/linter/index.html +34 -34
  29. package/coverage/lcov-report/linter/lintProject.ts.html +1 -1
  30. package/coverage/lcov-report/linter/mutationNotation.ts.html +99 -66
  31. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  32. package/coverage/lcov-report/linter/schema.ts.html +170 -80
  33. package/coverage/lcov-report/linter/segmentSchema.ts.html +1 -1
  34. package/coverage/lcov-report/linter/testSchema.ts.html +1 -1
  35. package/coverage/lcov-report/list/index.html +1 -1
  36. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  37. package/coverage/lcov-report/parsers/index.html +1 -1
  38. package/coverage/lcov-report/parsers/index.ts.html +1 -1
  39. package/coverage/lcov-report/parsers/json.ts.html +1 -1
  40. package/coverage/lcov-report/parsers/yml.ts.html +1 -1
  41. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  42. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  43. package/coverage/lcov-report/tester/index.html +1 -1
  44. package/coverage/lcov-report/utils/git.ts.html +1 -1
  45. package/coverage/lcov-report/utils/index.html +1 -1
  46. package/coverage/lcov.info +1653 -1567
  47. package/json-schema/attribute.json +25 -9
  48. package/json-schema/feature.json +319 -238
  49. package/json-schema/segment.json +76 -45
  50. package/lib/generate-code/typescript.js +63 -20
  51. package/lib/generate-code/typescript.js.map +1 -1
  52. package/lib/linter/featureSchema.d.ts +9 -0
  53. package/lib/linter/featureSchema.js +38 -8
  54. package/lib/linter/featureSchema.js.map +1 -1
  55. package/lib/linter/featureSchema.spec.js +93 -0
  56. package/lib/linter/featureSchema.spec.js.map +1 -1
  57. package/lib/linter/mutationNotation.js +21 -11
  58. package/lib/linter/mutationNotation.js.map +1 -1
  59. package/lib/linter/mutationNotation.spec.js +18 -0
  60. package/lib/linter/mutationNotation.spec.js.map +1 -1
  61. package/lib/linter/schema.d.ts +1 -0
  62. package/lib/linter/schema.js +13 -0
  63. package/lib/linter/schema.js.map +1 -1
  64. package/lib/linter/schema.spec.js +51 -0
  65. package/lib/linter/schema.spec.js.map +1 -1
  66. package/package.json +5 -5
  67. package/src/generate-code/typescript.ts +87 -20
  68. package/src/linter/featureSchema.spec.ts +118 -0
  69. package/src/linter/featureSchema.ts +65 -6
  70. package/src/linter/mutationNotation.spec.ts +23 -0
  71. package/src/linter/mutationNotation.ts +18 -7
  72. package/src/linter/schema.spec.ts +72 -0
  73. 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
- const props = resolved.properties;
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
- if (props && typeof props === "object" && Object.keys(props).length > 0) {
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
- .join("\n");
287
- declarations.push(`${INDENT_NS}export interface ${typeName} {\n${entries}\n${INDENT_NS}}`);
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
- if (!props || typeof props !== "object") return true;
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 (!propSchema) return false;
209
- if (!valueMatchesSchema(propSchema as { [k: string]: unknown }, obj[key], schemasByKey))
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
- if (!props || typeof props !== "object") return null;
92
- const next = props[seg.key];
93
- if (next === undefined) return null;
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
- if (!props || typeof props !== "object") return null;
139
- const next = props[seg.key];
140
- if (next === undefined) return null;
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 = parentSchema.properties?.[lastSegment.key];
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({