@memberjunction/metadata-sync 2.52.0 → 2.54.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/README.md +216 -1
- package/dist/commands/push/index.d.ts +4 -0
- package/dist/commands/push/index.js +100 -10
- package/dist/commands/push/index.js.map +1 -1
- package/dist/config.d.ts +23 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/provider-utils.d.ts +4 -2
- package/dist/lib/provider-utils.js +26 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/sync-engine.d.ts +32 -2
- package/dist/lib/sync-engine.js +121 -20
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/ValidationService.d.ts +22 -0
- package/dist/services/ValidationService.js +348 -70
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/types/validation.d.ts +6 -2
- package/dist/types/validation.js.map +1 -1
- package/oclif.manifest.json +25 -25
- package/package.json +7 -7
|
@@ -27,6 +27,7 @@ exports.ValidationService = void 0;
|
|
|
27
27
|
const core_1 = require("@memberjunction/core");
|
|
28
28
|
const fs = __importStar(require("fs"));
|
|
29
29
|
const path = __importStar(require("path"));
|
|
30
|
+
const provider_utils_1 = require("../lib/provider-utils");
|
|
30
31
|
class ValidationService {
|
|
31
32
|
metadata;
|
|
32
33
|
errors = [];
|
|
@@ -34,6 +35,7 @@ class ValidationService {
|
|
|
34
35
|
entityDependencies = new Map();
|
|
35
36
|
processedEntities = new Set();
|
|
36
37
|
options;
|
|
38
|
+
userRoleCache = new Map();
|
|
37
39
|
constructor(options = {}) {
|
|
38
40
|
this.metadata = new core_1.Metadata();
|
|
39
41
|
this.options = {
|
|
@@ -41,7 +43,7 @@ class ValidationService {
|
|
|
41
43
|
outputFormat: 'human',
|
|
42
44
|
maxNestingDepth: 10,
|
|
43
45
|
checkBestPractices: true,
|
|
44
|
-
...options
|
|
46
|
+
...options,
|
|
45
47
|
};
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
@@ -55,11 +57,15 @@ class ValidationService {
|
|
|
55
57
|
type: 'entity',
|
|
56
58
|
severity: 'error',
|
|
57
59
|
file: dir,
|
|
58
|
-
message: 'No .mj-sync.json configuration file found in directory'
|
|
60
|
+
message: 'No .mj-sync.json configuration file found in directory',
|
|
59
61
|
});
|
|
60
62
|
return this.getResult();
|
|
61
63
|
}
|
|
62
64
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
65
|
+
// Load user role configuration and cache if enabled
|
|
66
|
+
if (config.userRoleValidation?.enabled) {
|
|
67
|
+
await this.loadUserRoles();
|
|
68
|
+
}
|
|
63
69
|
const directories = await this.getDirectoriesInOrder(dir, config);
|
|
64
70
|
let totalFiles = 0;
|
|
65
71
|
let totalEntities = 0;
|
|
@@ -86,26 +92,56 @@ class ValidationService {
|
|
|
86
92
|
totalEntities,
|
|
87
93
|
totalErrors: this.errors.length,
|
|
88
94
|
totalWarnings: this.warnings.length,
|
|
89
|
-
fileResults
|
|
90
|
-
}
|
|
95
|
+
fileResults,
|
|
96
|
+
},
|
|
91
97
|
};
|
|
92
98
|
}
|
|
93
99
|
/**
|
|
94
100
|
* Validates a single entity directory
|
|
95
101
|
*/
|
|
96
102
|
async validateEntityDirectory(dir) {
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
// Check for .mj-folder.json first (new format)
|
|
104
|
+
let configPath = path.join(dir, '.mj-folder.json');
|
|
105
|
+
let config;
|
|
106
|
+
if (fs.existsSync(configPath)) {
|
|
107
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
108
|
+
// .mj-folder.json uses entityName field
|
|
109
|
+
if (!config.entityName) {
|
|
110
|
+
this.addError({
|
|
111
|
+
type: 'validation',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
file: configPath,
|
|
114
|
+
message: 'Missing entityName field in .mj-folder.json',
|
|
115
|
+
});
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
config.entity = config.entityName; // Normalize to entity field
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Fall back to .mj-sync.json (legacy format)
|
|
122
|
+
configPath = path.join(dir, '.mj-sync.json');
|
|
123
|
+
if (!fs.existsSync(configPath)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
127
|
+
}
|
|
128
|
+
// Validate entity name exists
|
|
129
|
+
if (!config.entity || config.entity.trim() === '') {
|
|
130
|
+
this.addError({
|
|
131
|
+
type: 'validation',
|
|
132
|
+
severity: 'error',
|
|
133
|
+
file: configPath,
|
|
134
|
+
message: 'Entity name is empty or missing',
|
|
135
|
+
});
|
|
99
136
|
return null;
|
|
100
137
|
}
|
|
101
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
102
138
|
const entityInfo = this.metadata.EntityByName(config.entity);
|
|
103
139
|
if (!entityInfo) {
|
|
104
140
|
this.addError({
|
|
105
141
|
type: 'entity',
|
|
106
142
|
severity: 'error',
|
|
107
143
|
file: configPath,
|
|
108
|
-
message: `Entity "${config.entity}" not found in metadata
|
|
144
|
+
message: `Entity "${config.entity}" not found in metadata`,
|
|
109
145
|
});
|
|
110
146
|
return null;
|
|
111
147
|
}
|
|
@@ -125,7 +161,6 @@ class ValidationService {
|
|
|
125
161
|
*/
|
|
126
162
|
async validateFile(filePath, entityInfo, config) {
|
|
127
163
|
const fileErrors = [];
|
|
128
|
-
const fileWarnings = [];
|
|
129
164
|
let entityCount = 0;
|
|
130
165
|
try {
|
|
131
166
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -141,17 +176,17 @@ class ValidationService {
|
|
|
141
176
|
type: 'entity',
|
|
142
177
|
severity: 'error',
|
|
143
178
|
file: filePath,
|
|
144
|
-
message: `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}
|
|
179
|
+
message: `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
145
180
|
});
|
|
146
181
|
}
|
|
147
182
|
// Collect errors and warnings for this file
|
|
148
|
-
const currentFileErrors = this.errors.filter(e => e.file === filePath);
|
|
149
|
-
const currentFileWarnings = this.warnings.filter(w => w.file === filePath);
|
|
183
|
+
const currentFileErrors = this.errors.filter((e) => e.file === filePath);
|
|
184
|
+
const currentFileWarnings = this.warnings.filter((w) => w.file === filePath);
|
|
150
185
|
return {
|
|
151
186
|
file: filePath,
|
|
152
187
|
entityCount,
|
|
153
188
|
errors: currentFileErrors,
|
|
154
|
-
warnings: currentFileWarnings
|
|
189
|
+
warnings: currentFileWarnings,
|
|
155
190
|
};
|
|
156
191
|
}
|
|
157
192
|
/**
|
|
@@ -166,7 +201,7 @@ class ValidationService {
|
|
|
166
201
|
entity: entityInfo.Name,
|
|
167
202
|
file: filePath,
|
|
168
203
|
message: `Nesting depth ${depth} exceeds recommended maximum of ${this.options.maxNestingDepth}`,
|
|
169
|
-
suggestion: 'Consider flattening the data structure or increasing maxNestingDepth'
|
|
204
|
+
suggestion: 'Consider flattening the data structure or increasing maxNestingDepth',
|
|
170
205
|
});
|
|
171
206
|
}
|
|
172
207
|
// Validate fields
|
|
@@ -185,7 +220,7 @@ class ValidationService {
|
|
|
185
220
|
severity: 'error',
|
|
186
221
|
entity: entityInfo.Name,
|
|
187
222
|
file: filePath,
|
|
188
|
-
message: `Related entity "${relatedEntityName}" not found in metadata
|
|
223
|
+
message: `Related entity "${relatedEntityName}" not found in metadata`,
|
|
189
224
|
});
|
|
190
225
|
continue;
|
|
191
226
|
}
|
|
@@ -216,7 +251,7 @@ class ValidationService {
|
|
|
216
251
|
entity: entityInfo.Name,
|
|
217
252
|
field: fieldName,
|
|
218
253
|
file: filePath,
|
|
219
|
-
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"
|
|
254
|
+
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"`,
|
|
220
255
|
});
|
|
221
256
|
continue;
|
|
222
257
|
}
|
|
@@ -232,7 +267,7 @@ class ValidationService {
|
|
|
232
267
|
entity: entityInfo.Name,
|
|
233
268
|
field: fieldName,
|
|
234
269
|
file: filePath,
|
|
235
|
-
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"
|
|
270
|
+
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"`,
|
|
236
271
|
});
|
|
237
272
|
continue;
|
|
238
273
|
}
|
|
@@ -246,7 +281,7 @@ class ValidationService {
|
|
|
246
281
|
field: fieldName,
|
|
247
282
|
file: filePath,
|
|
248
283
|
message: `Field "${fieldName}" is a system field and cannot be set`,
|
|
249
|
-
suggestion: 'Remove this field from your metadata file'
|
|
284
|
+
suggestion: 'Remove this field from your metadata file',
|
|
250
285
|
});
|
|
251
286
|
continue;
|
|
252
287
|
}
|
|
@@ -294,7 +329,7 @@ class ValidationService {
|
|
|
294
329
|
field: field.Name,
|
|
295
330
|
file: filePath,
|
|
296
331
|
message: `Required field "${field.Name}" is missing`,
|
|
297
|
-
suggestion: `Add "${field.Name}" to the fields object
|
|
332
|
+
suggestion: `Add "${field.Name}" to the fields object`,
|
|
298
333
|
});
|
|
299
334
|
}
|
|
300
335
|
}
|
|
@@ -306,7 +341,34 @@ class ValidationService {
|
|
|
306
341
|
if (typeof value === 'string' && value.startsWith('@')) {
|
|
307
342
|
await this.validateReference(value, fieldInfo, entityInfo, filePath, parentContext);
|
|
308
343
|
}
|
|
309
|
-
//
|
|
344
|
+
// Validate UserID fields against allowed roles
|
|
345
|
+
if (fieldInfo.Name === 'UserID' && typeof value === 'string' && value.length > 0) {
|
|
346
|
+
// Get the sync config from the file's directory
|
|
347
|
+
const dir = path.dirname(filePath);
|
|
348
|
+
// Walk up to find the root sync config with userRoleValidation
|
|
349
|
+
let currentDir = dir;
|
|
350
|
+
let config = null;
|
|
351
|
+
while (currentDir && currentDir !== path.parse(currentDir).root) {
|
|
352
|
+
const currentConfigPath = path.join(currentDir, '.mj-sync.json');
|
|
353
|
+
if (fs.existsSync(currentConfigPath)) {
|
|
354
|
+
try {
|
|
355
|
+
const currentConfig = JSON.parse(fs.readFileSync(currentConfigPath, 'utf8'));
|
|
356
|
+
if (currentConfig.userRoleValidation) {
|
|
357
|
+
config = currentConfig;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// Ignore parse errors
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
currentDir = path.dirname(currentDir);
|
|
366
|
+
}
|
|
367
|
+
if (config?.userRoleValidation?.enabled) {
|
|
368
|
+
await this.validateUserRole(value, entityInfo.Name, fieldInfo.Name, filePath, config);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Add other type validation here if needed
|
|
310
372
|
}
|
|
311
373
|
/**
|
|
312
374
|
* Validates special references (@file:, @lookup:, etc.)
|
|
@@ -320,7 +382,7 @@ class ValidationService {
|
|
|
320
382
|
entity: entityInfo.Name,
|
|
321
383
|
field: fieldInfo.Name,
|
|
322
384
|
file: filePath,
|
|
323
|
-
message: `Invalid reference format: "${reference}"
|
|
385
|
+
message: `Invalid reference format: "${reference}"`,
|
|
324
386
|
});
|
|
325
387
|
return;
|
|
326
388
|
}
|
|
@@ -348,32 +410,53 @@ class ValidationService {
|
|
|
348
410
|
parseReference(reference) {
|
|
349
411
|
const patterns = [
|
|
350
412
|
['@file:', /^@file:(.+)$/],
|
|
351
|
-
['@lookup:', /^@lookup:([^.]+)\.(
|
|
413
|
+
['@lookup:', /^@lookup:([^.]+)\.(.+)$/],
|
|
352
414
|
['@template:', /^@template:(.+)$/],
|
|
353
415
|
['@parent:', /^@parent:(.+)$/],
|
|
354
416
|
['@root:', /^@root:(.+)$/],
|
|
355
|
-
['@env:', /^@env:(.+)$/]
|
|
417
|
+
['@env:', /^@env:(.+)$/],
|
|
356
418
|
];
|
|
357
419
|
for (const [type, pattern] of patterns) {
|
|
358
420
|
const match = reference.match(pattern);
|
|
359
421
|
if (match) {
|
|
360
422
|
if (type === '@lookup:') {
|
|
361
|
-
const [, entity,
|
|
362
|
-
|
|
363
|
-
const
|
|
423
|
+
const [, entity, remaining] = match;
|
|
424
|
+
// Check if this has ?create syntax
|
|
425
|
+
const hasCreate = remaining.includes('?create');
|
|
426
|
+
const lookupPart = hasCreate ? remaining.split('?')[0] : remaining;
|
|
427
|
+
// Parse all lookup fields (can be multiple with &)
|
|
428
|
+
const lookupPairs = lookupPart.split('&');
|
|
429
|
+
const fields = [];
|
|
430
|
+
for (const pair of lookupPairs) {
|
|
431
|
+
const fieldMatch = pair.match(/^(.+?)=(.+)$/);
|
|
432
|
+
if (fieldMatch) {
|
|
433
|
+
const [, field, value] = fieldMatch;
|
|
434
|
+
fields.push({ field: field.trim(), value: value.trim() });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// For backward compatibility, use the first field as primary
|
|
438
|
+
const primaryField = fields.length > 0 ? fields[0] : { field: '', value: '' };
|
|
439
|
+
// Parse additional fields for creation if ?create is present
|
|
364
440
|
const additionalFields = {};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
441
|
+
if (hasCreate && remaining.includes('?create&')) {
|
|
442
|
+
const createPart = remaining.split('?create&')[1];
|
|
443
|
+
const pairs = createPart.split('&');
|
|
444
|
+
for (const pair of pairs) {
|
|
445
|
+
const [key, val] = pair.split('=');
|
|
446
|
+
if (key && val) {
|
|
447
|
+
additionalFields[key] = decodeURIComponent(val);
|
|
373
448
|
}
|
|
374
449
|
}
|
|
375
450
|
}
|
|
376
|
-
return {
|
|
451
|
+
return {
|
|
452
|
+
type,
|
|
453
|
+
value: primaryField.value,
|
|
454
|
+
entity,
|
|
455
|
+
field: primaryField.field,
|
|
456
|
+
fields, // Include all fields for validation
|
|
457
|
+
createIfMissing: hasCreate,
|
|
458
|
+
additionalFields
|
|
459
|
+
};
|
|
377
460
|
}
|
|
378
461
|
return { type, value: match[1] };
|
|
379
462
|
}
|
|
@@ -394,7 +477,24 @@ class ValidationService {
|
|
|
394
477
|
field: fieldName,
|
|
395
478
|
file: sourceFile,
|
|
396
479
|
message: `File reference not found: "${filePath}"`,
|
|
397
|
-
suggestion: `Create file at: ${resolvedPath}
|
|
480
|
+
suggestion: `Create file at: ${resolvedPath}`,
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
// Read the file and check for {@include} references
|
|
485
|
+
try {
|
|
486
|
+
const content = fs.readFileSync(resolvedPath, 'utf-8');
|
|
487
|
+
await this.validateIncludeReferences(content, resolvedPath, new Set([resolvedPath]));
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
this.addError({
|
|
491
|
+
type: 'reference',
|
|
492
|
+
severity: 'error',
|
|
493
|
+
entity: entityName,
|
|
494
|
+
field: fieldName,
|
|
495
|
+
file: sourceFile,
|
|
496
|
+
message: `Failed to read file reference: "${filePath}"`,
|
|
497
|
+
details: error instanceof Error ? error.message : String(error),
|
|
398
498
|
});
|
|
399
499
|
}
|
|
400
500
|
}
|
|
@@ -411,21 +511,41 @@ class ValidationService {
|
|
|
411
511
|
field: fieldName,
|
|
412
512
|
file: sourceFile,
|
|
413
513
|
message: `Lookup entity "${parsed.entity}" not found`,
|
|
414
|
-
suggestion: 'Check entity name spelling and case'
|
|
514
|
+
suggestion: 'Check entity name spelling and case',
|
|
415
515
|
});
|
|
416
516
|
return;
|
|
417
517
|
}
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
518
|
+
// For multi-field lookups, validate all fields
|
|
519
|
+
if (parsed.fields && parsed.fields.length > 0) {
|
|
520
|
+
for (const { field } of parsed.fields) {
|
|
521
|
+
const lookupField = lookupEntity.Fields.find((f) => f.Name === field);
|
|
522
|
+
if (!lookupField) {
|
|
523
|
+
this.addError({
|
|
524
|
+
type: 'reference',
|
|
525
|
+
severity: 'error',
|
|
526
|
+
entity: entityName,
|
|
527
|
+
field: fieldName,
|
|
528
|
+
file: sourceFile,
|
|
529
|
+
message: `Lookup field "${field}" not found on entity "${parsed.entity}"`,
|
|
530
|
+
suggestion: `Available fields: ${lookupEntity.Fields.map((f) => f.Name).join(', ')}`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (parsed.field) {
|
|
536
|
+
// Fallback for single field lookup (backward compatibility)
|
|
537
|
+
const lookupField = lookupEntity.Fields.find((f) => f.Name === parsed.field);
|
|
538
|
+
if (!lookupField) {
|
|
539
|
+
this.addError({
|
|
540
|
+
type: 'reference',
|
|
541
|
+
severity: 'error',
|
|
542
|
+
entity: entityName,
|
|
543
|
+
field: fieldName,
|
|
544
|
+
file: sourceFile,
|
|
545
|
+
message: `Lookup field "${parsed.field}" not found on entity "${parsed.entity}"`,
|
|
546
|
+
suggestion: `Available fields: ${lookupEntity.Fields.map((f) => f.Name).join(', ')}`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
429
549
|
}
|
|
430
550
|
// Track dependency
|
|
431
551
|
this.addEntityDependency(entityName, parsed.entity);
|
|
@@ -444,7 +564,7 @@ class ValidationService {
|
|
|
444
564
|
field: fieldName,
|
|
445
565
|
file: sourceFile,
|
|
446
566
|
message: `Template file not found: "${templatePath}"`,
|
|
447
|
-
suggestion: `Create template at: ${resolvedPath}
|
|
567
|
+
suggestion: `Create template at: ${resolvedPath}`,
|
|
448
568
|
});
|
|
449
569
|
return;
|
|
450
570
|
}
|
|
@@ -460,14 +580,14 @@ class ValidationService {
|
|
|
460
580
|
field: fieldName,
|
|
461
581
|
file: sourceFile,
|
|
462
582
|
message: `Template file is not valid JSON: "${templatePath}"`,
|
|
463
|
-
details: error instanceof Error ? error.message : String(error)
|
|
583
|
+
details: error instanceof Error ? error.message : String(error),
|
|
464
584
|
});
|
|
465
585
|
}
|
|
466
586
|
}
|
|
467
587
|
/**
|
|
468
588
|
* Validates @parent: references
|
|
469
589
|
*/
|
|
470
|
-
validateParentReference(
|
|
590
|
+
validateParentReference(_fieldName, parentContext, sourceFile, entityName, currentFieldName) {
|
|
471
591
|
if (!parentContext) {
|
|
472
592
|
this.addError({
|
|
473
593
|
type: 'reference',
|
|
@@ -476,14 +596,14 @@ class ValidationService {
|
|
|
476
596
|
field: currentFieldName,
|
|
477
597
|
file: sourceFile,
|
|
478
598
|
message: `@parent: reference used but no parent context exists`,
|
|
479
|
-
suggestion: '@parent: can only be used in nested/related entities'
|
|
599
|
+
suggestion: '@parent: can only be used in nested/related entities',
|
|
480
600
|
});
|
|
481
601
|
}
|
|
482
602
|
}
|
|
483
603
|
/**
|
|
484
604
|
* Validates @root: references
|
|
485
605
|
*/
|
|
486
|
-
validateRootReference(
|
|
606
|
+
validateRootReference(_fieldName, parentContext, sourceFile, entityName, currentFieldName) {
|
|
487
607
|
if (!parentContext) {
|
|
488
608
|
this.addError({
|
|
489
609
|
type: 'reference',
|
|
@@ -492,7 +612,7 @@ class ValidationService {
|
|
|
492
612
|
field: currentFieldName,
|
|
493
613
|
file: sourceFile,
|
|
494
614
|
message: `@root: reference used but no root context exists`,
|
|
495
|
-
suggestion: '@root: can only be used in nested/related entities'
|
|
615
|
+
suggestion: '@root: can only be used in nested/related entities',
|
|
496
616
|
});
|
|
497
617
|
}
|
|
498
618
|
}
|
|
@@ -504,7 +624,7 @@ class ValidationService {
|
|
|
504
624
|
this.entityDependencies.set(entityName, {
|
|
505
625
|
entityName,
|
|
506
626
|
dependsOn: new Set(),
|
|
507
|
-
file: filePath
|
|
627
|
+
file: filePath,
|
|
508
628
|
});
|
|
509
629
|
}
|
|
510
630
|
// Track dependencies from lookups in fields
|
|
@@ -523,11 +643,15 @@ class ValidationService {
|
|
|
523
643
|
* Add an entity dependency
|
|
524
644
|
*/
|
|
525
645
|
addEntityDependency(from, to) {
|
|
646
|
+
// Don't add self-references as dependencies (e.g., ParentID in hierarchical structures)
|
|
647
|
+
if (from === to) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
526
650
|
if (!this.entityDependencies.has(from)) {
|
|
527
651
|
this.entityDependencies.set(from, {
|
|
528
652
|
entityName: from,
|
|
529
653
|
dependsOn: new Set(),
|
|
530
|
-
file: ''
|
|
654
|
+
file: '',
|
|
531
655
|
});
|
|
532
656
|
}
|
|
533
657
|
this.entityDependencies.get(from).dependsOn.add(to);
|
|
@@ -546,7 +670,7 @@ class ValidationService {
|
|
|
546
670
|
// Check for circular dependencies
|
|
547
671
|
const visited = new Set();
|
|
548
672
|
const recursionStack = new Set();
|
|
549
|
-
for (const [entity
|
|
673
|
+
for (const [entity] of this.entityDependencies) {
|
|
550
674
|
if (!visited.has(entity)) {
|
|
551
675
|
this.checkCircularDependency(entity, visited, recursionStack);
|
|
552
676
|
}
|
|
@@ -563,7 +687,7 @@ class ValidationService {
|
|
|
563
687
|
entity: violation.entity,
|
|
564
688
|
file: violation.file,
|
|
565
689
|
message: `Entity '${violation.entity}' depends on '${violation.dependency}' but is processed before it`,
|
|
566
|
-
suggestion: `Reorder directories to: [${suggestedOrder.join(', ')}]
|
|
690
|
+
suggestion: `Reorder directories to: [${suggestedOrder.join(', ')}]`,
|
|
567
691
|
});
|
|
568
692
|
}
|
|
569
693
|
}
|
|
@@ -594,7 +718,7 @@ class ValidationService {
|
|
|
594
718
|
entity: entity,
|
|
595
719
|
file: deps.file,
|
|
596
720
|
message: `Circular dependency detected: ${cyclePath}`,
|
|
597
|
-
suggestion: 'Restructure your entities to avoid circular references'
|
|
721
|
+
suggestion: 'Restructure your entities to avoid circular references',
|
|
598
722
|
});
|
|
599
723
|
return true;
|
|
600
724
|
}
|
|
@@ -607,12 +731,13 @@ class ValidationService {
|
|
|
607
731
|
* Get directories in order based on config
|
|
608
732
|
*/
|
|
609
733
|
async getDirectoriesInOrder(rootDir, config) {
|
|
610
|
-
const allDirs = fs
|
|
611
|
-
.
|
|
612
|
-
.filter(
|
|
734
|
+
const allDirs = fs
|
|
735
|
+
.readdirSync(rootDir)
|
|
736
|
+
.filter((f) => fs.statSync(path.join(rootDir, f)).isDirectory())
|
|
737
|
+
.filter((d) => !d.startsWith('.'));
|
|
613
738
|
if (config.directoryOrder && Array.isArray(config.directoryOrder)) {
|
|
614
739
|
const ordered = config.directoryOrder.filter((d) => allDirs.includes(d));
|
|
615
|
-
const remaining = allDirs.filter(d => !ordered.includes(d)).sort();
|
|
740
|
+
const remaining = allDirs.filter((d) => !ordered.includes(d)).sort();
|
|
616
741
|
return [...ordered, ...remaining];
|
|
617
742
|
}
|
|
618
743
|
return allDirs.sort();
|
|
@@ -621,14 +746,13 @@ class ValidationService {
|
|
|
621
746
|
* Get files matching pattern
|
|
622
747
|
*/
|
|
623
748
|
async getMatchingFiles(dir, pattern) {
|
|
624
|
-
const files = fs.readdirSync(dir)
|
|
625
|
-
.filter(f => fs.statSync(path.join(dir, f)).isFile());
|
|
749
|
+
const files = fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile());
|
|
626
750
|
// Simple glob pattern matching
|
|
627
751
|
if (pattern === '*.json') {
|
|
628
|
-
return files.filter(f => f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
752
|
+
return files.filter((f) => f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
629
753
|
}
|
|
630
754
|
else if (pattern === '.*.json') {
|
|
631
|
-
return files.filter(f => f.startsWith('.') && f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
755
|
+
return files.filter((f) => f.startsWith('.') && f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
632
756
|
}
|
|
633
757
|
return files;
|
|
634
758
|
}
|
|
@@ -660,7 +784,7 @@ class ValidationService {
|
|
|
660
784
|
violations.push({
|
|
661
785
|
entity: entityName,
|
|
662
786
|
dependency: dep,
|
|
663
|
-
file: deps.file
|
|
787
|
+
file: deps.file,
|
|
664
788
|
});
|
|
665
789
|
}
|
|
666
790
|
}
|
|
@@ -714,6 +838,7 @@ class ValidationService {
|
|
|
714
838
|
this.warnings = [];
|
|
715
839
|
this.entityDependencies.clear();
|
|
716
840
|
this.processedEntities.clear();
|
|
841
|
+
this.userRoleCache.clear();
|
|
717
842
|
}
|
|
718
843
|
/**
|
|
719
844
|
* Get validation result
|
|
@@ -728,10 +853,163 @@ class ValidationService {
|
|
|
728
853
|
totalEntities: 0,
|
|
729
854
|
totalErrors: this.errors.length,
|
|
730
855
|
totalWarnings: this.warnings.length,
|
|
731
|
-
fileResults: new Map()
|
|
732
|
-
}
|
|
856
|
+
fileResults: new Map(),
|
|
857
|
+
},
|
|
733
858
|
};
|
|
734
859
|
}
|
|
860
|
+
/**
|
|
861
|
+
* Load user roles from the database into cache
|
|
862
|
+
*/
|
|
863
|
+
async loadUserRoles() {
|
|
864
|
+
try {
|
|
865
|
+
const rv = new core_1.RunView();
|
|
866
|
+
const systemUser = (0, provider_utils_1.getSystemUser)();
|
|
867
|
+
// Load all user roles with role names
|
|
868
|
+
const result = await rv.RunView({
|
|
869
|
+
EntityName: 'User Roles',
|
|
870
|
+
ExtraFilter: '',
|
|
871
|
+
OrderBy: 'UserID',
|
|
872
|
+
MaxRows: 10000,
|
|
873
|
+
}, systemUser);
|
|
874
|
+
if (!result.Success) {
|
|
875
|
+
this.addWarning({
|
|
876
|
+
type: 'validation',
|
|
877
|
+
severity: 'warning',
|
|
878
|
+
file: 'system',
|
|
879
|
+
message: 'Failed to load user roles for validation',
|
|
880
|
+
details: result.ErrorMessage,
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
// Group roles by UserID
|
|
885
|
+
for (const userRole of result.Results || []) {
|
|
886
|
+
const userId = userRole.UserID;
|
|
887
|
+
const roleName = userRole.Role;
|
|
888
|
+
if (!this.userRoleCache.has(userId)) {
|
|
889
|
+
this.userRoleCache.set(userId, []);
|
|
890
|
+
}
|
|
891
|
+
this.userRoleCache.get(userId).push(roleName);
|
|
892
|
+
}
|
|
893
|
+
if (this.options.verbose) {
|
|
894
|
+
console.log(`Loaded roles for ${this.userRoleCache.size} users`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
this.addWarning({
|
|
899
|
+
type: 'validation',
|
|
900
|
+
severity: 'warning',
|
|
901
|
+
file: 'system',
|
|
902
|
+
message: 'Error loading user roles for validation',
|
|
903
|
+
details: error instanceof Error ? error.message : String(error),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Validate a UserID field value against allowed roles
|
|
909
|
+
*/
|
|
910
|
+
async validateUserRole(userId, entityName, fieldName, filePath, config) {
|
|
911
|
+
// Skip if user role validation is not enabled
|
|
912
|
+
if (!config.userRoleValidation?.enabled) {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const userRoles = this.userRoleCache.get(userId);
|
|
916
|
+
const allowedRoles = config.userRoleValidation.allowedRoles || [];
|
|
917
|
+
const allowUsersWithoutRoles = config.userRoleValidation.allowUsersWithoutRoles || false;
|
|
918
|
+
if (!userRoles || userRoles.length === 0) {
|
|
919
|
+
if (!allowUsersWithoutRoles) {
|
|
920
|
+
this.addError({
|
|
921
|
+
type: 'validation',
|
|
922
|
+
severity: 'error',
|
|
923
|
+
entity: entityName,
|
|
924
|
+
field: fieldName,
|
|
925
|
+
file: filePath,
|
|
926
|
+
message: `UserID '${userId}' does not have any assigned roles`,
|
|
927
|
+
suggestion: allowedRoles.length > 0
|
|
928
|
+
? `User must have one of these roles: ${allowedRoles.join(', ')}`
|
|
929
|
+
: 'Assign appropriate roles to this user or set allowUsersWithoutRoles: true',
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
// Check if user has at least one allowed role
|
|
935
|
+
if (allowedRoles.length > 0) {
|
|
936
|
+
const hasAllowedRole = userRoles.some((role) => allowedRoles.includes(role));
|
|
937
|
+
if (!hasAllowedRole) {
|
|
938
|
+
this.addError({
|
|
939
|
+
type: 'validation',
|
|
940
|
+
severity: 'error',
|
|
941
|
+
entity: entityName,
|
|
942
|
+
field: fieldName,
|
|
943
|
+
file: filePath,
|
|
944
|
+
message: `UserID '${userId}' has roles [${userRoles.join(', ')}] but none are in allowed list`,
|
|
945
|
+
suggestion: `Allowed roles: ${allowedRoles.join(', ')}`,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Validates {@include} references within file content
|
|
952
|
+
*
|
|
953
|
+
* Recursively checks all {@include path} references in file content to ensure:
|
|
954
|
+
* - Referenced files exist
|
|
955
|
+
* - No circular references occur
|
|
956
|
+
* - Include paths are valid
|
|
957
|
+
*
|
|
958
|
+
* @param content - The file content to validate
|
|
959
|
+
* @param filePath - Path of the file being validated
|
|
960
|
+
* @param visitedPaths - Set of already visited paths for circular reference detection
|
|
961
|
+
*/
|
|
962
|
+
async validateIncludeReferences(content, filePath, visitedPaths) {
|
|
963
|
+
// Pattern to match {@include path} references
|
|
964
|
+
const includePattern = /\{@include\s+([^\}]+)\s*\}/g;
|
|
965
|
+
let match;
|
|
966
|
+
while ((match = includePattern.exec(content)) !== null) {
|
|
967
|
+
const [fullMatch, includePath] = match;
|
|
968
|
+
const trimmedPath = includePath.trim();
|
|
969
|
+
// Resolve the include path relative to the current file's directory
|
|
970
|
+
const currentDir = path.dirname(filePath);
|
|
971
|
+
const resolvedPath = path.resolve(currentDir, trimmedPath);
|
|
972
|
+
// Check for circular reference
|
|
973
|
+
if (visitedPaths.has(resolvedPath)) {
|
|
974
|
+
this.addError({
|
|
975
|
+
type: 'reference',
|
|
976
|
+
severity: 'error',
|
|
977
|
+
file: filePath,
|
|
978
|
+
message: `Circular {@include} reference detected: "${trimmedPath}"`,
|
|
979
|
+
details: `Path ${resolvedPath} is already being processed`,
|
|
980
|
+
suggestion: 'Restructure your includes to avoid circular references',
|
|
981
|
+
});
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
// Check if the included file exists
|
|
985
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
986
|
+
this.addError({
|
|
987
|
+
type: 'reference',
|
|
988
|
+
severity: 'error',
|
|
989
|
+
file: filePath,
|
|
990
|
+
message: `{@include} file not found: "${trimmedPath}"`,
|
|
991
|
+
suggestion: `Create file at: ${resolvedPath}`,
|
|
992
|
+
});
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
// Recursively validate the included file
|
|
996
|
+
try {
|
|
997
|
+
const includedContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
998
|
+
const newVisitedPaths = new Set(visitedPaths);
|
|
999
|
+
newVisitedPaths.add(resolvedPath);
|
|
1000
|
+
await this.validateIncludeReferences(includedContent, resolvedPath, newVisitedPaths);
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
this.addError({
|
|
1004
|
+
type: 'reference',
|
|
1005
|
+
severity: 'error',
|
|
1006
|
+
file: filePath,
|
|
1007
|
+
message: `Failed to read {@include} file: "${trimmedPath}"`,
|
|
1008
|
+
details: error instanceof Error ? error.message : String(error),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
735
1013
|
}
|
|
736
1014
|
exports.ValidationService = ValidationService;
|
|
737
1015
|
//# sourceMappingURL=ValidationService.js.map
|