@featurevisor/core 2.25.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.
- package/CHANGELOG.md +11 -0
- package/coverage/clover.xml +1499 -1476
- package/coverage/coverage-final.json +18 -17
- package/coverage/lcov-report/builder/allocator.ts.html +1 -1
- package/coverage/lcov-report/builder/buildDatafile.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedConditions.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedDatafile.ts.html +1 -1
- package/coverage/lcov-report/builder/buildScopedSegments.ts.html +1 -1
- package/coverage/lcov-report/builder/convertToV1.ts.html +1 -1
- package/coverage/lcov-report/builder/getFeatureRanges.ts.html +1 -1
- package/coverage/lcov-report/builder/hashes.ts.html +1 -1
- package/coverage/lcov-report/builder/index.html +1 -1
- package/coverage/lcov-report/builder/mutateVariables.ts.html +1 -1
- package/coverage/lcov-report/builder/mutator.ts.html +52 -52
- package/coverage/lcov-report/builder/revision.ts.html +1 -1
- package/coverage/lcov-report/builder/traffic.ts.html +1 -1
- package/coverage/lcov-report/config/index.html +1 -1
- package/coverage/lcov-report/config/index.ts.html +1 -1
- package/coverage/lcov-report/config/projectConfig.ts.html +15 -15
- package/coverage/lcov-report/datasource/adapter.ts.html +1 -1
- package/coverage/lcov-report/datasource/datasource.ts.html +18 -18
- package/coverage/lcov-report/datasource/filesystemAdapter.ts.html +43 -43
- package/coverage/lcov-report/datasource/index.html +1 -1
- package/coverage/lcov-report/datasource/index.ts.html +1 -1
- package/coverage/lcov-report/generate-code/index.html +1 -1
- package/coverage/lcov-report/generate-code/typescript.ts.html +1 -1
- package/coverage/lcov-report/index.html +18 -18
- package/coverage/lcov-report/linter/attributeSchema.ts.html +130 -130
- package/coverage/lcov-report/linter/checkCircularDependency.ts.html +11 -11
- package/coverage/lcov-report/linter/checkPercentageExceedingSlot.ts.html +15 -15
- package/coverage/lcov-report/linter/conditionSchema.ts.html +150 -150
- package/coverage/lcov-report/linter/featureSchema.ts.html +458 -443
- package/coverage/lcov-report/linter/groupSchema.ts.html +24 -27
- package/coverage/lcov-report/linter/index.html +83 -68
- package/coverage/lcov-report/linter/lintProject.ts.html +158 -158
- package/coverage/lcov-report/linter/mutationNotation.ts.html +104 -104
- package/coverage/lcov-report/linter/printError.ts.html +79 -28
- package/coverage/lcov-report/linter/schema.ts.html +129 -135
- package/coverage/lcov-report/linter/segmentSchema.ts.html +15 -9
- package/coverage/lcov-report/linter/testSchema.ts.html +67 -94
- package/coverage/lcov-report/linter/zodHelpers.ts.html +133 -0
- package/coverage/lcov-report/list/index.html +1 -1
- package/coverage/lcov-report/list/matrix.ts.html +1 -1
- package/coverage/lcov-report/tester/cliFormat.ts.html +6 -6
- package/coverage/lcov-report/tester/helpers.ts.html +1 -1
- package/coverage/lcov-report/tester/index.html +1 -1
- package/coverage/lcov-report/utils/extractKeys.ts.html +1 -1
- package/coverage/lcov-report/utils/git.ts.html +1 -1
- package/coverage/lcov-report/utils/index.html +1 -1
- package/coverage/lcov-report/utils/index.ts.html +1 -1
- package/coverage/lcov-report/utils/pretty.ts.html +1 -1
- package/coverage/lcov.info +3031 -2984
- package/lib/linter/attributeSchema.d.ts +19 -352
- package/lib/linter/attributeSchema.spec.js.map +1 -1
- package/lib/linter/conditionSchema.d.ts +1 -1
- package/lib/linter/conditionSchema.js +2 -3
- package/lib/linter/conditionSchema.js.map +1 -1
- package/lib/linter/conditionSchema.spec.js.map +1 -1
- package/lib/linter/featureSchema.d.ts +75 -964
- package/lib/linter/featureSchema.js +64 -53
- package/lib/linter/featureSchema.js.map +1 -1
- package/lib/linter/featureSchema.spec.js +28 -0
- package/lib/linter/featureSchema.spec.js.map +1 -1
- package/lib/linter/groupSchema.d.ts +4 -28
- package/lib/linter/groupSchema.js +3 -5
- package/lib/linter/groupSchema.js.map +1 -1
- package/lib/linter/lintProject.spec.js +101 -13
- package/lib/linter/lintProject.spec.js.map +1 -1
- package/lib/linter/printError.js +21 -7
- package/lib/linter/printError.js.map +1 -1
- package/lib/linter/schema.d.ts +9 -2
- package/lib/linter/schema.js +2 -6
- package/lib/linter/schema.js.map +1 -1
- package/lib/linter/schema.spec.js +3 -2
- package/lib/linter/schema.spec.js.map +1 -1
- package/lib/linter/segmentSchema.d.ts +1 -9
- package/lib/linter/segmentSchema.js +3 -1
- package/lib/linter/segmentSchema.js.map +1 -1
- package/lib/linter/segmentSchema.spec.js.map +1 -1
- package/lib/linter/testSchema.d.ts +16 -222
- package/lib/linter/testSchema.js +23 -38
- package/lib/linter/testSchema.js.map +1 -1
- package/lib/linter/testSchema.spec.d.ts +1 -0
- package/lib/linter/testSchema.spec.js +162 -0
- package/lib/linter/testSchema.spec.js.map +1 -0
- package/lib/linter/zodHelpers.d.ts +2 -0
- package/lib/linter/zodHelpers.js +15 -0
- package/lib/linter/zodHelpers.js.map +1 -0
- package/package.json +3 -3
- package/src/linter/attributeSchema.spec.ts +3 -3
- package/src/linter/conditionSchema.spec.ts +3 -3
- package/src/linter/conditionSchema.ts +4 -4
- package/src/linter/featureSchema.spec.ts +39 -4
- package/src/linter/featureSchema.ts +82 -77
- package/src/linter/groupSchema.ts +11 -12
- package/src/linter/lintProject.spec.ts +121 -13
- package/src/linter/printError.ts +27 -10
- package/src/linter/schema.spec.ts +12 -11
- package/src/linter/schema.ts +7 -9
- package/src/linter/segmentSchema.spec.ts +5 -5
- package/src/linter/segmentSchema.ts +3 -1
- package/src/linter/testSchema.spec.ts +193 -0
- package/src/linter/testSchema.ts +36 -45
- 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 =
|
|
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
|
-
.
|
|
1389
|
-
(value
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
-
.
|
|
1397
|
-
(
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
-
.
|
|
1408
|
-
(
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
}
|
|
1410
|
+
.superRefine((value, ctx) => {
|
|
1411
|
+
if (!projectConfig.enforceCatchAllRule) {
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1412
1414
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1469
|
+
refineWithMessage(
|
|
1470
|
+
z.string(),
|
|
1469
1471
|
(value) => tagRegex.test(value),
|
|
1470
|
-
(value) =>
|
|
1471
|
-
|
|
1472
|
-
}),
|
|
1472
|
+
(value) =>
|
|
1473
|
+
`Tag "${value}" must be lower cased and alphanumeric, and may contain hyphens.`,
|
|
1473
1474
|
),
|
|
1474
1475
|
)
|
|
1475
|
-
.
|
|
1476
|
-
(value)
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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:
|
|
1516
|
-
.string()
|
|
1517
|
-
.
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
.
|
|
1667
|
-
(
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
19
|
-
.string()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
+
`
|
|
277
|
+
rules:
|
|
278
|
+
- key: everyone
|
|
279
|
+
segments: '*'
|
|
280
|
+
percentage: invalid
|
|
281
|
+
`.trimStart(),
|
|
174
282
|
"utf8",
|
|
175
283
|
);
|
|
176
284
|
|
package/src/linter/printError.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(" ");
|
package/src/linter/schema.ts
CHANGED
|
@@ -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:
|
|
464
|
-
.string()
|
|
465
|
-
.
|
|
466
|
-
|
|
467
|
-
|
|
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()
|