@featurevisor/core 2.16.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 +22 -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 +118 -50
  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 +150 -52
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featurevisor/core",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "Core package of Featurevisor for Node.js usage",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -40,14 +40,14 @@
40
40
  },
41
41
  "license": "MIT",
42
42
  "dependencies": {
43
- "@featurevisor/sdk": "2.12.0",
44
- "@featurevisor/site": "2.12.0",
45
- "@featurevisor/types": "2.12.0",
43
+ "@featurevisor/sdk": "2.18.0",
44
+ "@featurevisor/site": "2.18.0",
45
+ "@featurevisor/types": "2.18.0",
46
46
  "axios": "^1.3.4",
47
47
  "tar": "^7.5.7",
48
48
  "yaml": "^1.10.2",
49
49
  "yargs": "^17.7.2",
50
50
  "zod": "^3.22.4"
51
51
  },
52
- "gitHead": "372701b0794f3b818ec9a9ef8d8e0449fb60a321"
52
+ "gitHead": "469800809b026bb42e1bc7298649d6e49e47992d"
53
53
  }
@@ -39,6 +39,80 @@ function convertFeaturevisorTypeToTypeScriptType(featurevisorType: string): stri
39
39
  }
40
40
  }
41
41
 
42
+ function shouldWrapArrayItemType(typeName: string): boolean {
43
+ const trimmed = typeName.trim();
44
+ return trimmed.includes("|") || trimmed.includes("&");
45
+ }
46
+
47
+ function formatObjectKey(key: string): string {
48
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
49
+ }
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
+
108
+ function formatTypeImport(typeNames: string[], fromPath: string): string {
109
+ if (typeNames.length === 0) {
110
+ return "";
111
+ }
112
+
113
+ return `import type {\n${typeNames.map((name) => ` ${name},`).join("\n")}\n} from "${fromPath}";\n\n`;
114
+ }
115
+
42
116
  /**
43
117
  * Resolve a schema to its full definition, following schema references (schema: key).
44
118
  */
@@ -110,23 +184,12 @@ function schemaToTypeScriptType(
110
184
  return literalFromConst ?? unionFromEnum ?? "number";
111
185
  case "array":
112
186
  if (resolved.items) {
113
- return `(${schemaToTypeScriptType(resolved.items, schemasByKey, schemaTypeNames)})[]`;
187
+ const itemType = schemaToTypeScriptType(resolved.items, schemasByKey, schemaTypeNames);
188
+ return shouldWrapArrayItemType(itemType) ? `(${itemType})[]` : `${itemType}[]`;
114
189
  }
115
190
  return "string[]";
116
191
  case "object": {
117
- const props = resolved.properties;
118
- if (props && typeof props === "object" && Object.keys(props).length > 0) {
119
- const requiredSet = new Set(resolved.required || []);
120
- const entries = Object.entries(props)
121
- .map(([k, v]) => {
122
- const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
123
- const optional = !requiredSet.has(k);
124
- return optional ? `${k}?: ${propType}` : `${k}: ${propType}`;
125
- })
126
- .join("; ");
127
- return `{ ${entries} }`;
128
- }
129
- return "Record<string, unknown>";
192
+ return buildObjectTypeFromSchema(resolved, schemasByKey, schemaTypeNames);
130
193
  }
131
194
  default:
132
195
  return "unknown";
@@ -247,13 +310,17 @@ function generateVariableTypeDeclarations(
247
310
 
248
311
  if (type === "object") {
249
312
  const resolvedEffective =
250
- effective && "properties" in effective
313
+ effective && ("properties" in effective || "additionalProperties" in effective)
251
314
  ? (resolveSchema(effective as Schema, schemasByKey) as Schema)
252
315
  : undefined;
253
316
  const props = resolvedEffective?.properties;
254
- 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
+ ) {
255
322
  const requiredSet = new Set(resolvedEffective?.required || []);
256
- const entries = Object.entries(props)
323
+ const entries = Object.entries(props ?? {})
257
324
  .map(([k, v]) => {
258
325
  const propType = schemaToTypeScriptType(v as Schema, schemasByKey, schemaTypeNames);
259
326
  if (schemaTypeNames)
@@ -262,11 +329,29 @@ function generateVariableTypeDeclarations(
262
329
  });
263
330
  const optional = !requiredSet.has(k);
264
331
  return optional
265
- ? `${INDENT_NS_BODY}${k}?: ${propType};`
266
- : `${INDENT_NS_BODY}${k}: ${propType};`;
332
+ ? `${INDENT_NS_BODY}${formatObjectKey(k)}?: ${propType};`
333
+ : `${INDENT_NS_BODY}${formatObjectKey(k)}: ${propType};`;
267
334
  })
268
- .join("\n");
269
- 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
+ );
270
355
  return { declarations, returnTypeName: typeName, genericArg: typeName, schemaTypesUsed };
271
356
  }
