@featurevisor/core 2.24.0 → 2.26.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 (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/coverage/clover.xml +1499 -1476
  3. package/coverage/coverage-final.json +18 -17
  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 +52 -52
  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 +15 -15
  20. package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
  21. package/coverage/lcov-report/datasource/datasource.ts.html +18 -18
  22. package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +43 -43
  23. package/coverage/lcov-report/datasource/index.html +1 -1
  24. package/coverage/lcov-report/datasource/index.ts.html +1 -1
  25. package/coverage/lcov-report/generate-code/index.html +1 -1
  26. package/coverage/lcov-report/generate-code/typescript.ts.html +1 -1
  27. package/coverage/lcov-report/index.html +18 -18
  28. package/coverage/lcov-report/linter/attributeSchema.ts.html +130 -130
  29. package/coverage/lcov-report/linter/checkCircularDependency.ts.html +11 -11
  30. package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +15 -15
  31. package/coverage/lcov-report/linter/conditionSchema.ts.html +150 -150
  32. package/coverage/lcov-report/linter/featureSchema.ts.html +458 -443
  33. package/coverage/lcov-report/linter/groupSchema.ts.html +24 -27
  34. package/coverage/lcov-report/linter/index.html +83 -68
  35. package/coverage/lcov-report/linter/lintProject.ts.html +158 -158
  36. package/coverage/lcov-report/linter/mutationNotation.ts.html +104 -104
  37. package/coverage/lcov-report/linter/printError.ts.html +79 -28
  38. package/coverage/lcov-report/linter/schema.ts.html +129 -135
  39. package/coverage/lcov-report/linter/segmentSchema.ts.html +15 -9
  40. package/coverage/lcov-report/linter/testSchema.ts.html +67 -94
  41. package/coverage/lcov-report/linter/zodHelpers.ts.html +133 -0
  42. package/coverage/lcov-report/list/index.html +1 -1
  43. package/coverage/lcov-report/list/matrix.ts.html +1 -1
  44. package/coverage/lcov-report/tester/cliFormat.ts.html +6 -6
  45. package/coverage/lcov-report/tester/helpers.ts.html +1 -1
  46. package/coverage/lcov-report/tester/index.html +1 -1
  47. package/coverage/lcov-report/utils/extractKeys.ts.html +1 -1
  48. package/coverage/lcov-report/utils/git.ts.html +1 -1
  49. package/coverage/lcov-report/utils/index.html +1 -1
  50. package/coverage/lcov-report/utils/index.ts.html +1 -1
  51. package/coverage/lcov-report/utils/pretty.ts.html +1 -1
  52. package/coverage/lcov.info +3031 -2984
  53. package/lib/linter/attributeSchema.d.ts +19 -352
  54. package/lib/linter/attributeSchema.spec.js.map +1 -1
  55. package/lib/linter/conditionSchema.d.ts +1 -1
  56. package/lib/linter/conditionSchema.js +2 -3
  57. package/lib/linter/conditionSchema.js.map +1 -1
  58. package/lib/linter/conditionSchema.spec.js.map +1 -1
  59. package/lib/linter/featureSchema.d.ts +75 -964
  60. package/lib/linter/featureSchema.js +64 -53
  61. package/lib/linter/featureSchema.js.map +1 -1
  62. package/lib/linter/featureSchema.spec.js +28 -0
  63. package/lib/linter/featureSchema.spec.js.map +1 -1
  64. package/lib/linter/groupSchema.d.ts +4 -28
  65. package/lib/linter/groupSchema.js +3 -5
  66. package/lib/linter/groupSchema.js.map +1 -1
  67. package/lib/linter/lintProject.spec.js +101 -13
  68. package/lib/linter/lintProject.spec.js.map +1 -1
  69. package/lib/linter/printError.js +21 -7
  70. package/lib/linter/printError.js.map +1 -1
  71. package/lib/linter/schema.d.ts +9 -2
  72. package/lib/linter/schema.js +2 -6
  73. package/lib/linter/schema.js.map +1 -1
  74. package/lib/linter/schema.spec.js +3 -2
  75. package/lib/linter/schema.spec.js.map +1 -1
  76. package/lib/linter/segmentSchema.d.ts +1 -9
  77. package/lib/linter/segmentSchema.js +3 -1
  78. package/lib/linter/segmentSchema.js.map +1 -1
  79. package/lib/linter/segmentSchema.spec.js.map +1 -1
  80. package/lib/linter/testSchema.d.ts +16 -222
  81. package/lib/linter/testSchema.js +23 -38
  82. package/lib/linter/testSchema.js.map +1 -1
  83. package/lib/linter/testSchema.spec.d.ts +1 -0
  84. package/lib/linter/testSchema.spec.js +162 -0
  85. package/lib/linter/testSchema.spec.js.map +1 -0
  86. package/lib/linter/zodHelpers.d.ts +2 -0
  87. package/lib/linter/zodHelpers.js +15 -0
  88. package/lib/linter/zodHelpers.js.map +1 -0
  89. package/package.json +4 -3
  90. package/src/linter/attributeSchema.spec.ts +3 -3
  91. package/src/linter/conditionSchema.spec.ts +3 -3
  92. package/src/linter/conditionSchema.ts +4 -4
  93. package/src/linter/featureSchema.spec.ts +39 -4
  94. package/src/linter/featureSchema.ts +82 -77
  95. package/src/linter/groupSchema.ts +11 -12
  96. package/src/linter/lintProject.spec.ts +121 -13
  97. package/src/linter/printError.ts +27 -10
  98. package/src/linter/schema.spec.ts +12 -11
  99. package/src/linter/schema.ts +7 -9
  100. package/src/linter/segmentSchema.spec.ts +5 -5
  101. package/src/linter/segmentSchema.ts +3 -1
  102. package/src/linter/testSchema.spec.ts +193 -0
  103. package/src/linter/testSchema.ts +36 -45
  104. package/src/linter/zodHelpers.ts +16 -0
@@ -12,6 +12,7 @@ import {
12
12
  refineStringLengthPattern,
13
13
  refineArrayItems,
14
14
  } from "./schema";
15
+ import { refineWithMessage } from "./zodHelpers";
15
16
 
16
17
  const tagRegex = /^[a-z0-9-]+$/;
17
18
 
@@ -1306,7 +1307,7 @@ export function getFeatureZodSchema(
1306
1307
  const schemaZodSchema = getSchemaZodSchema(availableSchemaKeys);
1307
1308
  const variableValueZodSchema = valueZodSchema;
1308
1309
  const variableValueOrNullZodSchema = z.union([valueZodSchema, z.null()]);
1309
- const overridePathMapValueZodSchema = z.record(variableValueOrNullZodSchema);
1310
+ const overridePathMapValueZodSchema = z.record(z.string(), variableValueOrNullZodSchema);
1310
1311
  const overrideValueZodSchema = z.union([
1311
1312
  variableValueOrNullZodSchema,
1312
1313
  overridePathMapValueZodSchema,
@@ -1314,11 +1315,10 @@ export function getFeatureZodSchema(
1314
1315
 
1315
1316
  const variationValueZodSchema = z.string().min(1);
1316
1317
 
1317
- const plainGroupSegment = z.string().refine(
1318
+ const plainGroupSegment = refineWithMessage(
1319
+ z.string(),
1318
1320
  (value) => value === "*" || availableSegmentKeys.includes(value),
1319
- (value) => ({
1320
- message: `Unknown segment key "${value}"`,
1321
- }),
1321
+ (value) => `Unknown segment key "${value}"`,
1322
1322
  );
1323
1323
 
1324
1324
  const andOrNotGroupSegment = z.union([
@@ -1358,9 +1358,10 @@ export function getFeatureZodSchema(
1358
1358
 
1359
1359
  enabled: z.boolean().optional(),
1360
1360
  variation: variationValueZodSchema.optional(),
1361
- variables: z.record(variableValueOrNullZodSchema).optional(),
1361
+ variables: z.record(z.string(), variableValueOrNullZodSchema).optional(),
1362
1362
  variableOverrides: z
1363
1363
  .record(
1364
+ z.string(),
1364
1365
  z.array(
1365
1366
  z.union([
1366
1367
  z
@@ -1379,44 +1380,46 @@ export function getFeatureZodSchema(
1379
1380
  ),
1380
1381
  )
1381
1382
  .optional(),
1382
- variationWeights: z.record(z.number().min(0).max(100)).optional(),
1383
+ variationWeights: z.record(z.string(), z.number().min(0).max(100)).optional(),
1383
1384
  })
1384
1385
  .strict(),
1385
1386
  )
1386
1387
 
1387
1388
  // must have at least one rule
1388
- .refine(
1389
- (value) => value.length > 0,
1390
- () => ({
1391
- message: "Must have at least one rule",
1392
- }),
1393
- )
1389
+ .superRefine((value, ctx) => {
1390
+ if (value.length === 0) {
1391
+ ctx.addIssue({
1392
+ code: z.ZodIssueCode.custom,
1393
+ message: "Must have at least one rule",
1394
+ });
1395
+ }
1396
+ })
1394
1397
 
1395
1398
  // duplicate rules
1396
- .refine(
1397
- (value) => {
1398
- const keys = value.map((v) => v.key);
1399
- return keys.length === new Set(keys).size;
1400
- },
1401
- (value) => ({
1402
- message: "Duplicate rule keys found: " + value.map((v) => v.key).join(", "),
1403
- }),
1404
- )
1399
+ .superRefine((value, ctx) => {
1400
+ const keys = value.map((v) => v.key);
1401
+ if (keys.length !== new Set(keys).size) {
1402
+ ctx.addIssue({
1403
+ code: z.ZodIssueCode.custom,
1404
+ message: "Duplicate rule keys found: " + value.map((v) => v.key).join(", "),
1405
+ });
1406
+ }
1407
+ })
1405
1408
 
1406
1409
  // enforce catch-all rule
1407
- .refine(
1408
- (value) => {
1409
- if (!projectConfig.enforceCatchAllRule) {
1410
- return true;
1411
- }
1410
+ .superRefine((value, ctx) => {
1411
+ if (!projectConfig.enforceCatchAllRule) {
1412
+ return;
1413
+ }
1412
1414
 
1413
- const hasCatchAllAsLastRule = value[value.length - 1].segments === "*";
1414
- return hasCatchAllAsLastRule;
1415
- },
1416
- () => ({
1417
- message: `Missing catch-all rule with \`segments: "*"\` at the end`,
1418
- }),
1419
- );
1415
+ const hasCatchAllAsLastRule = value[value.length - 1].segments === "*";
1416
+ if (!hasCatchAllAsLastRule) {
1417
+ ctx.addIssue({
1418
+ code: z.ZodIssueCode.custom,
1419
+ message: `Missing catch-all rule with \`segments: "*"\` at the end`,
1420
+ });
1421
+ }
1422
+ });
1420
1423
 
1421
1424
  const forceSchema = z
1422
1425
  .array(
@@ -1426,7 +1429,7 @@ export function getFeatureZodSchema(
1426
1429
  segments: groupSegmentsZodSchema,
1427
1430
  enabled: z.boolean().optional(),
1428
1431
  variation: variationValueZodSchema.optional(),
1429
- variables: z.record(variableValueOrNullZodSchema).optional(),
1432
+ variables: z.record(z.string(), variableValueOrNullZodSchema).optional(),
1430
1433
  })
1431
1434
  .strict(),
1432
1435
  z
@@ -1434,25 +1437,23 @@ export function getFeatureZodSchema(
1434
1437
  conditions: conditionsZodSchema,
1435
1438
  enabled: z.boolean().optional(),
1436
1439
  variation: variationValueZodSchema.optional(),
1437
- variables: z.record(variableValueOrNullZodSchema).optional(),
1440
+ variables: z.record(z.string(), variableValueOrNullZodSchema).optional(),
1438
1441
  })
1439
1442
  .strict(),
1440
1443
  ]),
1441
1444
  )
1442
1445
  .optional();
1443
1446
 
1444
- const attributeKeyZodSchema = z.string().refine(
1447
+ const attributeKeyZodSchema = refineWithMessage(
1448
+ z.string(),
1445
1449
  (value) => value === "*" || availableAttributeKeys.includes(value),
1446
- (value) => ({
1447
- message: `Unknown attribute "${value}"`,
1448
- }),
1450
+ (value) => `Unknown attribute "${value}"`,
1449
1451
  );
1450
1452
 
1451
- const featureKeyZodSchema = z.string().refine(
1453
+ const featureKeyZodSchema = refineWithMessage(
1454
+ z.string(),
1452
1455
  (value) => availableFeatureKeys.includes(value),
1453
- (value) => ({
1454
- message: `Unknown feature "${value}"`,
1455
- }),
1456
+ (value) => `Unknown feature "${value}"`,
1456
1457
  );
1457
1458
 
1458
1459
  const environmentKeys = projectConfig.environments || [];
@@ -1465,21 +1466,21 @@ export function getFeatureZodSchema(
1465
1466
 
1466
1467
  tags: z
1467
1468
  .array(
1468
- z.string().refine(
1469
+ refineWithMessage(
1470
+ z.string(),
1469
1471
  (value) => tagRegex.test(value),
1470
- (value) => ({
1471
- message: `Tag "${value}" must be lower cased and alphanumeric, and may contain hyphens.`,
1472
- }),
1472
+ (value) =>
1473
+ `Tag "${value}" must be lower cased and alphanumeric, and may contain hyphens.`,
1473
1474
  ),
1474
1475
  )
1475
- .refine(
1476
- (value) => {
1477
- return value.length === new Set(value).size;
1478
- },
1479
- (value) => ({
1480
- message: "Duplicate tags found: " + value.join(", "),
1481
- }),
1482
- ),
1476
+ .superRefine((value, ctx) => {
1477
+ if (value.length !== new Set(value).size) {
1478
+ ctx.addIssue({
1479
+ code: z.ZodIssueCode.custom,
1480
+ message: "Duplicate tags found: " + value.join(", "),
1481
+ });
1482
+ }
1483
+ }),
1483
1484
 
1484
1485
  required: z
1485
1486
  .array(
@@ -1507,23 +1508,22 @@ export function getFeatureZodSchema(
1507
1508
 
1508
1509
  variablesSchema: z
1509
1510
  .record(
1511
+ z.string(),
1510
1512
  z
1511
1513
  .object({
1512
1514
  deprecated: z.boolean().optional(),
1513
1515
 
1514
1516
  // Reference to a reusable schema (mutually exclusive with type/properties/required/items)
1515
- schema: z
1516
- .string()
1517
- .refine(
1518
- (value) => availableSchemaKeys.includes(value),
1519
- (value) => ({ message: `Unknown schema "${value}"` }),
1520
- )
1521
- .optional(),
1517
+ schema: refineWithMessage(
1518
+ z.string(),
1519
+ (value) => availableSchemaKeys.includes(value),
1520
+ (value) => `Unknown schema "${value}"`,
1521
+ ).optional(),
1522
1522
 
1523
1523
  // Inline schema (mutually exclusive with schema)
1524
1524
  type: z.union([z.literal("json"), propertyTypeEnum]).optional(),
1525
1525
  items: schemaZodSchema.optional(),
1526
- properties: z.record(schemaZodSchema).optional(),
1526
+ properties: z.record(z.string(), schemaZodSchema).optional(),
1527
1527
  additionalProperties: schemaZodSchema.optional(),
1528
1528
  required: z.array(z.string()).optional(),
1529
1529
  enum: z.array(variableValueZodSchema).optional(),
@@ -1639,9 +1639,10 @@ export function getFeatureZodSchema(
1639
1639
  description: z.string().optional(),
1640
1640
  value: variationValueZodSchema,
1641
1641
  weight: z.number().min(0).max(100),
1642
- variables: z.record(variableValueOrNullZodSchema).optional(),
1642
+ variables: z.record(z.string(), variableValueOrNullZodSchema).optional(),
1643
1643
  variableOverrides: z
1644
1644
  .record(
1645
+ z.string(),
1645
1646
  z.array(
1646
1647
  z.union([
1647
1648
  z
@@ -1663,31 +1664,35 @@ export function getFeatureZodSchema(
1663
1664
  })
1664
1665
  .strict(),
1665
1666
  )
1666
- .refine(
1667
- (value) => {
1668
- const variationValues = value.map((v) => v.value);
1669
- return variationValues.length === new Set(variationValues).size;
1670
- },
1671
- (value) => ({
1672
- message: "Duplicate variation values found: " + value.map((v) => v.value).join(", "),
1673
- }),
1674
- )
1667
+ .superRefine((value, ctx) => {
1668
+ const variationValues = value.map((v) => v.value);
1669
+ if (variationValues.length !== new Set(variationValues).size) {
1670
+ ctx.addIssue({
1671
+ code: z.ZodIssueCode.custom,
1672
+ message: "Duplicate variation values found: " + value.map((v) => v.value).join(", "),
1673
+ });
1674
+ }
1675
+ })
1675
1676
  .optional(),
1676
1677
 
1677
1678
  expose:
1678
1679
  projectConfig.environments === false
1679
1680
  ? exposeSchema.optional()
1680
- : z.record(z.enum(environmentKeys as [string, ...string[]]), exposeSchema).optional(),
1681
+ : z
1682
+ .partialRecord(z.enum(environmentKeys as [string, ...string[]]), exposeSchema)
1683
+ .optional(),
1681
1684
 
1682
1685
  force:
1683
1686
  projectConfig.environments === false
1684
1687
  ? forceSchema
1685
- : z.record(z.enum(environmentKeys as [string, ...string[]]), forceSchema).optional(),
1688
+ : z
1689
+ .partialRecord(z.enum(environmentKeys as [string, ...string[]]), forceSchema)
1690
+ .optional(),
1686
1691
 
1687
1692
  rules:
1688
1693
  projectConfig.environments === false
1689
1694
  ? rulesSchema
1690
- : z.record(z.enum(environmentKeys as [string, ...string[]]), rulesSchema),
1695
+ : z.partialRecord(z.enum(environmentKeys as [string, ...string[]]), rulesSchema),
1691
1696
  })
1692
1697
  .strict()
1693
1698
  .superRefine((value, ctx) => {
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  import { ProjectConfig } from "../config";
4
4
  import { Datasource } from "../datasource";
5
+ import { refineWithMessage } from "./zodHelpers";
5
6
 
6
7
  export function getGroupZodSchema(
7
8
  projectConfig: ProjectConfig,
@@ -15,19 +16,17 @@ export function getGroupZodSchema(
15
16
  .array(
16
17
  z
17
18
  .object({
18
- feature: z
19
- .string()
20
- .optional()
21
- .refine(
22
- (value) => {
23
- if (value && availableFeatureKeys.indexOf(value) === -1) {
24
- return false;
25
- }
19
+ feature: refineWithMessage(
20
+ z.string().optional(),
21
+ (value) => {
22
+ if (value && availableFeatureKeys.indexOf(value) === -1) {
23
+ return false;
24
+ }
26
25
 
27
- return true;
28
- },
29
- (value) => ({ message: `Unknown feature "${value}"` }),
30
- ),
26
+ return true;
27
+ },
28
+ (value) => `Unknown feature "${value}"`,
29
+ ),
31
30
  percentage: z.number().min(0).max(100),
32
31
  })
33
32
  .strict(),
@@ -20,13 +20,13 @@ function createTempSplitProject() {
20
20
 
21
21
  fs.writeFileSync(
22
22
  path.join(tempRoot, "featurevisor.config.js"),
23
- [
24
- "module.exports = {",
25
- " environments: ['staging', 'production'],",
26
- " splitByEnvironment: true,",
27
- " tags: ['all'],",
28
- "};",
29
- ].join("\n"),
23
+ `
24
+ module.exports = {
25
+ environments: ['staging', 'production'],
26
+ splitByEnvironment: true,
27
+ tags: ['all'],
28
+ };
29
+ `.trimStart(),
30
30
  "utf8",
31
31
  );
32
32
 
@@ -36,25 +36,46 @@ function createTempSplitProject() {
36
36
 
37
37
  fs.writeFileSync(
38
38
  path.join(tempRoot, "features", "foo.yml"),
39
- ["key: foo", "description: Foo", "tags:", " - all", "bucketBy: userId"].join("\n"),
39
+ `
40
+ key: foo
41
+ description: Foo
42
+ tags:
43
+ - all
44
+ bucketBy: userId
45
+ `.trimStart(),
40
46
  "utf8",
41
47
  );
42
48
 
43
49
  fs.writeFileSync(
44
50
  path.join(tempRoot, "environments", "staging", "foo.yml"),
45
- ["rules:", " - key: everyone", " segments: '*'", " percentage: 100"].join("\n"),
51
+ `
52
+ rules:
53
+ - key: everyone
54
+ segments: '*'
55
+ percentage: 100
56
+ `.trimStart(),
46
57
  "utf8",
47
58
  );
48
59
 
49
60
  fs.writeFileSync(
50
61
  path.join(tempRoot, "environments", "production", "foo.yml"),
51
- ["rules:", " - key: everyone", " segments: '*'", " percentage: 0"].join("\n"),
62
+ `
63
+ rules:
64
+ - key: everyone
65
+ segments: '*'
66
+ percentage: 0
67
+ `.trimStart(),
52
68
  "utf8",
53
69
  );
54
70
 
55
71
  return tempRoot;
56
72
  }
57
73
 
74
+ function replaceInFile(filePath: string, search: string, replacement: string) {
75
+ const contents = fs.readFileSync(filePath, "utf8");
76
+ fs.writeFileSync(filePath, contents.replace(search, replacement), "utf8");
77
+ }
78
+
58
79
  function getDeps(rootDirectoryPath: string) {
59
80
  const projectConfig = getProjectConfig(rootDirectoryPath);
60
81
  const datasource = new Datasource(projectConfig, rootDirectoryPath);
@@ -91,7 +112,10 @@ describe("core: lintProject", function () {
91
112
  it("returns structured errors in JSON mode", async () => {
92
113
  fs.writeFileSync(
93
114
  path.join(tempProjectPath, "attributes", "invalid name.yml"),
94
- "description: this has an invalid key name\ntype: string\n",
115
+ `
116
+ description: this has an invalid key name
117
+ type: string
118
+ `.trimStart(),
95
119
  "utf8",
96
120
  );
97
121
 
@@ -114,6 +138,83 @@ describe("core: lintProject", function () {
114
138
  expect(result.errors[0].filePath).toContain("attributes");
115
139
  });
116
140
 
141
+ it("reports real example-1 segment, feature, and test schema mistakes with useful paths", async () => {
142
+ replaceInFile(
143
+ path.join(tempProjectPath, "segments", "desktop.yml"),
144
+ `
145
+ conditions:
146
+ - attribute: device
147
+ operator: equals
148
+ value: desktop
149
+ `.trimStart(),
150
+ `
151
+ conditions:
152
+ - attribute: device
153
+ operator: equals
154
+ value: desktop
155
+ - attribute: notARealAttribute
156
+ operator: equals
157
+ value: nope
158
+ `.trimStart(),
159
+ );
160
+
161
+ replaceInFile(
162
+ path.join(tempProjectPath, "features", "withSchema.yml"),
163
+ `
164
+ singleLink:
165
+ schema: link
166
+ defaultValue:
167
+ title: Home
168
+ url: /
169
+ `.trimStart(),
170
+ `
171
+ singleLink:
172
+ schema: link
173
+ defaultValue:
174
+ title: Home
175
+ url: 123
176
+ `.trimStart(),
177
+ );
178
+
179
+ replaceInFile(
180
+ path.join(tempProjectPath, "tests", "features", "withSchema.spec.yml"),
181
+ " environment: staging",
182
+ " environment: qa",
183
+ );
184
+
185
+ const result = await lintProject(getDeps(tempProjectPath) as any, { json: true });
186
+
187
+ expect(result.hasError).toBe(true);
188
+ expect(result.errors).toEqual(
189
+ expect.arrayContaining([
190
+ expect.objectContaining({
191
+ filePath: expect.stringContaining(path.join("segments", "desktop.yml")),
192
+ entityType: "segment",
193
+ key: "desktop",
194
+ message: 'Unknown attribute "notARealAttribute"',
195
+ path: ["conditions", 1, "attribute"],
196
+ code: "custom",
197
+ }),
198
+ expect.objectContaining({
199
+ filePath: expect.stringContaining(path.join("features", "withSchema.yml")),
200
+ entityType: "feature",
201
+ key: "withSchema",
202
+ message: 'Variable "url" (type string) must be a string; got number.',
203
+ path: ["variablesSchema", "singleLink", "defaultValue", "url"],
204
+ code: "custom",
205
+ }),
206
+ expect.objectContaining({
207
+ filePath: expect.stringContaining(path.join("tests", "features", "withSchema.spec.yml")),
208
+ entityType: "test",
209
+ key: "features/withSchema.spec",
210
+ message: 'Unknown environment "qa"',
211
+ path: ["assertions", 0, "environment"],
212
+ code: "custom",
213
+ }),
214
+ ]),
215
+ );
216
+ });
217
+
117
218
  it("plugin prints pretty JSON only once in --json --pretty mode", async () => {
118
219
  const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {});
119
220
 
@@ -135,7 +236,9 @@ describe("core: lintProject", function () {
135
236
  it("does not call process.exit for zod validation errors while linting tests", async () => {
136
237
  fs.writeFileSync(
137
238
  path.join(tempProjectPath, "tests", "broken.spec.yml"),
138
- "feature: foo\n",
239
+ `
240
+ feature: foo
241
+ `.trimStart(),
139
242
  "utf8",
140
243
  );
141
244
 
@@ -170,7 +273,12 @@ describe("core: lintProject", function () {
170
273
  const splitProjectPath = createTempSplitProject();
171
274
  fs.writeFileSync(
172
275
  path.join(splitProjectPath, "environments", "staging", "foo.yml"),
173
- ["rules:", " - key: everyone", " segments: '*'", " percentage: invalid"].join("\n"),
276
+ `
277
+ rules:
278
+ - key: everyone
279
+ segments: '*'
280
+ percentage: invalid
281
+ `.trimStart(),
174
282
  "utf8",
175
283
  );
176
284
 
@@ -1,4 +1,4 @@
1
- import { ZodError } from "zod";
1
+ import { ZodError, ZodIssue } from "zod";
2
2
 
3
3
  import { CLI_FORMAT_RED } from "../tester/cliFormat";
4
4
 
@@ -9,20 +9,37 @@ export interface LintIssueFromZod {
9
9
  value?: unknown;
10
10
  }
11
11
 
12
+ function normalizePath(path: PropertyKey[]): (string | number)[] {
13
+ return path.map((segment) => (typeof segment === "symbol" ? String(segment) : segment));
14
+ }
15
+
16
+ function getInvalidUnionIssue(issue: ZodIssue): ZodIssue | undefined {
17
+ if (issue.code !== "invalid_union" || issue.path.length > 0) {
18
+ return undefined;
19
+ }
20
+
21
+ const unionErrors = (issue as any).unionErrors as ZodError[] | undefined;
22
+ if (Array.isArray(unionErrors) && unionErrors.length > 0) {
23
+ return unionErrors[unionErrors.length - 1].issues[0];
24
+ }
25
+
26
+ const errors = (issue as any).errors as ZodIssue[][] | undefined;
27
+ if (Array.isArray(errors) && errors.length > 0) {
28
+ return errors[errors.length - 1][0];
29
+ }
30
+
31
+ return undefined;
32
+ }
33
+
12
34
  export function getLintIssuesFromZodError(e: ZodError): LintIssueFromZod[] {
13
35
  return e.issues
14
36
  .map((issue) => {
15
- if (
16
- issue.code === "invalid_union" &&
17
- issue.path.length === 0 &&
18
- issue.unionErrors.length > 0
19
- ) {
20
- const lastUnionError = issue.unionErrors[issue.unionErrors.length - 1];
21
- const nestedIssue = lastUnionError.issues[0];
37
+ const nestedIssue = getInvalidUnionIssue(issue);
22
38
 
39
+ if (nestedIssue) {
23
40
  return {
24
41
  message: nestedIssue.message,
25
- path: nestedIssue.path,
42
+ path: normalizePath(nestedIssue.path),
26
43
  code: nestedIssue.code,
27
44
  value: (nestedIssue as any).received,
28
45
  };
@@ -30,7 +47,7 @@ export function getLintIssuesFromZodError(e: ZodError): LintIssueFromZod[] {
30
47
 
31
48
  return {
32
49
  message: issue.message,
33
- path: issue.path,
50
+ path: normalizePath(issue.path),
34
51
  code: issue.code,
35
52
  value: (issue as any).received,
36
53
  };
@@ -18,12 +18,13 @@ import {
18
18
  /** Build a refinement context that collects issues for assertion. */
19
19
  function createRefinementCtx(): { issues: z.ZodIssue[]; ctx: z.RefinementCtx } {
20
20
  const issues: z.ZodIssue[] = [];
21
- const ctx: z.RefinementCtx = {
22
- path: [],
23
- addIssue(issue: z.ZodIssue) {
24
- issues.push(issue);
21
+ const ctx = {
22
+ addIssue(issue: Parameters<z.RefinementCtx["addIssue"]>[0]) {
23
+ if (typeof issue !== "string") {
24
+ issues.push(issue as z.ZodIssue);
25
+ }
25
26
  },
26
- };
27
+ } as z.RefinementCtx;
27
28
  return { issues, ctx };
28
29
  }
29
30
 
@@ -440,7 +441,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
440
441
  const Schema = getSchemaZodSchema(["link", "color"]);
441
442
  const result = Schema.safeParse({ schema: "nonexistent" });
442
443
  expect(result.success).toBe(false);
443
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
444
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
444
445
  expect(err?.issues.some((i) => i.message === 'Unknown schema "nonexistent"')).toBe(true);
445
446
  });
446
447
 
@@ -456,7 +457,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
456
457
  oneOf: [{ type: "string" }],
457
458
  });
458
459
  expect(result.success).toBe(false);
459
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
460
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
460
461
  const msg =
461
462
  err?.issues.map((i) => (typeof i.message === "string" ? i.message : "")).join(" ") ?? "";
462
463
  expect(msg).toMatch(/array|length|minimum|oneOf|element/i);
@@ -474,7 +475,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
474
475
  const Schema = getSchemaZodSchema([]);
475
476
  const result = Schema.safeParse({ type: "array" });
476
477
  expect(result.success).toBe(false);
477
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
478
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
478
479
  expect(
479
480
  err?.issues.some(
480
481
  (i) =>
@@ -501,7 +502,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
501
502
  enum: ["a", 42, "c"],
502
503
  });
503
504
  expect(result.success).toBe(false);
504
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
505
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
505
506
  expect(
506
507
  err?.issues.some(
507
508
  (i) => typeof i.message === "string" && i.message.includes("does not match type"),
@@ -622,7 +623,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
622
623
  const Schema = getSchemaZodSchema(schemaKeys);
623
624
  const result = Schema.safeParse(invalidSchema);
624
625
  expect(result.success).toBe(false);
625
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
626
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
626
627
  expect(err).not.toBeNull();
627
628
  const messages = (err?.issues ?? [])
628
629
  .map((i) => (typeof i.message === "string" ? i.message : ""))
@@ -679,7 +680,7 @@ describe("schema.ts :: getSchemaZodSchema", () => {
679
680
  const Schema = getSchemaZodSchema([]);
680
681
  const result = Schema.safeParse({ type: "string", invalidKey: true });
681
682
  expect(result.success).toBe(false);
682
- const err = !result.success ? (result as z.SafeParseError<unknown>).error : null;
683
+ const err = !result.success ? (result as z.ZodSafeParseError<unknown>).error : null;
683
684
  const messages = (err?.issues ?? [])
684
685
  .map((i) => (typeof i.message === "string" ? i.message : ""))
685
686
  .join(" ");
@@ -1,6 +1,8 @@
1
1
  import type { Schema, SchemaKey, Value } from "@featurevisor/types";
2
2
  import { z } from "zod";
3
3
 
4
+ import { refineWithMessage } from "./zodHelpers";
5
+
4
6
  /** Returns true if value matches the schema type. */
5
7
  function valueMatchesType(v: unknown, type: string): boolean {
6
8
  switch (type) {
@@ -460,15 +462,11 @@ export function getSchemaZodSchema(schemaKeys: SchemaKey[] = []) {
460
462
  additionalProperties: schemaZodSchema.optional(),
461
463
  // Annotations: default?: Value; examples?: Value[];
462
464
 
463
- schema: z
464
- .string()
465
- .refine(
466
- (value) => schemaKeys.includes(value),
467
- (value) => ({
468
- message: `Unknown schema "${value}"`,
469
- }),
470
- )
471
- .optional(),
465
+ schema: refineWithMessage(
466
+ z.string(),
467
+ (value) => schemaKeys.includes(value),
468
+ (value) => `Unknown schema "${value}"`,
469
+ ).optional(),
472
470
  oneOf: z.array(schemaZodSchema).min(2).optional(),
473
471
  })
474
472
  .strict()