@featurevisor/core 2.20.0 → 2.22.1

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 (87) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/coverage/clover.xml +1286 -515
  3. package/coverage/coverage-final.json +17 -16
  4. package/coverage/lcov-report/builder/allocator.ts.html +1 -1
  5. package/coverage/lcov-report/builder/buildDatafile.ts.html +1 -1
  6. package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
  7. package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
  8. package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
  9. package/coverage/lcov-report/builder/convertToV1.ts.html +1 -1
  10. package/coverage/lcov-report/builder/getFeatureRanges.ts.html +1 -1
  11. package/coverage/lcov-report/builder/hashes.ts.html +1 -1
  12. package/coverage/lcov-report/builder/index.html +1 -1
  13. package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
  14. package/coverage/lcov-report/builder/mutator.ts.html +1 -1
  15. package/coverage/lcov-report/builder/revision.ts.html +1 -1
  16. package/coverage/lcov-report/builder/traffic.ts.html +1 -1
  17. package/coverage/lcov-report/config/index.html +1 -1
  18. package/coverage/lcov-report/config/index.ts.html +1 -1
  19. package/coverage/lcov-report/config/projectConfig.ts.html +42 -42
  20. package/coverage/lcov-report/datasource/adapter.ts.html +2 -2
  21. package/coverage/lcov-report/datasource/datasource.ts.html +43 -43
  22. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +48 -48
  23. package/coverage/lcov-report/datasource/index.html +19 -19
  24. package/coverage/lcov-report/datasource/index.ts.html +4 -4
  25. package/coverage/lcov-report/generate-code/index.html +116 -0
  26. package/coverage/lcov-report/generate-code/typescript.ts.html +3004 -0
  27. package/coverage/lcov-report/index.html +44 -29
  28. package/coverage/lcov-report/linter/attributeSchema.ts.html +1815 -30
  29. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +1 -1
  30. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +1 -1
  31. package/coverage/lcov-report/linter/conditionSchema.ts.html +1606 -157
  32. package/coverage/lcov-report/linter/featureSchema.ts.html +56 -56
  33. package/coverage/lcov-report/linter/groupSchema.ts.html +1 -1
  34. package/coverage/lcov-report/linter/index.html +39 -39
  35. package/coverage/lcov-report/linter/lintProject.ts.html +189 -78
  36. package/coverage/lcov-report/linter/mutationNotation.ts.html +1 -1
  37. package/coverage/lcov-report/linter/printError.ts.html +1 -1
  38. package/coverage/lcov-report/linter/schema.ts.html +59 -59
  39. package/coverage/lcov-report/linter/segmentSchema.ts.html +3 -3
  40. package/coverage/lcov-report/linter/testSchema.ts.html +6 -6
  41. package/coverage/lcov-report/list/index.html +1 -1
  42. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  43. package/coverage/lcov-report/parsers/index.html +1 -1
  44. package/coverage/lcov-report/parsers/index.ts.html +4 -4
  45. package/coverage/lcov-report/parsers/json.ts.html +2 -2
  46. package/coverage/lcov-report/parsers/yml.ts.html +6 -6
  47. package/coverage/lcov-report/tester/cliFormat.ts.html +1 -1
  48. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  49. package/coverage/lcov-report/tester/index.html +1 -1
  50. package/coverage/lcov-report/utils/extractKeys.ts.html +1 -1
  51. package/coverage/lcov-report/utils/git.ts.html +3 -3
  52. package/coverage/lcov-report/utils/index.html +1 -1
  53. package/coverage/lcov-report/utils/index.ts.html +1 -1
  54. package/coverage/lcov-report/utils/pretty.ts.html +1 -1
  55. package/coverage/lcov.info +2624 -926
  56. package/lib/generate-code/typescript.js +57 -29
  57. package/lib/generate-code/typescript.js.map +1 -1
  58. package/lib/generate-code/typescript.spec.d.ts +1 -0
  59. package/lib/generate-code/typescript.spec.js +78 -0
  60. package/lib/generate-code/typescript.spec.js.map +1 -0
  61. package/lib/linter/attributeSchema.d.ts +352 -20
  62. package/lib/linter/attributeSchema.js +426 -16
  63. package/lib/linter/attributeSchema.js.map +1 -1
  64. package/lib/linter/attributeSchema.spec.d.ts +1 -0
  65. package/lib/linter/attributeSchema.spec.js +266 -0
  66. package/lib/linter/attributeSchema.spec.js.map +1 -0
  67. package/lib/linter/conditionSchema.d.ts +2 -1
  68. package/lib/linter/conditionSchema.js +370 -100
  69. package/lib/linter/conditionSchema.js.map +1 -1
  70. package/lib/linter/conditionSchema.spec.js +200 -153
  71. package/lib/linter/conditionSchema.spec.js.map +1 -1
  72. package/lib/linter/featureSchema.spec.js +114 -3
  73. package/lib/linter/featureSchema.spec.js.map +1 -1
  74. package/lib/linter/lintProject.js +45 -14
  75. package/lib/linter/lintProject.js.map +1 -1
  76. package/lib/linter/segmentSchema.spec.js +71 -1
  77. package/lib/linter/segmentSchema.spec.js.map +1 -1
  78. package/package.json +5 -5
  79. package/src/generate-code/typescript.spec.ts +108 -0
  80. package/src/generate-code/typescript.ts +80 -34
  81. package/src/linter/attributeSchema.spec.ts +342 -0
  82. package/src/linter/attributeSchema.ts +613 -18
  83. package/src/linter/conditionSchema.spec.ts +231 -189
  84. package/src/linter/conditionSchema.ts +582 -99
  85. package/src/linter/featureSchema.spec.ts +129 -4
  86. package/src/linter/lintProject.ts +55 -18
  87. package/src/linter/segmentSchema.spec.ts +82 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@featurevisor/core",