272
357
  declarations.push(`${INDENT_NS}export type ${typeName} = Record<string, unknown>;`);
@@ -534,7 +619,7 @@ export async function generateTypeScriptCodeForProject(
534
619
  })
535
620
  .join("\n");
536
621
  const contextContent = `
537
- import type { AttributeKey, AttributeValue } from "@featurevisor/types";
622
+ import type { AttributeKey, AttributeValue } from "@featurevisor/sdk";
538
623
 
539
624
  export interface Context {
540
625
  ${attributeProperties}
@@ -542,7 +627,7 @@ ${attributeProperties}
542
627
  }
543
628
  `.trimStart();
544
629
 
545
- const contextTypeFilePath = path.join(outputPath, "Context.ts");
630
+ const contextTypeFilePath = path.join(outputPath, "context.ts");
546
631
  fs.writeFileSync(contextTypeFilePath, contextContent);
547
632
  console.log(
548
633
  `Context type file written at: ${getRelativePath(rootDirectoryPath, contextTypeFilePath)}`,
@@ -565,7 +650,7 @@ ${attributeProperties}
565
650
  const hasSchemasFile = schemaKeys.length > 0;
566
651
  if (hasSchemasFile) {
567
652
  const schemasContent = generateSchemasFileContent(schemaKeys, schemasByKey);
568
- const schemasFilePath = path.join(outputPath, "Schemas.ts");
653
+ const schemasFilePath = path.join(outputPath, "schemas.ts");
569
654
  fs.writeFileSync(schemasFilePath, schemasContent);
570
655
  console.log(
571
656
  `Schemas type file written at: ${getRelativePath(rootDirectoryPath, schemasFilePath)}`,
@@ -674,13 +759,10 @@ ${INDENT_NS}}`;
674
759
  }
675
760
  }
676
761
 
677
- const schemasImportLine =
678
- featureSchemaTypesUsed.size > 0
679
- ? `import type { ${[...featureSchemaTypesUsed].sort().join(", ")} } from "./Schemas";\n\n`
680
- : "";
762
+ const schemasImportLine = formatTypeImport([...featureSchemaTypesUsed].sort(), "./schemas");
681
763
 
682
764
  const featureContent = `
683
- import { Context } from "./Context";
765
+ import { Context } from "./context";
684
766
  import { getInstance } from "./instance";
685
767
  ${schemasImportLine}export namespace ${namespaceValue} {
686
768
  ${INDENT_NS}export const key = "${featureKey}";${variableTypeDeclarations}
@@ -724,18 +806,22 @@ ${INDENT_NS}}${variableMethods}
724
806
  hasSchemasFile ? schemaTypeNames : undefined,
725
807
  );
726
808
  schemaTypesUsed.forEach((name) => featuresTypeSchemasUsed.add(name));
727
- featureLines.push(`${INDENT_NS_BODY}${JSON.stringify(variableKey)}: ${typeName};`);
809
+ featureLines.push(`${INDENT_NS_BODY}${formatObjectKey(variableKey)}: ${typeName};`);
728
810
  }
729
811
  }
730
812
 
731
- return `${INDENT_NS}${JSON.stringify(featureKey)}: {\n${featureLines.join("\n")}\n${INDENT_NS}};`;
813
+ if (featureLines.length === 0) {
814
+ return `${INDENT_NS}${formatObjectKey(featureKey)}: null;`;
815
+ }
816
+
817
+ return `${INDENT_NS}${formatObjectKey(featureKey)}: {\n${featureLines.join("\n")}\n${INDENT_NS}};`;
732
818
  })
733
819
  .join("\n");
734
820
 
735
- const featuresSchemasImportLine =
736
- featuresTypeSchemasUsed.size > 0
737
- ? `import type { ${[...featuresTypeSchemasUsed].sort().join(", ")} } from "./Schemas";\n\n`
738
- : "";
821
+ const featuresSchemasImportLine = formatTypeImport(
822
+ [...featuresTypeSchemasUsed].sort(),
823
+ "./schemas",
824
+ );
739
825
 
740
826
  const featuresFileContent = `
741
827
  ${featuresSchemasImportLine}export type Features = {
@@ -743,24 +829,33 @@ ${featureTypeEntries}
743
829
  };
744
830
 
745
831
  export type FeatureKey = keyof Features;
746
- export type VariableKey<F extends FeatureKey> = Extract<Exclude<keyof Features[F], "variation">, string>;
747
- export type VariableType<F extends FeatureKey, V extends VariableKey<F>> = Features[F][V];
748
- export type Variation<F extends FeatureKey> = Features[F] extends { variation: infer V } ? V : never;
832
+ export type VariableKey<F extends FeatureKey> = Features[F] extends Record<string, unknown>
833
+ ? Extract<Exclude<keyof Features[F], "variation">, string>
834
+ : never;
835
+ export type VariableType<F extends FeatureKey, V extends VariableKey<F>> = Features[F] extends Record<string, unknown>
836
+ ? Features[F][V]
837
+ : never;
838
+ export type Variation<F extends FeatureKey> = Features[F] extends { variation: infer V }
839
+ ? V
840
+ : never;
749
841
  `.trimStart();
