@eventvisor/core 0.23.0 → 0.25.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 +19 -0
- package/lib/linter/conditionsSchema.d.ts +1 -301
- package/lib/linter/conditionsSchema.js +8 -14
- package/lib/linter/conditionsSchema.js.map +1 -1
- package/lib/linter/destinationSchema.d.ts +89 -1
- package/lib/linter/destinationSchema.js +0 -2
- package/lib/linter/destinationSchema.js.map +1 -1
- package/lib/linter/effectSchema.d.ts +9 -1209
- package/lib/linter/effectSchema.js +6 -3
- package/lib/linter/effectSchema.js.map +1 -1
- package/lib/linter/entitySchemas.spec.d.ts +1 -0
- package/lib/linter/entitySchemas.spec.js +169 -0
- package/lib/linter/entitySchemas.spec.js.map +1 -0
- package/lib/linter/eventSchema.js +5 -3
- package/lib/linter/eventSchema.js.map +1 -1
- package/lib/linter/lintProject.d.ts +5 -0
- package/lib/linter/lintProject.js +141 -5
- package/lib/linter/lintProject.js.map +1 -1
- package/lib/linter/lintProject.spec.d.ts +1 -0
- package/lib/linter/lintProject.spec.js +103 -0
- package/lib/linter/lintProject.spec.js.map +1 -0
- package/lib/linter/persistSchema.d.ts +4 -604
- package/lib/linter/persistSchema.js +4 -2
- package/lib/linter/persistSchema.js.map +1 -1
- package/lib/linter/printError.js +4 -0
- package/lib/linter/printError.js.map +1 -1
- package/lib/linter/sampleSchema.d.ts +13 -285
- package/lib/linter/sampleSchema.js +2 -1
- package/lib/linter/sampleSchema.js.map +1 -1
- package/lib/linter/semanticValidation.d.ts +11 -0
- package/lib/linter/semanticValidation.js +592 -0
- package/lib/linter/semanticValidation.js.map +1 -0
- package/lib/linter/semanticValidation.spec.d.ts +1 -0
- package/lib/linter/semanticValidation.spec.js +190 -0
- package/lib/linter/semanticValidation.spec.js.map +1 -0
- package/lib/linter/testSchema.d.ts +64 -3
- package/lib/linter/testSchema.js +81 -4
- package/lib/linter/testSchema.js.map +1 -1
- package/lib/linter/testSchema.spec.d.ts +1 -0
- package/lib/linter/testSchema.spec.js +260 -0
- package/lib/linter/testSchema.spec.js.map +1 -0
- package/package.json +5 -5
- package/src/linter/conditionsSchema.ts +12 -18
- package/src/linter/destinationSchema.ts +0 -3
- package/src/linter/effectSchema.ts +12 -9
- package/src/linter/entitySchemas.spec.ts +212 -0
- package/src/linter/eventSchema.ts +8 -6
- package/src/linter/lintProject.spec.ts +112 -0
- package/src/linter/lintProject.ts +134 -6
- package/src/linter/persistSchema.ts +6 -4
- package/src/linter/printError.ts +3 -0
- package/src/linter/sampleSchema.ts +3 -1
- package/src/linter/semanticValidation.spec.ts +239 -0
- package/src/linter/semanticValidation.ts +953 -0
- package/src/linter/testSchema.spec.ts +279 -0
- package/src/linter/testSchema.ts +89 -4
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Attribute,
|
|
5
|
+
Destination,
|
|
6
|
+
Effect,
|
|
7
|
+
Event,
|
|
8
|
+
JSONSchema,
|
|
9
|
+
Test,
|
|
10
|
+
Transform,
|
|
11
|
+
Condition,
|
|
12
|
+
Sample,
|
|
13
|
+
Value,
|
|
14
|
+
} from "@eventvisor/types";
|
|
15
|
+
|
|
16
|
+
export interface LintContext {
|
|
17
|
+
attributes: Record<string, Attribute>;
|
|
18
|
+
events: Record<string, Event>;
|
|
19
|
+
destinations: Record<string, Destination>;
|
|
20
|
+
effects: Record<string, Effect>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type EntityType = "attribute" | "event" | "destination" | "effect" | "test";
|
|
24
|
+
|
|
25
|
+
interface ValidationState {
|
|
26
|
+
issues: z.ZodIssue[];
|
|
27
|
+
ctx: LintContext;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SourceValidationOptions {
|
|
31
|
+
path: (string | number)[];
|
|
32
|
+
allowedOrigins: Set<string>;
|
|
33
|
+
payloadSchemas?: JSONSchema[];
|
|
34
|
+
stateValue?: Value;
|
|
35
|
+
allowPayloadArrays?: boolean;
|
|
36
|
+
validateTargetAgainstPayload?: boolean;
|
|
37
|
+
validateTargetAgainstState?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const conditionStringWildcard = "*";
|
|
41
|
+
|
|
42
|
+
function pushIssue(state: ValidationState, path: (string | number)[], message: string) {
|
|
43
|
+
state.issues.push({
|
|
44
|
+
code: "custom",
|
|
45
|
+
path,
|
|
46
|
+
message,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseDottedReference(ref: string) {
|
|
51
|
+
const parts = ref.split(".");
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
origin: parts[0],
|
|
55
|
+
name: parts[1],
|
|
56
|
+
path: parts.slice(2),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isObjectValue(value: Value | undefined): value is Record<string, Value> {
|
|
61
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getSchemaAtPath(
|
|
65
|
+
schema: JSONSchema | undefined,
|
|
66
|
+
pathParts: string[],
|
|
67
|
+
): JSONSchema | undefined {
|
|
68
|
+
let current = schema;
|
|
69
|
+
|
|
70
|
+
for (const part of pathParts) {
|
|
71
|
+
if (!current) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (current.type === "array" && current.items && !Array.isArray(current.items)) {
|
|
76
|
+
current = current.items;
|
|
77
|
+
if (part === "") {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (/^\d+$/.test(part)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (current.properties && current.properties[part]) {
|
|
86
|
+
current = current.properties[part];
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (current.type === "array" && current.items && Array.isArray(current.items)) {
|
|
91
|
+
const index = Number(part);
|
|
92
|
+
if (!Number.isInteger(index)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
current = current.items[index];
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function valueHasPath(value: Value | undefined, pathParts: string[]): boolean {
|
|
106
|
+
let current = value;
|
|
107
|
+
|
|
108
|
+
for (const part of pathParts) {
|
|
109
|
+
if (Array.isArray(current) && /^\d+$/.test(part)) {
|
|
110
|
+
current = current[Number(part)];
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isObjectValue(current) && Object.prototype.hasOwnProperty.call(current, part)) {
|
|
115
|
+
current = current[part];
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validatePayloadPath(
|
|
126
|
+
state: ValidationState,
|
|
127
|
+
payloadSchemas: JSONSchema[] | undefined,
|
|
128
|
+
pathParts: string[],
|
|
129
|
+
issuePath: (string | number)[],
|
|
130
|
+
label: string,
|
|
131
|
+
) {
|
|
132
|
+
if (!payloadSchemas || payloadSchemas.length === 0 || pathParts.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const missingFor = payloadSchemas.filter((schema) => !getSchemaAtPath(schema, pathParts));
|
|
137
|
+
|
|
138
|
+
if (missingFor.length > 0) {
|
|
139
|
+
pushIssue(
|
|
140
|
+
state,
|
|
141
|
+
issuePath,
|
|
142
|
+
`${label} references payload path "${pathParts.join(".")}" that is not defined in the referenced schema`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateTargetPath(
|
|
148
|
+
state: ValidationState,
|
|
149
|
+
target: string,
|
|
150
|
+
options: SourceValidationOptions,
|
|
151
|
+
issuePath: (string | number)[],
|
|
152
|
+
) {
|
|
153
|
+
const targetPath = target.split(".").filter(Boolean);
|
|
154
|
+
|
|
155
|
+
if (targetPath.length === 0) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (options.validateTargetAgainstPayload) {
|
|
160
|
+
// Eventvisor intentionally allows introducing new top-level fields during transforms,
|
|
161
|
+
// but nested writes should still honor the declared object structure.
|
|
162
|
+
if (targetPath.length > 1) {
|
|
163
|
+
validatePayloadPath(
|
|
164
|
+
state,
|
|
165
|
+
options.payloadSchemas,
|
|
166
|
+
targetPath,
|
|
167
|
+
issuePath,
|
|
168
|
+
`Transform target "${target}"`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
options.validateTargetAgainstState &&
|
|
175
|
+
options.stateValue !== undefined &&
|
|
176
|
+
!valueHasPath(options.stateValue, targetPath)
|
|
177
|
+
) {
|
|
178
|
+
pushIssue(state, issuePath, `Transform target "${target}" is not declared in the effect state`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateLookupKey(
|
|
183
|
+
state: ValidationState,
|
|
184
|
+
lookupKey: string,
|
|
185
|
+
issuePath: (string | number)[],
|
|
186
|
+
) {
|
|
187
|
+
const parts = lookupKey.split(".");
|
|
188
|
+
|
|
189
|
+
if (parts.length < 2 || !parts[0] || parts.slice(1).join(".").length === 0) {
|
|
190
|
+
pushIssue(
|
|
191
|
+
state,
|
|
192
|
+
issuePath,
|
|
193
|
+
`Lookup reference "${lookupKey}" must include both module and key, for example "browser.screen.width"`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateEntityReference(
|
|
199
|
+
state: ValidationState,
|
|
200
|
+
entityType: "attribute" | "event" | "destination" | "effect",
|
|
201
|
+
entityName: string | undefined,
|
|
202
|
+
issuePath: (string | number)[],
|
|
203
|
+
label: string,
|
|
204
|
+
) {
|
|
205
|
+
if (!entityName) {
|
|
206
|
+
pushIssue(state, issuePath, `${label} must include an entity name`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const collection =
|
|
211
|
+
entityType === "attribute"
|
|
212
|
+
? state.ctx.attributes
|
|
213
|
+
: entityType === "event"
|
|
214
|
+
? state.ctx.events
|
|
215
|
+
: entityType === "destination"
|
|
216
|
+
? state.ctx.destinations
|
|
217
|
+
: state.ctx.effects;
|
|
218
|
+
|
|
219
|
+
if (!collection[entityName]) {
|
|
220
|
+
pushIssue(state, issuePath, `${label} references missing ${entityType} "${entityName}"`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function validateSourceString(
|
|
225
|
+
state: ValidationState,
|
|
226
|
+
source: string,
|
|
227
|
+
options: SourceValidationOptions,
|
|
228
|
+
) {
|
|
229
|
+
const parsed = parseDottedReference(source);
|
|
230
|
+
|
|
231
|
+
if (!options.allowedOrigins.has(parsed.origin)) {
|
|
232
|
+
pushIssue(
|
|
233
|
+
state,
|
|
234
|
+
options.path,
|
|
235
|
+
`Source origin "${parsed.origin}" is not available in this context`,
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsed.origin === "attributes" || parsed.origin === "attribute") {
|
|
241
|
+
if (parsed.origin === "attributes" && !parsed.name) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
validateEntityReference(state, "attribute", parsed.name, options.path, `Source "${source}"`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (parsed.origin === "effects" || parsed.origin === "effect") {
|
|
249
|
+
if (parsed.origin === "effects" && !parsed.name) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
validateEntityReference(state, "effect", parsed.name, options.path, `Source "${source}"`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (parsed.origin === "payload") {
|
|
257
|
+
const payloadPath = parsed.name ? [parsed.name, ...parsed.path] : parsed.path;
|
|
258
|
+
validatePayloadPath(
|
|
259
|
+
state,
|
|
260
|
+
options.payloadSchemas,
|
|
261
|
+
payloadPath,
|
|
262
|
+
options.path,
|
|
263
|
+
`Source "${source}"`,
|
|
264
|
+
);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (parsed.origin === "lookup") {
|
|
269
|
+
validateLookupKey(state, source, options.path);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (parsed.origin === "state" && parsed.name) {
|
|
274
|
+
const statePath = [parsed.name, ...parsed.path];
|
|
275
|
+
if (options.stateValue !== undefined && !valueHasPath(options.stateValue, statePath)) {
|
|
276
|
+
pushIssue(
|
|
277
|
+
state,
|
|
278
|
+
options.path,
|
|
279
|
+
`Source "${source}" references missing state path "${statePath.join(".")}"`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function validateSourceBase(
|
|
286
|
+
state: ValidationState,
|
|
287
|
+
sourceBase: Record<string, any>,
|
|
288
|
+
options: SourceValidationOptions,
|
|
289
|
+
) {
|
|
290
|
+
if (typeof sourceBase.source !== "undefined") {
|
|
291
|
+
if (Array.isArray(sourceBase.source)) {
|
|
292
|
+
pushIssue(
|
|
293
|
+
state,
|
|
294
|
+
[...options.path, "source"],
|
|
295
|
+
`The "source" field does not support arrays; use "payload" for multi-source payload references`,
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
validateSourceString(state, sourceBase.source, {
|
|
299
|
+
...options,
|
|
300
|
+
path: [...options.path, "source"],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (typeof sourceBase.payload !== "undefined") {
|
|
306
|
+
const payloadValue = sourceBase.payload;
|
|
307
|
+
const payloadPaths = Array.isArray(payloadValue) ? payloadValue : [payloadValue];
|
|
308
|
+
|
|
309
|
+
if (Array.isArray(payloadValue) && !options.allowPayloadArrays) {
|
|
310
|
+
pushIssue(
|
|
311
|
+
state,
|
|
312
|
+
[...options.path, "payload"],
|
|
313
|
+
`The "payload" field does not support arrays in this context`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (let index = 0; index < payloadPaths.length; index++) {
|
|
318
|
+
const payloadPath = payloadPaths[index];
|
|
319
|
+
const issuePath = Array.isArray(payloadValue)
|
|
320
|
+
? [...options.path, "payload", index]
|
|
321
|
+
: [...options.path, "payload"];
|
|
322
|
+
|
|
323
|
+
validatePayloadPath(
|
|
324
|
+
state,
|
|
325
|
+
options.payloadSchemas,
|
|
326
|
+
String(payloadPath).split(".").filter(Boolean),
|
|
327
|
+
issuePath,
|
|
328
|
+
`Payload reference "${payloadPath}"`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (typeof sourceBase.lookup !== "undefined") {
|
|
334
|
+
if (Array.isArray(sourceBase.lookup)) {
|
|
335
|
+
pushIssue(state, [...options.path, "lookup"], `The "lookup" field must be a single string`);
|
|
336
|
+
} else {
|
|
337
|
+
validateLookupKey(state, String(sourceBase.lookup), [...options.path, "lookup"]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof sourceBase.attribute !== "undefined") {
|
|
342
|
+
if (Array.isArray(sourceBase.attribute)) {
|
|
343
|
+
pushIssue(
|
|
344
|
+
state,
|
|
345
|
+
[...options.path, "attribute"],
|
|
346
|
+
`The "attribute" field must reference a single attribute`,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
validateEntityReference(
|
|
350
|
+
state,
|
|
351
|
+
"attribute",
|
|
352
|
+
String(sourceBase.attribute).split(".")[0],
|
|
353
|
+
[...options.path, "attribute"],
|
|
354
|
+
`Attribute reference "${sourceBase.attribute}"`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (typeof sourceBase.effect !== "undefined") {
|
|
360
|
+
if (Array.isArray(sourceBase.effect)) {
|
|
361
|
+
pushIssue(
|
|
362
|
+
state,
|
|
363
|
+
[...options.path, "effect"],
|
|
364
|
+
`The "effect" field must reference a single effect`,
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
validateEntityReference(
|
|
368
|
+
state,
|
|
369
|
+
"effect",
|
|
370
|
+
String(sourceBase.effect).split(".")[0],
|
|
371
|
+
[...options.path, "effect"],
|
|
372
|
+
`Effect reference "${sourceBase.effect}"`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (typeof sourceBase.state !== "undefined") {
|
|
378
|
+
if (Array.isArray(sourceBase.state)) {
|
|
379
|
+
pushIssue(state, [...options.path, "state"], `The "state" field must be a single path`);
|
|
380
|
+
} else if (
|
|
381
|
+
options.stateValue !== undefined &&
|
|
382
|
+
!valueHasPath(options.stateValue, String(sourceBase.state).split("."))
|
|
383
|
+
) {
|
|
384
|
+
pushIssue(
|
|
385
|
+
state,
|
|
386
|
+
[...options.path, "state"],
|
|
387
|
+
`State reference "${sourceBase.state}" is not declared in the effect state`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validateCondition(
|
|
394
|
+
state: ValidationState,
|
|
395
|
+
condition: Condition | Condition[],
|
|
396
|
+
path: (string | number)[],
|
|
397
|
+
options: SourceValidationOptions,
|
|
398
|
+
) {
|
|
399
|
+
if (typeof condition === "string") {
|
|
400
|
+
if (condition !== conditionStringWildcard) {
|
|
401
|
+
pushIssue(
|
|
402
|
+
state,
|
|
403
|
+
path,
|
|
404
|
+
`Only "*" is allowed as a string condition; use structured conditions instead`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (Array.isArray(condition)) {
|
|
411
|
+
condition.forEach((item, index) => validateCondition(state, item, [...path, index], options));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if ("and" in condition) {
|
|
416
|
+
condition.and.forEach((item, index) =>
|
|
417
|
+
validateCondition(state, item, [...path, "and", index], options),
|
|
418
|
+
);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if ("or" in condition) {
|
|
423
|
+
condition.or.forEach((item, index) =>
|
|
424
|
+
validateCondition(state, item, [...path, "or", index], options),
|
|
425
|
+
);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if ("not" in condition) {
|
|
430
|
+
condition.not.forEach((item, index) =>
|
|
431
|
+
validateCondition(state, item, [...path, "not", index], options),
|
|
432
|
+
);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
validateSourceBase(state, condition as Record<string, any>, options);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validateSample(
|
|
440
|
+
state: ValidationState,
|
|
441
|
+
sample: Sample | Sample[],
|
|
442
|
+
path: (string | number)[],
|
|
443
|
+
options: SourceValidationOptions,
|
|
444
|
+
) {
|
|
445
|
+
const samples = Array.isArray(sample) ? sample : [sample];
|
|
446
|
+
|
|
447
|
+
samples.forEach((singleSample, index) => {
|
|
448
|
+
const samplePath = Array.isArray(sample) ? [...path, index] : path;
|
|
449
|
+
|
|
450
|
+
if (typeof singleSample.by === "string") {
|
|
451
|
+
validateSourceString(state, singleSample.by, { ...options, path: [...samplePath, "by"] });
|
|
452
|
+
} else if (Array.isArray(singleSample.by)) {
|
|
453
|
+
singleSample.by.forEach((byEntry, byIndex) => {
|
|
454
|
+
if (typeof byEntry === "string") {
|
|
455
|
+
validateSourceString(state, byEntry, {
|
|
456
|
+
...options,
|
|
457
|
+
path: [...samplePath, "by", byIndex],
|
|
458
|
+
});
|
|
459
|
+
} else {
|
|
460
|
+
validateSourceBase(state, byEntry, {
|
|
461
|
+
...options,
|
|
462
|
+
path: [...samplePath, "by", byIndex],
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
} else if ("or" in singleSample.by) {
|
|
467
|
+
singleSample.by.or.forEach((byEntry, byIndex) => {
|
|
468
|
+
if (typeof byEntry === "string") {
|
|
469
|
+
validateSourceString(state, byEntry, {
|
|
470
|
+
...options,
|
|
471
|
+
path: [...samplePath, "by", "or", byIndex],
|
|
472
|
+
});
|
|
473
|
+
} else {
|
|
474
|
+
validateSourceBase(state, byEntry, {
|
|
475
|
+
...options,
|
|
476
|
+
path: [...samplePath, "by", "or", byIndex],
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
} else {
|
|
481
|
+
validateSourceBase(state, singleSample.by, { ...options, path: [...samplePath, "by"] });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (singleSample.conditions) {
|
|
485
|
+
validateCondition(state, singleSample.conditions, [...samplePath, "conditions"], options);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function validateTransform(
|
|
491
|
+
state: ValidationState,
|
|
492
|
+
transform: Transform,
|
|
493
|
+
path: (string | number)[],
|
|
494
|
+
options: SourceValidationOptions,
|
|
495
|
+
) {
|
|
496
|
+
validateSourceBase(state, transform as Record<string, any>, {
|
|
497
|
+
...options,
|
|
498
|
+
path,
|
|
499
|
+
allowPayloadArrays: transform.type === "concat",
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (
|
|
503
|
+
["rename"].includes(transform.type) &&
|
|
504
|
+
(!transform.targetMap ||
|
|
505
|
+
(Array.isArray(transform.targetMap) && transform.targetMap.length === 0) ||
|
|
506
|
+
(!Array.isArray(transform.targetMap) && Object.keys(transform.targetMap).length === 0))
|
|
507
|
+
) {
|
|
508
|
+
pushIssue(
|
|
509
|
+
state,
|
|
510
|
+
[...path, "targetMap"],
|
|
511
|
+
`Transform "${transform.type}" requires a non-empty targetMap`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (
|
|
516
|
+
["remove", "trim", "toInteger", "toDouble", "toString", "toBoolean"].includes(transform.type)
|
|
517
|
+
) {
|
|
518
|
+
if (!transform.target) {
|
|
519
|
+
pushIssue(state, [...path, "target"], `Transform "${transform.type}" requires a target`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (transform.type === "set" && !transform.target && typeof transform.value === "undefined") {
|
|
524
|
+
pushIssue(state, path, `Transform "set" must define either a target or a value`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (transform.type === "rename" && transform.target) {
|
|
528
|
+
pushIssue(
|
|
529
|
+
state,
|
|
530
|
+
[...path, "target"],
|
|
531
|
+
`Transform "rename" must use targetMap instead of target`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (transform.target) {
|
|
536
|
+
const targets = Array.isArray(transform.target) ? transform.target : [transform.target];
|
|
537
|
+
targets.forEach((target, index) => {
|
|
538
|
+
validateTargetPath(
|
|
539
|
+
state,
|
|
540
|
+
String(target),
|
|
541
|
+
options,
|
|
542
|
+
Array.isArray(transform.target) ? [...path, "target", index] : [...path, "target"],
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (
|
|
548
|
+
transform.targetMap &&
|
|
549
|
+
(options.validateTargetAgainstPayload || options.validateTargetAgainstState)
|
|
550
|
+
) {
|
|
551
|
+
const targetMaps = Array.isArray(transform.targetMap)
|
|
552
|
+
? transform.targetMap
|
|
553
|
+
: [transform.targetMap];
|
|
554
|
+
targetMaps.forEach((targetMap, mapIndex) => {
|
|
555
|
+
Object.values(targetMap).forEach((targetPath) => {
|
|
556
|
+
validateTargetPath(
|
|
557
|
+
state,
|
|
558
|
+
String(targetPath),
|
|
559
|
+
options,
|
|
560
|
+
Array.isArray(transform.targetMap)
|
|
561
|
+
? [...path, "targetMap", mapIndex]
|
|
562
|
+
: [...path, "targetMap"],
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (transform.targetMap && options.payloadSchemas && options.payloadSchemas.length > 0) {
|
|
569
|
+
const targetMaps = Array.isArray(transform.targetMap)
|
|
570
|
+
? transform.targetMap
|
|
571
|
+
: [transform.targetMap];
|
|
572
|
+
targetMaps.forEach((targetMap, mapIndex) => {
|
|
573
|
+
Object.keys(targetMap).forEach((sourceKey) => {
|
|
574
|
+
validatePayloadPath(
|
|
575
|
+
state,
|
|
576
|
+
options.payloadSchemas,
|
|
577
|
+
sourceKey.split("."),
|
|
578
|
+
Array.isArray(transform.targetMap)
|
|
579
|
+
? [...path, "targetMap", mapIndex, sourceKey]
|
|
580
|
+
: [...path, "targetMap", sourceKey],
|
|
581
|
+
`Transform source "${sourceKey}"`,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (transform.conditions) {
|
|
588
|
+
validateCondition(state, transform.conditions, [...path, "conditions"], options);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function validatePersist(
|
|
593
|
+
state: ValidationState,
|
|
594
|
+
persist: Attribute["persist"] | Effect["persist"],
|
|
595
|
+
path: (string | number)[],
|
|
596
|
+
options: SourceValidationOptions,
|
|
597
|
+
) {
|
|
598
|
+
if (!persist || typeof persist === "string") {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (Array.isArray(persist)) {
|
|
603
|
+
persist.forEach((item, index) => validatePersist(state, item, [...path, index], options));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (persist.conditions) {
|
|
608
|
+
validateCondition(state, persist.conditions, [...path, "conditions"], options);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function validateAttributeSemantics(state: ValidationState, attribute: Attribute) {
|
|
613
|
+
const options: SourceValidationOptions = {
|
|
614
|
+
path: [],
|
|
615
|
+
allowedOrigins: new Set([
|
|
616
|
+
"attributes",
|
|
617
|
+
"attribute",
|
|
618
|
+
"effects",
|
|
619
|
+
"effect",
|
|
620
|
+
"payload",
|
|
621
|
+
"lookup",
|
|
622
|
+
"attributeName",
|
|
623
|
+
]),
|
|
624
|
+
payloadSchemas: [attribute],
|
|
625
|
+
validateTargetAgainstPayload: true,
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
attribute.transforms?.forEach((transform, index) =>
|
|
629
|
+
validateTransform(state, transform, ["transforms", index], options),
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
if (attribute.persist) {
|
|
633
|
+
validatePersist(state, attribute.persist, ["persist"], options);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function validateEventSemantics(state: ValidationState, event: Event) {
|
|
638
|
+
const options: SourceValidationOptions = {
|
|
639
|
+
path: [],
|
|
640
|
+
allowedOrigins: new Set([
|
|
641
|
+
"attributes",
|
|
642
|
+
"attribute",
|
|
643
|
+
"effects",
|
|
644
|
+
"effect",
|
|
645
|
+
"payload",
|
|
646
|
+
"lookup",
|
|
647
|
+
"eventName",
|
|
648
|
+
"eventLevel",
|
|
649
|
+
]),
|
|
650
|
+
payloadSchemas: [event],
|
|
651
|
+
validateTargetAgainstPayload: true,
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
event.requiredAttributes?.forEach((attributeName, index) => {
|
|
655
|
+
if (!state.ctx.attributes[attributeName]) {
|
|
656
|
+
pushIssue(
|
|
657
|
+
state,
|
|
658
|
+
["requiredAttributes", index],
|
|
659
|
+
`requiredAttributes references missing attribute "${attributeName}"`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
if (event.conditions) {
|
|
665
|
+
validateCondition(state, event.conditions, ["conditions"], options);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (event.sample) {
|
|
669
|
+
validateSample(state, event.sample, ["sample"], options);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
event.transforms?.forEach((transform, index) =>
|
|
673
|
+
validateTransform(state, transform, ["transforms", index], options),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
if (
|
|
677
|
+
event.skipValidation &&
|
|
678
|
+
typeof event.skipValidation === "object" &&
|
|
679
|
+
event.skipValidation.conditions
|
|
680
|
+
) {
|
|
681
|
+
validateCondition(
|
|
682
|
+
state,
|
|
683
|
+
event.skipValidation.conditions,
|
|
684
|
+
["skipValidation", "conditions"],
|
|
685
|
+
options,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (event.destinations) {
|
|
690
|
+
Object.entries(event.destinations).forEach(([destinationName, destinationOverride]) => {
|
|
691
|
+
if (!state.ctx.destinations[destinationName]) {
|
|
692
|
+
pushIssue(
|
|
693
|
+
state,
|
|
694
|
+
["destinations", destinationName],
|
|
695
|
+
`destinations references missing destination "${destinationName}"`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (destinationOverride && typeof destinationOverride === "object") {
|
|
700
|
+
if (destinationOverride.conditions) {
|
|
701
|
+
validateCondition(
|
|
702
|
+
state,
|
|
703
|
+
destinationOverride.conditions,
|
|
704
|
+
["destinations", destinationName, "conditions"],
|
|
705
|
+
options,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
if (destinationOverride.sample) {
|
|
709
|
+
validateSample(
|
|
710
|
+
state,
|
|
711
|
+
destinationOverride.sample,
|
|
712
|
+
["destinations", destinationName, "sample"],
|
|
713
|
+
options,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
destinationOverride.transforms?.forEach((transform, index) =>
|
|
717
|
+
validateTransform(
|
|
718
|
+
state,
|
|
719
|
+
transform,
|
|
720
|
+
["destinations", destinationName, "transforms", index],
|
|
721
|
+
options,
|
|
722
|
+
),
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function getEffectPayloadSchemas(state: ValidationState, effect: Effect): JSONSchema[] | undefined {
|
|
730
|
+
if (!effect.on || Array.isArray(effect.on)) {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const payloadSchemas: JSONSchema[] = [];
|
|
735
|
+
|
|
736
|
+
effect.on.event_tracked?.forEach((eventName) => {
|
|
737
|
+
const event = state.ctx.events[eventName];
|
|
738
|
+
if (event) {
|
|
739
|
+
payloadSchemas.push(event);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
effect.on.attribute_set?.forEach((attributeName) => {
|
|
744
|
+
const attribute = state.ctx.attributes[attributeName];
|
|
745
|
+
if (attribute) {
|
|
746
|
+
payloadSchemas.push(attribute);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
return payloadSchemas.length > 0 ? payloadSchemas : undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function validateEffectSemantics(state: ValidationState, effect: Effect) {
|
|
754
|
+
if (!Array.isArray(effect.on)) {
|
|
755
|
+
effect.on.event_tracked?.forEach((eventName, index) => {
|
|
756
|
+
if (!state.ctx.events[eventName]) {
|
|
757
|
+
pushIssue(
|
|
758
|
+
state,
|
|
759
|
+
["on", "event_tracked", index],
|
|
760
|
+
`Effect trigger references missing event "${eventName}"`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
effect.on.attribute_set?.forEach((attributeName, index) => {
|
|
766
|
+
if (!state.ctx.attributes[attributeName]) {
|
|
767
|
+
pushIssue(
|
|
768
|
+
state,
|
|
769
|
+
["on", "attribute_set", index],
|
|
770
|
+
`Effect trigger references missing attribute "${attributeName}"`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const conditionOptions: SourceValidationOptions = {
|
|
777
|
+
path: [],
|
|
778
|
+
allowedOrigins: new Set([
|
|
779
|
+
"attributes",
|
|
780
|
+
"attribute",
|
|
781
|
+
"effects",
|
|
782
|
+
"effect",
|
|
783
|
+
"payload",
|
|
784
|
+
"lookup",
|
|
785
|
+
"eventName",
|
|
786
|
+
"attributeName",
|
|
787
|
+
"state",
|
|
788
|
+
]),
|
|
789
|
+
payloadSchemas: getEffectPayloadSchemas(state, effect),
|
|
790
|
+
stateValue: effect.state,
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
if (effect.conditions) {
|
|
794
|
+
validateCondition(state, effect.conditions, ["conditions"], conditionOptions);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
effect.steps?.forEach((step, stepIndex) => {
|
|
798
|
+
if (step.conditions) {
|
|
799
|
+
validateCondition(
|
|
800
|
+
state,
|
|
801
|
+
step.conditions,
|
|
802
|
+
["steps", stepIndex, "conditions"],
|
|
803
|
+
conditionOptions,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
step.transforms?.forEach((transform, transformIndex) =>
|
|
808
|
+
validateTransform(state, transform, ["steps", stepIndex, "transforms", transformIndex], {
|
|
809
|
+
path: [],
|
|
810
|
+
allowedOrigins: new Set([
|
|
811
|
+
"attributes",
|
|
812
|
+
"attribute",
|
|
813
|
+
"effects",
|
|
814
|
+
"effect",
|
|
815
|
+
"lookup",
|
|
816
|
+
"eventName",
|
|
817
|
+
"attributeName",
|
|
818
|
+
"state",
|
|
819
|
+
]),
|
|
820
|
+
stateValue: effect.state,
|
|
821
|
+
validateTargetAgainstState: true,
|
|
822
|
+
}),
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
if (effect.persist) {
|
|
827
|
+
validatePersist(state, effect.persist, ["persist"], conditionOptions);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function validateDestinationSemantics(state: ValidationState, destination: Destination) {
|
|
832
|
+
const conditionOptions: SourceValidationOptions = {
|
|
833
|
+
path: [],
|
|
834
|
+
allowedOrigins: new Set([
|
|
835
|
+
"attributes",
|
|
836
|
+
"attribute",
|
|
837
|
+
"effects",
|
|
838
|
+
"effect",
|
|
839
|
+
"payload",
|
|
840
|
+
"lookup",
|
|
841
|
+
"eventName",
|
|
842
|
+
"eventLevel",
|
|
843
|
+
]),
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const transformOptions: SourceValidationOptions = {
|
|
847
|
+
path: [],
|
|
848
|
+
allowedOrigins: new Set([
|
|
849
|
+
"attributes",
|
|
850
|
+
"attribute",
|
|
851
|
+
"effects",
|
|
852
|
+
"effect",
|
|
853
|
+
"payload",
|
|
854
|
+
"lookup",
|
|
855
|
+
"eventName",
|
|
856
|
+
"eventLevel",
|
|
857
|
+
"destinationName",
|
|
858
|
+
]),
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
if (destination.conditions) {
|
|
862
|
+
validateCondition(state, destination.conditions, ["conditions"], conditionOptions);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (destination.sample) {
|
|
866
|
+
validateSample(state, destination.sample, ["sample"], conditionOptions);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
destination.transforms?.forEach((transform, index) =>
|
|
870
|
+
validateTransform(state, transform, ["transforms", index], transformOptions),
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function validateTestSemantics(state: ValidationState, test: Test) {
|
|
875
|
+
if ("attribute" in test && !state.ctx.attributes[test.attribute]) {
|
|
876
|
+
pushIssue(state, ["attribute"], `Test references missing attribute "${test.attribute}"`);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if ("event" in test) {
|
|
880
|
+
if (!state.ctx.events[test.event]) {
|
|
881
|
+
pushIssue(state, ["event"], `Test references missing event "${test.event}"`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
test.assertions.forEach((assertion, index) => {
|
|
885
|
+
assertion.expectedDestinations?.forEach((destinationName, destinationIndex) => {
|
|
886
|
+
const destinationExists =
|
|
887
|
+
!!state.ctx.destinations[destinationName] ||
|
|
888
|
+
Object.values(state.ctx.destinations).some(
|
|
889
|
+
(destination) => destination.transport === destinationName,
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
if (!destinationExists) {
|
|
893
|
+
pushIssue(
|
|
894
|
+
state,
|
|
895
|
+
["assertions", index, "expectedDestinations", destinationIndex],
|
|
896
|
+
`expectedDestinations references missing destination "${destinationName}"`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
assertion.actions?.forEach((action, actionIndex) => {
|
|
902
|
+
if (action.type === "track" && !state.ctx.events[action.name]) {
|
|
903
|
+
pushIssue(
|
|
904
|
+
state,
|
|
905
|
+
["assertions", index, "actions", actionIndex, "name"],
|
|
906
|
+
`Action references missing event "${action.name}"`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (action.type === "setAttribute" && !state.ctx.attributes[action.name]) {
|
|
911
|
+
pushIssue(
|
|
912
|
+
state,
|
|
913
|
+
["assertions", index, "actions", actionIndex, "name"],
|
|
914
|
+
`Action references missing attribute "${action.name}"`,
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if ("effect" in test && !state.ctx.effects[test.effect]) {
|
|
922
|
+
pushIssue(state, ["effect"], `Test references missing effect "${test.effect}"`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if ("destination" in test && !state.ctx.destinations[test.destination]) {
|
|
926
|
+
pushIssue(state, ["destination"], `Test references missing destination "${test.destination}"`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export function getSemanticIssues(
|
|
931
|
+
entityType: EntityType,
|
|
932
|
+
entity: Attribute | Event | Destination | Effect | Test,
|
|
933
|
+
ctx: LintContext,
|
|
934
|
+
): z.ZodIssue[] {
|
|
935
|
+
const state: ValidationState = {
|
|
936
|
+
issues: [],
|
|
937
|
+
ctx,
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
if (entityType === "attribute") {
|
|
941
|
+
validateAttributeSemantics(state, entity as Attribute);
|
|
942
|
+
} else if (entityType === "event") {
|
|
943
|
+
validateEventSemantics(state, entity as Event);
|
|
944
|
+
} else if (entityType === "destination") {
|
|
945
|
+
validateDestinationSemantics(state, entity as Destination);
|
|
946
|
+
} else if (entityType === "effect") {
|
|
947
|
+
validateEffectSemantics(state, entity as Effect);
|
|
948
|
+
} else if (entityType === "test") {
|
|
949
|
+
validateTestSemantics(state, entity as Test);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return state.issues;
|
|
953
|
+
}
|