3
- "version": "2.20.0",
3
+ "version": "2.22.1",
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.20.0",
44
- "@featurevisor/site": "2.20.0",
45
- "@featurevisor/types": "2.20.0",
43
+ "@featurevisor/sdk": "2.21.0",
44
+ "@featurevisor/site": "2.22.1",
45
+ "@featurevisor/types": "2.21.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": "414ad2fdbb4520bf302dca781cdc8011abe312c4"
52
+ "gitHead": "e5b94a013197e1dfc94667470194db5cea7b4014"
53
53
  }
@@ -0,0 +1,108 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { getProjectConfig } from "../config/projectConfig";
6
+ import { Datasource } from "../datasource";
7
+ import { generateTypeScriptCodeForProject } from "./typescript";
8
+
9
+ function createTempProjectFromExample1() {
10
+ const fixturePath = path.resolve(__dirname, "../../../../examples/example-1");
11
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "featurevisor-codegen-"));
12
+
13
+ fs.cpSync(fixturePath, tempRoot, { recursive: true });
14
+
15
+ return tempRoot;
16
+ }
17
+
18
+ describe("generate-code/typescript", () => {
19
+ let tempProjectPath: string;
20
+ let outputPath: string;
21
+
22
+ beforeEach(() => {
23
+ tempProjectPath = createTempProjectFromExample1();
24
+ outputPath = path.join(tempProjectPath, "generated");
25
+ fs.mkdirSync(outputPath, { recursive: true });
26
+
27
+ fs.writeFileSync(
28
+ path.join(tempProjectPath, "attributes", "account.yml"),
29
+ [
30
+ "description: Account",
31
+ "type: object",
32
+ "required:",
33
+ " - plan",
34
+ "properties:",
35
+ " plan:",
36
+ " type: string",
37
+ " enum:",
38
+ " - free",
39
+ " - pro",
40
+ " locale:",
41
+ " type: string",
42
+ ].join("\n"),
43
+ "utf8",
44
+ );
45
+
46
+ fs.writeFileSync(
47
+ path.join(tempProjectPath, "attributes", "permissions.yml"),
48
+ [
49
+ "description: Permissions",
50
+ "type: array",
51
+ "items:",
52
+ " type: string",
53
+ " enum:",
54
+ " - read",
55
+ " - write",
56
+ " - admin",
57
+ ].join("\n"),
58
+ "utf8",
59
+ );
60
+ });
61
+
62
+ afterEach(() => {
63
+ fs.rmSync(tempProjectPath, { recursive: true, force: true });
64
+ });
65
+
66
+ it("generates schema-aware context types for attributes", async () => {
67
+ const projectConfig = getProjectConfig(tempProjectPath);
68
+ const datasource = new Datasource(projectConfig, tempProjectPath);
69
+
70
+ await generateTypeScriptCodeForProject(
71
+ {
72
+ rootDirectoryPath: tempProjectPath,
73
+ projectConfig,
74
+ datasource,
75
+ options: {},
76
+ } as any,
77
+ outputPath,
78
+ {},
79
+ );
80
+
81
+ const attributesContent = fs.readFileSync(path.join(outputPath, "attributes.ts"), "utf8");
82
+ const contextContent = fs.readFileSync(path.join(outputPath, "context.ts"), "utf8");
83
+ const indexContent = fs.readFileSync(path.join(outputPath, "index.ts"), "utf8");
84
+
85
+ expect(attributesContent).toContain(
86
+ 'export type AccountAttribute = { plan: "free" | "pro"; locale?: string };',
87
+ );
88
+ expect(attributesContent).toContain(
89
+ 'export type PermissionsAttribute = ("read" | "write" | "admin")[];',
90
+ );
91
+ expect(attributesContent).toContain("export type VersionAttribute = string | number;");
92
+ expect(attributesContent).toContain(
93
+ "export type BrowserAttribute = { name?: string; version?: string };",
94
+ );
95
+
96
+ expect(contextContent).toContain("import type {");
97
+ expect(contextContent).toContain("AccountAttribute,");
98
+ expect(contextContent).toContain("PermissionsAttribute,");
99
+ expect(contextContent).toContain("VersionAttribute,");
100
+ expect(contextContent).toContain('} from "./attributes";');
101
+ expect(contextContent).toContain("account?: AccountAttribute;");
102
+ expect(contextContent).toContain("permissions?: PermissionsAttribute;");
103
+ expect(contextContent).toContain("version?: VersionAttribute;");
104
+ expect(contextContent).toContain("browser?: BrowserAttribute;");
105
+
106
+ expect(indexContent).toContain('export * from "./attributes";');
107
+ });
108
+ });
@@ -28,6 +28,8 @@ function convertFeaturevisorTypeToTypeScriptType(featurevisorType: string): stri
28
28
  return "number";