750
- const featuresFilePath = path.join(outputPath, "Features.ts");
842
+ const featuresFilePath = path.join(outputPath, "features.ts");
751
843
  fs.writeFileSync(featuresFilePath, featuresFileContent);
752
844
  console.log(`Features file written at: ${getRelativePath(rootDirectoryPath, featuresFilePath)}`);
753
845
 
754
846
  const functionsFileContent = `
755
- import { FeatureKey, Variation, VariableKey, VariableType } from "./Features";
756
- import { Context } from "./Context";
847
+ import { FeatureKey, Variation, VariableKey, VariableType } from "./features";
848
+ import { Context } from "./context";
757
849
  import { getInstance } from "./instance";
758
850
 
759
851
  export function isEnabled(featureKey: FeatureKey, context: Context = {}): boolean {
760
852
  return getInstance().isEnabled(featureKey, context);
761
853
  }
762
854
 
763
- export function getVariation<F extends FeatureKey>(featureKey: F, context: Context = {}): Variation<F> | null {
855
+ export function getVariation<F extends FeatureKey>(
856
+ featureKey: F,
857
+ context: Context = {},
858
+ ): Variation<F> | null {
764
859
  return getInstance().getVariation(featureKey, context) as Variation<F> | null;
765
860
  }
766
861
 
@@ -772,7 +867,7 @@ export function getVariable<F extends FeatureKey, V extends VariableKey<F>>(
772
867
  return getInstance().getVariable(featureKey, variableKey, context) as VariableType<F, V> | null;
773
868
  }
774
869
  `.trimStart();
775
- const functionsFilePath = path.join(outputPath, "Functions.ts");
870
+ const functionsFilePath = path.join(outputPath, "functions.ts");
776
871
  fs.writeFileSync(functionsFilePath, functionsFileContent);
777
872
  console.log(
778
873
  `Functions file written at: ${getRelativePath(rootDirectoryPath, functionsFilePath)}`,
@@ -786,14 +881,17 @@ import {
786
881
  useVariable as useVariableOriginal,
787
882
  } from "@featurevisor/react";
788
883
 
789
- import { FeatureKey, Variation, VariableKey, VariableType } from "./Features";
790
- import { Context } from "./Context";
884
+ import { FeatureKey, Variation, VariableKey, VariableType } from "./features";
885
+ import { Context } from "./context";
791
886
 
792
887
  export function useFlag(featureKey: FeatureKey, context: Context = {}): boolean {
793
888
  return useFlagOriginal(featureKey, context);
794
889
  }
795
890
 
796
- export function useVariation<F extends FeatureKey>(featureKey: F, context: Context = {}): Variation<F> | null {
891
+ export function useVariation<F extends FeatureKey>(
892
+ featureKey: F,
893
+ context: Context = {},
894
+ ): Variation<F> | null {
797
895
  return useVariationOriginal(featureKey, context) as Variation<F> | null;
798
896
  }
799
897
 
@@ -805,7 +903,7 @@ export function useVariable<F extends FeatureKey, V extends VariableKey<F>>(
805
903
  return useVariableOriginal(featureKey, variableKey, context) as VariableType<F, V> | null;
806
904
  }
807
905
  `.trimStart();
808
- const reactFilePath = path.join(outputPath, "React.ts");
906
+ const reactFilePath = path.join(outputPath, "react.ts");
809
907
  fs.writeFileSync(reactFilePath, reactFileContent);
810
908
  console.log(`React file written at: ${getRelativePath(rootDirectoryPath, reactFilePath)}`);
811
909
  }
@@ -813,12 +911,12 @@ export function useVariable<F extends FeatureKey, V extends VariableKey<F>>(
813
911
  // index
814
912
  const indexContent =
815
913
  [
816
- `export * from "./Context";`,
914
+ `export * from "./context";`,
817
915
  `export * from "./instance";`,
818
- ...(hasSchemasFile ? [`export * from "./Schemas";`] : []),
819
- `export * from "./Features";`,
820
- `export * from "./Functions";`,
821
- ...(shouldGenerateReact ? [`export * from "./React";`] : []),
916
+ ...(hasSchemasFile ? [`export * from "./schemas";`] : []),
917
+ `export * from "./features";`,
918
+ `export * from "./functions";`,
919
+ ...(shouldGenerateReact ? [`export * from "./react";`] : []),
822
920
  ...featureNamespaces.map((featureNamespace) => {
823
921
  return `export * from "./${featureNamespace}";`;
824
922
  }),
@@ -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
  }