29
29
  case "date":
30
30
  return "Date | string";
31
+ case "semver":
32
+ return "string";
31
33
  case "array":
32
34
  return "string[]";
33
35
  case "object":
@@ -170,7 +172,7 @@ function schemaToTypeScriptType(
170
172
  resolved.enum && Array.isArray(resolved.enum) && resolved.enum.length > 0
171
173
  ? enumToUnionType(resolved.enum)
172
174
  : null;
173
- const type = resolved.type;
175
+ const type = resolved.type as string | undefined;
174
176
  if (!type) {
175
177
  return literalFromConst ?? unionFromEnum ?? "unknown";
176
178
  }
@@ -178,7 +180,10 @@ function schemaToTypeScriptType(
178
180
  case "boolean":
179
181
  return literalFromConst ?? unionFromEnum ?? "boolean";
180
182
  case "string":
183
+ case "semver":
181
184
  return literalFromConst ?? unionFromEnum ?? "string";
185
+ case "date":
186
+ return literalFromConst ?? unionFromEnum ?? "Date | string";
182
187
  case "integer":
183
188
  case "double":
184
189
  return literalFromConst ?? unionFromEnum ?? "number";
@@ -554,6 +559,29 @@ function generateSchemasFileContent(
554
559
  return lines.join("\n") + "\n";
555
560
  }
556
561
 
562
+ function generateAttributesFileContent(
563
+ attributes: Array<Attribute & { key: string; typescriptType: string }>,
564
+ schemaTypeNames: Record<string, string>,
565
+ ): string {
566
+ const schemaTypesUsed = new Set<string>();
567
+
568
+ for (const attribute of attributes) {
569
+ Object.values(schemaTypeNames).forEach((typeName) => {
570
+ if (attribute.typescriptType.includes(typeName)) {
571
+ schemaTypesUsed.add(typeName);
572
+ }
573
+ });
574
+ }
575
+
576
+ const importLine = formatTypeImport(Array.from(schemaTypesUsed).sort(), "./schemas");
577
+ const lines = attributes.map((attribute) => {
578
+ const typeName = `${getPascalCase(attribute.key)}Attribute`;
579
+ return `export type ${typeName} = ${attribute.typescriptType};`;
580
+ });
581
+
582
+ return `${importLine}${lines.join("\n")}\n`;
583
+ }
584
+
557
585
  // Indentation for generated namespace content (2 spaces per level)
558
586
  const INDENT_NS = " ";
559
587
  const INDENT_NS_BODY = " ";
@@ -588,6 +616,32 @@ export async function generateTypeScriptCodeForProject(
588
616
 
589
617
  console.log("\nGenerating TypeScript code...\n");
590
618
 
619
+ // Load schemas for resolving variable schema references
620
+ const schemaListKeys = await datasource.listSchemas();
621
+ const schemasByKey: Record<string, Schema> = {};
622
+ for (const key of schemaListKeys) {
623
+ try {
624
+ schemasByKey[key] = await datasource.readSchema(key);
625
+ } catch {
626
+ // Schema file may be invalid; skip for code gen
627
+ }
628
+ }
629
+ const schemaKeys = Object.keys(schemasByKey);
630
+ const hasSchemasFile = schemaKeys.length > 0;
631
+ if (hasSchemasFile) {
632
+ const schemasContent = generateSchemasFileContent(schemaKeys, schemasByKey);
633
+ const schemasFilePath = path.join(outputPath, "schemas.ts");
634
+ fs.writeFileSync(schemasFilePath, schemasContent);
635
+ console.log(
636
+ `Schemas type file written at: ${getRelativePath(rootDirectoryPath, schemasFilePath)}`,
637
+ );
638
+ }
639
+
640
+ const schemaTypeNames: Record<string, string> = {};
641
+ for (const k of schemaKeys) {
642
+ schemaTypeNames[k] = getPascalCase(k) + "Schema";
643
+ }
644
+
591
645
  // instance
592
646
  const instanceFilePath = path.join(outputPath, "instance.ts");
593
647
  fs.writeFileSync(instanceFilePath, instanceSnippet);
@@ -595,7 +649,8 @@ export async function generateTypeScriptCodeForProject(
595
649
 
596
650
  // attributes
597
651
  const attributeFiles = await datasource.listAttributes();
598
- const attributes: (Attribute & { typescriptType })[] = [];
652
+ const attributes: (Attribute & { typescriptType: string })[] = [];
653
+ const attributeTypeNames: Record<string, string> = {};
599
654
 
600
655
  for (const attributeKey of attributeFiles) {
601
656
  const parsedAttribute = await datasource.readAttribute(attributeKey);
@@ -604,24 +659,40 @@ export async function generateTypeScriptCodeForProject(
604
659
  continue;
605
660
  }
606
661
 
662
+ const typescriptType = schemaToTypeScriptType(
663
+ parsedAttribute as Schema,
664
+ schemasByKey,
665
+ hasSchemasFile ? schemaTypeNames : undefined,
666
+ );
667
+
607
668
  attributes.push({
608
- archived: parsedAttribute.archived,
669
+ ...parsedAttribute,
609
670
  key: attributeKey,
610
- type: parsedAttribute.type,
611
- typescriptType: convertFeaturevisorTypeToTypeScriptType(parsedAttribute.type),
671
+ typescriptType,
612
672
  });
673
+ attributeTypeNames[attributeKey] = `${getPascalCase(attributeKey)}Attribute`;
613
674
  }
614
675
 
676
+ const attributesFileContent = generateAttributesFileContent(
677
+ attributes as Array<Attribute & { key: string; typescriptType: string }>,
678
+ hasSchemasFile ? schemaTypeNames : {},
679
+ );
680
+ const attributesFilePath = path.join(outputPath, "attributes.ts");
681
+ fs.writeFileSync(attributesFilePath, attributesFileContent);
682
+ console.log(
683
+ `Attributes type file written at: ${getRelativePath(rootDirectoryPath, attributesFilePath)}`,
684
+ );
685
+
615
686
  // context
687
+ const contextImport = formatTypeImport(Object.values(attributeTypeNames).sort(), "./attributes");
616
688
  const attributeProperties = attributes
617
689
  .map((attribute) => {
618
- return ` ${attribute.key}?: ${attribute.typescriptType};`;
690
+ return ` ${formatObjectKey(attribute.key)}?: ${attributeTypeNames[attribute.key]};`;
619
691
  })
620
692
  .join("\n");
621
693
  const contextContent = `
622
694
  import type { AttributeKey, AttributeValue } from "@featurevisor/sdk";
623
-
624
- export interface Context {
695
+ ${contextImport}export interface Context {
625
696
  ${attributeProperties}
626
697
  [key: AttributeKey]: AttributeValue;
627
698
  }
@@ -636,32 +707,6 @@ ${attributeProperties}
636
707
  // features
637
708
  const featureFiles = await datasource.listFeatures();
638
709
 
639
- // Load schemas for resolving variable schema references
640
- const schemaListKeys = await datasource.listSchemas();
641
- const schemasByKey: Record<string, Schema> = {};
642
- for (const key of schemaListKeys) {
643
- try {
644
- schemasByKey[key] = await datasource.readSchema(key);
645
- } catch {
646
- // Schema file may be invalid; skip for code gen
647
- }
648
- }
649
- const schemaKeys = Object.keys(schemasByKey);
650
- const hasSchemasFile = schemaKeys.length > 0;
651
- if (hasSchemasFile) {
652
- const schemasContent = generateSchemasFileContent(schemaKeys, schemasByKey);
653
- const schemasFilePath = path.join(outputPath, "schemas.ts");
654
- fs.writeFileSync(schemasFilePath, schemasContent);
655
- console.log(
656
- `Schemas type file written at: ${getRelativePath(rootDirectoryPath, schemasFilePath)}`,
657
- );
658
- }
659
-
660
- const schemaTypeNames: Record<string, string> = {};
661
- for (const k of schemaKeys) {
662
- schemaTypeNames[k] = getPascalCase(k) + "Schema";
663
- }
664
-
665
710
  const parsedFeatures: {
666
711
  featureKey: string;
667
712
  parsedFeature: ParsedFeature;
@@ -911,6 +956,7 @@ export function useVariable<F extends FeatureKey, V extends VariableKey<F>>(
911
956
  // index
912
957
  const indexContent =
913
958
  [
959
+ `export * from "./attributes";`,
914
960
  `export * from "./context";`,
915
961
  `export * from "./instance";`,
916
962
  ...(hasSchemasFile ? [`export * from "./schemas";`] : []),
@@ -0,0 +1,342 @@
1
+ import { z } from "zod";
2
+
3
+ import { getAttributeZodSchema } from "./attributeSchema";
4
+
5
+ function parseAttribute(input: unknown): z.SafeParseReturnType<unknown, unknown> {
6
+ return getAttributeZodSchema().safeParse(input);
7
+ }
8
+
9
+ function expectAttributeSuccess(input: unknown): void {
10
+ const result = parseAttribute(input);
11
+ expect(result.success).toBe(true);
12
+ if (!result.success) {
13
+ const error = (result as z.SafeParseError<unknown>).error;
14
+ const msg = error.issues.map((issue) => issue.message).join("; ");
15
+ throw new Error(`Expected attribute to pass: ${msg}`);
16
+ }
17
+ }
18
+
19
+ function expectAttributeFailure(input: unknown, messageSubstring?: string): z.ZodError {
20
+ const result = parseAttribute(input);
21
+ expect(result.success).toBe(false);
22
+ if (result.success) {
23
+ throw new Error("Expected attribute to fail");
24
+ }
25
+
26
+ const error = (result as z.SafeParseError<unknown>).error;
27
+
28
+ if (messageSubstring) {
29
+ const messages = error.issues.map((issue) => issue.message).join(" ");
30
+ expect(messages).toContain(messageSubstring);
31
+ }
32
+
33
+ return error;
34
+ }
35
+
36
+ describe("attributeSchema.ts :: getAttributeZodSchema", () => {
37
+ it("accepts flat object attributes without additional schema details", () => {
38
+ expectAttributeSuccess({
39
+ description: "Traits",
40
+ type: "object",
41
+ });
42
+ });
43
+
44
+ it("accepts flat object properties and typed arrays", () => {
45
+ expectAttributeSuccess({
46
+ description: "Account",
47
+ type: "object",
48
+ required: ["plan"],
49
+ properties: {
50
+ plan: {
51
+ type: "string",
52
+ enum: ["free", "pro"],
53
+ },
54
+ locale: {
55
+ type: "string",
56
+ },
57
+ permissions: {
58
+ type: "array",
59
+ items: {
60
+ type: "string",
61
+ enum: ["read", "write", "admin"],
62
+ },
63
+ },
64
+ },
65
+ });
66
+ });
67
+
68
+ it("accepts array attributes without items when they stay flat", () => {
69
+ expectAttributeSuccess({
70
+ description: "Tags",
71
+ type: "array",
72
+ });
73
+ });
74
+
75
+ it("accepts string-array enums on array attributes", () => {
76
+ expectAttributeSuccess({
77
+ description: "Permissions",
78
+ type: "array",
79
+ items: {
80
+ type: "string",
81
+ },
82
+ enum: [["read"], ["write", "admin"]],
83
+ });
84
+ });
85
+
86
+ it("accepts top-level oneOf attributes without type", () => {
87
+ expectAttributeSuccess({
88
+ description: "Version number of the app",
89
+ oneOf: [{ type: "string" }, { type: "double" }],
90
+ });
91
+ });
92
+
93
+ it("accepts top-level oneOf attributes with branch-specific enums", () => {
94
+ expectAttributeSuccess({
95
+ description: "Plan or score",
96
+ oneOf: [
97
+ { type: "string", enum: ["free", "pro"] },
98
+ { type: "double", minimum: 0 },
99
+ ],
100
+ });
101
+ });
102
+
103
+ it("requires items when array schema adds extra constraints", () => {
104
+ expectAttributeFailure(
105
+ {
106
+ description: "Permissions",
107
+ type: "array",
108
+ enum: [["read"], ["write"]],
109
+ },
110
+ "`items` is required",
111
+ );
112
+ });
113
+
114
+ it("rejects attributes without top-level type or oneOf", () => {
115
+ expectAttributeFailure(
116
+ {
117
+ description: "Country",
118
+ },
119
+ "either `type` or `oneOf`",
120
+ );
121
+ });
122
+
123
+ it("rejects attributes with both top-level type and oneOf", () => {
124
+ expectAttributeFailure(
125
+ {
126
+ description: "Version number of the app",
127
+ type: "string",
128
+ oneOf: [{ type: "string" }, { type: "double" }],
129
+ },
130
+ "cannot have both `type` and `oneOf`",
131
+ );
132
+ });
133
+
134
+ it("rejects top-level oneOf attributes when a branch enum does not match its type", () => {
135
+ expectAttributeFailure(
136
+ {
137
+ description: "Plan or score",
138
+ oneOf: [{ type: "string", enum: ["free", 1] }, { type: "double" }],
139
+ },
140
+ 'does not match type "string"',
141
+ );
142
+ });
143
+
144
+ it("rejects nested object properties inside object attributes", () => {
145
+ expectAttributeFailure(
146
+ {
147
+ description: "Account",
148
+ type: "object",
149
+ properties: {
150
+ metadata: {
151
+ type: "object",
152
+ properties: {
153
+ locale: {
154
+ type: "string",
155
+ },
156
+ },
157
+ },
158
+ },
159
+ },
160
+ "must stay flat",
161
+ );
162
+ });
163
+
164
+ it("rejects object additionalProperties that allow nested objects", () => {
165
+ expectAttributeFailure(
166
+ {
167
+ description: "Labels",
168
+ type: "object",
169
+ additionalProperties: {
170
+ type: "object",
171
+ properties: {
172
+ locale: {
173
+ type: "string",
174
+ },
175
+ },
176
+ },
177
+ },
178
+ "must stay flat",
179
+ );
180
+ });
181
+
182
+ it("rejects object properties whose oneOf allows nested objects", () => {
183
+ expectAttributeFailure(
184
+ {
185
+ description: "Account",
186
+ type: "object",
187
+ properties: {
188
+ metadata: {
189
+ oneOf: [
190
+ { type: "string" },
191
+ {
192
+ type: "object",
193
+ properties: {
194
+ locale: {
195
+ type: "string",
196
+ },
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ },
203
+ "must stay flat",
204
+ );
205
+ });
206
+
207
+ it("rejects object additionalProperties whose oneOf allows nested objects", () => {
208
+ expectAttributeFailure(
209
+ {
210
+ description: "Labels",
211
+ type: "object",
212
+ additionalProperties: {
213
+ oneOf: [
214
+ { type: "string" },
215
+ {
216
+ type: "object",
217
+ properties: {
218
+ locale: {
219
+ type: "string",
220
+ },
221
+ },
222
+ },
223
+ ],
224
+ },
225
+ },
226
+ "must stay flat",
227
+ );
228
+ });
229
+
230
+ it("rejects non-string item types for array attributes", () => {
231
+ expectAttributeFailure(
232
+ {
233
+ description: "Scores",
234
+ type: "array",
235
+ items: {
236
+ type: "integer",
237
+ },
238
+ },
239
+ "items.type",
240
+ );
241
+ });
242
+
243
+ it("rejects non-string enum values for array attributes", () => {
244
+ expectAttributeFailure(
245
+ {
246
+ description: "Scores",
247
+ type: "array",
248
+ enum: [[1, 2]],
249
+ },
250
+ "array of strings",
251
+ );
252
+ });
253
+
254
+ it("rejects non-string const values for array attributes", () => {
255
+ expectAttributeFailure(
256
+ {
257
+ description: "Scores",
258
+ type: "array",
259
+ items: {
260
+ type: "string",
261
+ },
262
+ const: [1, 2],
263
+ },
264
+ "array of strings",
265
+ );
266
+ });
267
+
268
+ it("rejects required keys that are not declared in properties", () => {
269
+ expectAttributeFailure(
270
+ {
271
+ description: "Account",
272
+ type: "object",
273
+ required: ["plan", "tier"],
274
+ properties: {
275
+ plan: {
276
+ type: "string",
277
+ },
278
+ },
279
+ },
280
+ "Unknown required field",
281
+ );
282
+ });
283
+
284
+ it("accepts required keys on flat object attributes without declared properties", () => {
285
+ expectAttributeSuccess({
286
+ description: "Traits",
287
+ type: "object",
288
+ required: ["plan"],
289
+ });
290
+ });
291
+
292
+ it("rejects enum values that do not match semver/date string-like types", () => {
293
+ expectAttributeFailure(
294
+ {
295
+ description: "Browser version",
296
+ type: "semver",
297
+ enum: ["1.0.0", 2],
298
+ },
299
+ 'does not match type "semver"',
300
+ );
301
+ });
302
+
303
+ it("rejects minimum values greater than maximum for numeric attributes", () => {
304
+ expectAttributeFailure(
305
+ {
306
+ description: "Score",
307
+ type: "double",
308
+ minimum: 10,
309
+ maximum: 5,
310
+ },
311
+ "minimum",
312
+ );
313
+ });
314
+
315
+ it("rejects minLength values greater than maxLength for string attributes", () => {
316
+ expectAttributeFailure(
317
+ {
318
+ description: "Country",
319
+ type: "string",
320
+ minLength: 5,
321
+ maxLength: 2,
322
+ },
323
+ "minLength",
324
+ );
325
+ });
326
+
327
+ it("rejects invalid string patterns nested under object properties", () => {
328
+ expectAttributeFailure(
329
+ {
330
+ description: "Profile",
331
+ type: "object",
332
+ properties: {
333
+ email: {
334
+ type: "string",
335
+ pattern: "[",
336
+ },
337
+ },
338
+ },
339
+ "valid ECMA-262",
340
+ );
341
+ });
342
+ });