@memberjunction/metadata-sync 2.50.0 → 2.52.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 +423 -2
- package/dist/commands/file-reset/index.d.ts +15 -0
- package/dist/commands/file-reset/index.js +221 -0
- package/dist/commands/file-reset/index.js.map +1 -0
- package/dist/commands/pull/index.d.ts +1 -0
- package/dist/commands/pull/index.js +82 -10
- package/dist/commands/pull/index.js.map +1 -1
- package/dist/commands/push/index.d.ts +21 -0
- package/dist/commands/push/index.js +589 -45
- package/dist/commands/push/index.js.map +1 -1
- package/dist/commands/validate/index.d.ts +15 -0
- package/dist/commands/validate/index.js +149 -0
- package/dist/commands/validate/index.js.map +1 -0
- package/dist/commands/watch/index.js +39 -1
- package/dist/commands/watch/index.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/file-backup-manager.d.ts +90 -0
- package/dist/lib/file-backup-manager.js +186 -0
- package/dist/lib/file-backup-manager.js.map +1 -0
- package/dist/lib/provider-utils.d.ts +2 -2
- package/dist/lib/provider-utils.js +3 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/sync-engine.js +29 -3
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/FormattingService.d.ts +45 -0
- package/dist/services/FormattingService.js +564 -0
- package/dist/services/FormattingService.js.map +1 -0
- package/dist/services/ValidationService.d.ts +110 -0
- package/dist/services/ValidationService.js +737 -0
- package/dist/services/ValidationService.js.map +1 -0
- package/dist/types/validation.d.ts +98 -0
- package/dist/types/validation.js +97 -0
- package/dist/types/validation.js.map +1 -0
- package/oclif.manifest.json +205 -39
- package/package.json +7 -7
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.ValidationService = void 0;
|
|
27
|
+
const core_1 = require("@memberjunction/core");
|
|
28
|
+
const fs = __importStar(require("fs"));
|
|
29
|
+
const path = __importStar(require("path"));
|
|
30
|
+
class ValidationService {
|
|
31
|
+
metadata;
|
|
32
|
+
errors = [];
|
|
33
|
+
warnings = [];
|
|
34
|
+
entityDependencies = new Map();
|
|
35
|
+
processedEntities = new Set();
|
|
36
|
+
options;
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.metadata = new core_1.Metadata();
|
|
39
|
+
this.options = {
|
|
40
|
+
verbose: false,
|
|
41
|
+
outputFormat: 'human',
|
|
42
|
+
maxNestingDepth: 10,
|
|
43
|
+
checkBestPractices: true,
|
|
44
|
+
...options
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validates all metadata files in the specified directory
|
|
49
|
+
*/
|
|
50
|
+
async validateDirectory(dir) {
|
|
51
|
+
this.reset();
|
|
52
|
+
const configPath = path.join(dir, '.mj-sync.json');
|
|
53
|
+
if (!fs.existsSync(configPath)) {
|
|
54
|
+
this.addError({
|
|
55
|
+
type: 'entity',
|
|
56
|
+
severity: 'error',
|
|
57
|
+
file: dir,
|
|
58
|
+
message: 'No .mj-sync.json configuration file found in directory'
|
|
59
|
+
});
|
|
60
|
+
return this.getResult();
|
|
61
|
+
}
|
|
62
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
63
|
+
const directories = await this.getDirectoriesInOrder(dir, config);
|
|
64
|
+
let totalFiles = 0;
|
|
65
|
+
let totalEntities = 0;
|
|
66
|
+
const fileResults = new Map();
|
|
67
|
+
for (const subDir of directories) {
|
|
68
|
+
const subDirPath = path.join(dir, subDir);
|
|
69
|
+
const result = await this.validateEntityDirectory(subDirPath);
|
|
70
|
+
if (result) {
|
|
71
|
+
totalFiles += result.files;
|
|
72
|
+
totalEntities += result.entities;
|
|
73
|
+
for (const [file, fileResult] of result.fileResults) {
|
|
74
|
+
fileResults.set(file, fileResult);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Validate dependency order
|
|
79
|
+
await this.validateDependencyOrder(directories);
|
|
80
|
+
return {
|
|
81
|
+
isValid: this.errors.length === 0,
|
|
82
|
+
errors: this.errors,
|
|
83
|
+
warnings: this.warnings,
|
|
84
|
+
summary: {
|
|
85
|
+
totalFiles,
|
|
86
|
+
totalEntities,
|
|
87
|
+
totalErrors: this.errors.length,
|
|
88
|
+
totalWarnings: this.warnings.length,
|
|
89
|
+
fileResults
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Validates a single entity directory
|
|
95
|
+
*/
|
|
96
|
+
async validateEntityDirectory(dir) {
|
|
97
|
+
const configPath = path.join(dir, '.mj-sync.json');
|
|
98
|
+
if (!fs.existsSync(configPath)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
102
|
+
const entityInfo = this.metadata.EntityByName(config.entity);
|
|
103
|
+
if (!entityInfo) {
|
|
104
|
+
this.addError({
|
|
105
|
+
type: 'entity',
|
|
106
|
+
severity: 'error',
|
|
107
|
+
file: configPath,
|
|
108
|
+
message: `Entity "${config.entity}" not found in metadata`
|
|
109
|
+
});
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const files = await this.getMatchingFiles(dir, config.filePattern);
|
|
113
|
+
let totalEntities = 0;
|
|
114
|
+
const fileResults = new Map();
|
|
115
|
+
for (const file of files) {
|
|
116
|
+
const filePath = path.join(dir, file);
|
|
117
|
+
const result = await this.validateFile(filePath, entityInfo, config);
|
|
118
|
+
totalEntities += result.entityCount;
|
|
119
|
+
fileResults.set(filePath, result);
|
|
120
|
+
}
|
|
121
|
+
return { files: files.length, entities: totalEntities, fileResults };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Validates a single metadata file
|
|
125
|
+
*/
|
|
126
|
+
async validateFile(filePath, entityInfo, config) {
|
|
127
|
+
const fileErrors = [];
|
|
128
|
+
const fileWarnings = [];
|
|
129
|
+
let entityCount = 0;
|
|
130
|
+
try {
|
|
131
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
132
|
+
const data = JSON.parse(content);
|
|
133
|
+
const entities = Array.isArray(data) ? data : [data];
|
|
134
|
+
entityCount = entities.length;
|
|
135
|
+
for (const entityData of entities) {
|
|
136
|
+
await this.validateEntityData(entityData, entityInfo, filePath, config);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
fileErrors.push({
|
|
141
|
+
type: 'entity',
|
|
142
|
+
severity: 'error',
|
|
143
|
+
file: filePath,
|
|
144
|
+
message: `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// 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);
|
|
150
|
+
return {
|
|
151
|
+
file: filePath,
|
|
152
|
+
entityCount,
|
|
153
|
+
errors: currentFileErrors,
|
|
154
|
+
warnings: currentFileWarnings
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validates a single entity data object
|
|
159
|
+
*/
|
|
160
|
+
async validateEntityData(entityData, entityInfo, filePath, config, parentContext, depth = 0) {
|
|
161
|
+
// Check nesting depth
|
|
162
|
+
if (depth > this.options.maxNestingDepth) {
|
|
163
|
+
this.addWarning({
|
|
164
|
+
type: 'nesting',
|
|
165
|
+
severity: 'warning',
|
|
166
|
+
entity: entityInfo.Name,
|
|
167
|
+
file: filePath,
|
|
168
|
+
message: `Nesting depth ${depth} exceeds recommended maximum of ${this.options.maxNestingDepth}`,
|
|
169
|
+
suggestion: 'Consider flattening the data structure or increasing maxNestingDepth'
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Validate fields
|
|
173
|
+
if (entityData.fields) {
|
|
174
|
+
await this.validateFields(entityData.fields, entityInfo, filePath, parentContext);
|
|
175
|
+
}
|
|
176
|
+
// Track dependencies
|
|
177
|
+
this.trackEntityDependencies(entityData, entityInfo.Name, filePath);
|
|
178
|
+
// Validate related entities
|
|
179
|
+
if (entityData.relatedEntities) {
|
|
180
|
+
for (const [relatedEntityName, relatedData] of Object.entries(entityData.relatedEntities)) {
|
|
181
|
+
const relatedEntityInfo = this.metadata.EntityByName(relatedEntityName);
|
|
182
|
+
if (!relatedEntityInfo) {
|
|
183
|
+
this.addError({
|
|
184
|
+
type: 'entity',
|
|
185
|
+
severity: 'error',
|
|
186
|
+
entity: entityInfo.Name,
|
|
187
|
+
file: filePath,
|
|
188
|
+
message: `Related entity "${relatedEntityName}" not found in metadata`
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const relatedEntities = Array.isArray(relatedData) ? relatedData : [relatedData];
|
|
193
|
+
for (const relatedEntity of relatedEntities) {
|
|
194
|
+
await this.validateEntityData(relatedEntity, relatedEntityInfo, filePath, config, { entity: entityInfo.Name, field: relatedEntityName }, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Validates entity fields
|
|
201
|
+
*/
|
|
202
|
+
async validateFields(fields, entityInfo, filePath, parentContext) {
|
|
203
|
+
const entityFields = entityInfo.Fields;
|
|
204
|
+
const fieldMap = new Map(entityFields.map((f) => [f.Name, f]));
|
|
205
|
+
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
|
206
|
+
const fieldInfo = fieldMap.get(fieldName);
|
|
207
|
+
if (!fieldInfo) {
|
|
208
|
+
// Check if this might be a virtual property (getter/setter)
|
|
209
|
+
try {
|
|
210
|
+
const entityInstance = await this.metadata.GetEntityObject(entityInfo.Name);
|
|
211
|
+
const hasProperty = fieldName in entityInstance;
|
|
212
|
+
if (!hasProperty) {
|
|
213
|
+
this.addError({
|
|
214
|
+
type: 'field',
|
|
215
|
+
severity: 'error',
|
|
216
|
+
entity: entityInfo.Name,
|
|
217
|
+
field: fieldName,
|
|
218
|
+
file: filePath,
|
|
219
|
+
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"`
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// It's a virtual property, validate the value
|
|
224
|
+
await this.validateFieldValue(fieldValue, { Name: fieldName }, entityInfo, filePath, parentContext);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
// If we can't create an entity instance, fall back to error
|
|
229
|
+
this.addError({
|
|
230
|
+
type: 'field',
|
|
231
|
+
severity: 'error',
|
|
232
|
+
entity: entityInfo.Name,
|
|
233
|
+
field: fieldName,
|
|
234
|
+
file: filePath,
|
|
235
|
+
message: `Field "${fieldName}" does not exist on entity "${entityInfo.Name}"`
|
|
236
|
+
});
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Check if field is settable (not system field)
|
|
241
|
+
if (fieldInfo.IsSystemField || fieldName.startsWith('__mj_')) {
|
|
242
|
+
this.addError({
|
|
243
|
+
type: 'field',
|
|
244
|
+
severity: 'error',
|
|
245
|
+
entity: entityInfo.Name,
|
|
246
|
+
field: fieldName,
|
|
247
|
+
file: filePath,
|
|
248
|
+
message: `Field "${fieldName}" is a system field and cannot be set`,
|
|
249
|
+
suggestion: 'Remove this field from your metadata file'
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
// Validate field value and references
|
|
254
|
+
await this.validateFieldValue(fieldValue, fieldInfo, entityInfo, filePath, parentContext);
|
|
255
|
+
}
|
|
256
|
+
// Check for required fields
|
|
257
|
+
if (this.options.checkBestPractices) {
|
|
258
|
+
for (const field of entityFields) {
|
|
259
|
+
// Skip if field allows null or has a value already
|
|
260
|
+
if (field.AllowsNull || fields[field.Name]) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Skip if field has a default value
|
|
264
|
+
if (field.DefaultValue !== null && field.DefaultValue !== undefined) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Skip virtual/computed fields (foreign key reference fields)
|
|
268
|
+
// These are typically named without 'ID' suffix but have a corresponding FK field
|
|
269
|
+
const relatedEntityField = field.RelatedEntity;
|
|
270
|
+
const correspondingFKField = entityFields.find((f) => f.Name === field.Name + 'ID' && f.IsForeignKey);
|
|
271
|
+
if (relatedEntityField && correspondingFKField) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
// Skip fields that are marked as AutoUpdateOnly or ReadOnly
|
|
275
|
+
if (field.AutoUpdateOnly || field.ReadOnly) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// Skip if this is a parent context and the field can be inherited
|
|
279
|
+
if (parentContext && (field.Name === parentContext.field || field.Name === parentContext.field + 'ID')) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
// Special case: Skip TemplateID if TemplateText is provided (virtual property)
|
|
283
|
+
if (field.Name === 'TemplateID' && fields['TemplateText']) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// Skip Template field if TemplateText is provided
|
|
287
|
+
if (field.Name === 'Template' && fields['TemplateText']) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
this.addWarning({
|
|
291
|
+
type: 'bestpractice',
|
|
292
|
+
severity: 'warning',
|
|
293
|
+
entity: entityInfo.Name,
|
|
294
|
+
field: field.Name,
|
|
295
|
+
file: filePath,
|
|
296
|
+
message: `Required field "${field.Name}" is missing`,
|
|
297
|
+
suggestion: `Add "${field.Name}" to the fields object`
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Validates field values and references
|
|
304
|
+
*/
|
|
305
|
+
async validateFieldValue(value, fieldInfo, entityInfo, filePath, parentContext) {
|
|
306
|
+
if (typeof value === 'string' && value.startsWith('@')) {
|
|
307
|
+
await this.validateReference(value, fieldInfo, entityInfo, filePath, parentContext);
|
|
308
|
+
}
|
|
309
|
+
// Add type validation here if needed
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Validates special references (@file:, @lookup:, etc.)
|
|
313
|
+
*/
|
|
314
|
+
async validateReference(reference, fieldInfo, entityInfo, filePath, parentContext) {
|
|
315
|
+
const parsed = this.parseReference(reference);
|
|
316
|
+
if (!parsed) {
|
|
317
|
+
this.addError({
|
|
318
|
+
type: 'reference',
|
|
319
|
+
severity: 'error',
|
|
320
|
+
entity: entityInfo.Name,
|
|
321
|
+
field: fieldInfo.Name,
|
|
322
|
+
file: filePath,
|
|
323
|
+
message: `Invalid reference format: "${reference}"`
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
switch (parsed.type) {
|
|
328
|
+
case '@file:':
|
|
329
|
+
await this.validateFileReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
|
|
330
|
+
break;
|
|
331
|
+
case '@lookup:':
|
|
332
|
+
await this.validateLookupReference(parsed, filePath, entityInfo.Name, fieldInfo.Name);
|
|
333
|
+
break;
|
|
334
|
+
case '@template:':
|
|
335
|
+
await this.validateTemplateReference(parsed.value, filePath, entityInfo.Name, fieldInfo.Name);
|
|
336
|
+
break;
|
|
337
|
+
case '@parent:':
|
|
338
|
+
this.validateParentReference(parsed.value, parentContext, filePath, entityInfo.Name, fieldInfo.Name);
|
|
339
|
+
break;
|
|
340
|
+
case '@root:':
|
|
341
|
+
this.validateRootReference(parsed.value, parentContext, filePath, entityInfo.Name, fieldInfo.Name);
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Parses a reference string
|
|
347
|
+
*/
|
|
348
|
+
parseReference(reference) {
|
|
349
|
+
const patterns = [
|
|
350
|
+
['@file:', /^@file:(.+)$/],
|
|
351
|
+
['@lookup:', /^@lookup:([^.]+)\.([^=]+)=(.+)$/],
|
|
352
|
+
['@template:', /^@template:(.+)$/],
|
|
353
|
+
['@parent:', /^@parent:(.+)$/],
|
|
354
|
+
['@root:', /^@root:(.+)$/],
|
|
355
|
+
['@env:', /^@env:(.+)$/]
|
|
356
|
+
];
|
|
357
|
+
for (const [type, pattern] of patterns) {
|
|
358
|
+
const match = reference.match(pattern);
|
|
359
|
+
if (match) {
|
|
360
|
+
if (type === '@lookup:') {
|
|
361
|
+
const [, entity, field, valueAndOptions] = match;
|
|
362
|
+
const [value, ...options] = valueAndOptions.split('?');
|
|
363
|
+
const createIfMissing = options.includes('create');
|
|
364
|
+
const additionalFields = {};
|
|
365
|
+
for (const option of options) {
|
|
366
|
+
if (option.includes('&')) {
|
|
367
|
+
const pairs = option.split('&');
|
|
368
|
+
for (const pair of pairs) {
|
|
369
|
+
const [key, val] = pair.split('=');
|
|
370
|
+
if (key && val && key !== 'create') {
|
|
371
|
+
additionalFields[key] = val;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { type, value, entity, field, createIfMissing, additionalFields };
|
|
377
|
+
}
|
|
378
|
+
return { type, value: match[1] };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Validates @file: references
|
|
385
|
+
*/
|
|
386
|
+
async validateFileReference(filePath, sourceFile, entityName, fieldName) {
|
|
387
|
+
const dir = path.dirname(sourceFile);
|
|
388
|
+
const resolvedPath = path.resolve(dir, filePath);
|
|
389
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
390
|
+
this.addError({
|
|
391
|
+
type: 'reference',
|
|
392
|
+
severity: 'error',
|
|
393
|
+
entity: entityName,
|
|
394
|
+
field: fieldName,
|
|
395
|
+
file: sourceFile,
|
|
396
|
+
message: `File reference not found: "${filePath}"`,
|
|
397
|
+
suggestion: `Create file at: ${resolvedPath}`
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Validates @lookup: references
|
|
403
|
+
*/
|
|
404
|
+
async validateLookupReference(parsed, sourceFile, entityName, fieldName) {
|
|
405
|
+
const lookupEntity = this.metadata.EntityByName(parsed.entity);
|
|
406
|
+
if (!lookupEntity) {
|
|
407
|
+
this.addError({
|
|
408
|
+
type: 'reference',
|
|
409
|
+
severity: 'error',
|
|
410
|
+
entity: entityName,
|
|
411
|
+
field: fieldName,
|
|
412
|
+
file: sourceFile,
|
|
413
|
+
message: `Lookup entity "${parsed.entity}" not found`,
|
|
414
|
+
suggestion: 'Check entity name spelling and case'
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const lookupField = lookupEntity.Fields.find((f) => f.Name === parsed.field);
|
|
419
|
+
if (!lookupField) {
|
|
420
|
+
this.addError({
|
|
421
|
+
type: 'reference',
|
|
422
|
+
severity: 'error',
|
|
423
|
+
entity: entityName,
|
|
424
|
+
field: fieldName,
|
|
425
|
+
file: sourceFile,
|
|
426
|
+
message: `Lookup field "${parsed.field}" not found on entity "${parsed.entity}"`,
|
|
427
|
+
suggestion: `Available fields: ${lookupEntity.Fields.map((f) => f.Name).join(', ')}`
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// Track dependency
|
|
431
|
+
this.addEntityDependency(entityName, parsed.entity);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Validates @template: references
|
|
435
|
+
*/
|
|
436
|
+
async validateTemplateReference(templatePath, sourceFile, entityName, fieldName) {
|
|
437
|
+
const dir = path.dirname(sourceFile);
|
|
438
|
+
const resolvedPath = path.resolve(dir, templatePath);
|
|
439
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
440
|
+
this.addError({
|
|
441
|
+
type: 'reference',
|
|
442
|
+
severity: 'error',
|
|
443
|
+
entity: entityName,
|
|
444
|
+
field: fieldName,
|
|
445
|
+
file: sourceFile,
|
|
446
|
+
message: `Template file not found: "${templatePath}"`,
|
|
447
|
+
suggestion: `Create template at: ${resolvedPath}`
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// Validate template is valid JSON
|
|
452
|
+
try {
|
|
453
|
+
JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
this.addError({
|
|
457
|
+
type: 'reference',
|
|
458
|
+
severity: 'error',
|
|
459
|
+
entity: entityName,
|
|
460
|
+
field: fieldName,
|
|
461
|
+
file: sourceFile,
|
|
462
|
+
message: `Template file is not valid JSON: "${templatePath}"`,
|
|
463
|
+
details: error instanceof Error ? error.message : String(error)
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Validates @parent: references
|
|
469
|
+
*/
|
|
470
|
+
validateParentReference(fieldName, parentContext, sourceFile, entityName, currentFieldName) {
|
|
471
|
+
if (!parentContext) {
|
|
472
|
+
this.addError({
|
|
473
|
+
type: 'reference',
|
|
474
|
+
severity: 'error',
|
|
475
|
+
entity: entityName,
|
|
476
|
+
field: currentFieldName,
|
|
477
|
+
file: sourceFile,
|
|
478
|
+
message: `@parent: reference used but no parent context exists`,
|
|
479
|
+
suggestion: '@parent: can only be used in nested/related entities'
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Validates @root: references
|
|
485
|
+
*/
|
|
486
|
+
validateRootReference(fieldName, parentContext, sourceFile, entityName, currentFieldName) {
|
|
487
|
+
if (!parentContext) {
|
|
488
|
+
this.addError({
|
|
489
|
+
type: 'reference',
|
|
490
|
+
severity: 'error',
|
|
491
|
+
entity: entityName,
|
|
492
|
+
field: currentFieldName,
|
|
493
|
+
file: sourceFile,
|
|
494
|
+
message: `@root: reference used but no root context exists`,
|
|
495
|
+
suggestion: '@root: can only be used in nested/related entities'
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Track entity dependencies
|
|
501
|
+
*/
|
|
502
|
+
trackEntityDependencies(entityData, entityName, filePath) {
|
|
503
|
+
if (!this.entityDependencies.has(entityName)) {
|
|
504
|
+
this.entityDependencies.set(entityName, {
|
|
505
|
+
entityName,
|
|
506
|
+
dependsOn: new Set(),
|
|
507
|
+
file: filePath
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// Track dependencies from lookups in fields
|
|
511
|
+
if (entityData.fields) {
|
|
512
|
+
for (const value of Object.values(entityData.fields)) {
|
|
513
|
+
if (typeof value === 'string' && value.startsWith('@lookup:')) {
|
|
514
|
+
const parsed = this.parseReference(value);
|
|
515
|
+
if (parsed?.entity) {
|
|
516
|
+
this.addEntityDependency(entityName, parsed.entity);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Add an entity dependency
|
|
524
|
+
*/
|
|
525
|
+
addEntityDependency(from, to) {
|
|
526
|
+
if (!this.entityDependencies.has(from)) {
|
|
527
|
+
this.entityDependencies.set(from, {
|
|
528
|
+
entityName: from,
|
|
529
|
+
dependsOn: new Set(),
|
|
530
|
+
file: ''
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
this.entityDependencies.get(from).dependsOn.add(to);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Validates dependency order
|
|
537
|
+
*/
|
|
538
|
+
async validateDependencyOrder(directoryOrder) {
|
|
539
|
+
// Build a map of entity to directory
|
|
540
|
+
const entityToDirectory = new Map();
|
|
541
|
+
for (const dir of directoryOrder) {
|
|
542
|
+
// This is simplified - in reality we'd need to read the .mj-sync.json
|
|
543
|
+
// to get the actual entity name
|
|
544
|
+
entityToDirectory.set(dir, dir);
|
|
545
|
+
}
|
|
546
|
+
// Check for circular dependencies
|
|
547
|
+
const visited = new Set();
|
|
548
|
+
const recursionStack = new Set();
|
|
549
|
+
for (const [entity, deps] of this.entityDependencies) {
|
|
550
|
+
if (!visited.has(entity)) {
|
|
551
|
+
this.checkCircularDependency(entity, visited, recursionStack);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Check if current order satisfies dependencies
|
|
555
|
+
const orderViolations = this.checkDependencyOrder(directoryOrder);
|
|
556
|
+
if (orderViolations.length > 0) {
|
|
557
|
+
// Suggest a corrected order
|
|
558
|
+
const suggestedOrder = this.topologicalSort();
|
|
559
|
+
for (const violation of orderViolations) {
|
|
560
|
+
this.addError({
|
|
561
|
+
type: 'dependency',
|
|
562
|
+
severity: 'error',
|
|
563
|
+
entity: violation.entity,
|
|
564
|
+
file: violation.file,
|
|
565
|
+
message: `Entity '${violation.entity}' depends on '${violation.dependency}' but is processed before it`,
|
|
566
|
+
suggestion: `Reorder directories to: [${suggestedOrder.join(', ')}]`
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Check for circular dependencies
|
|
573
|
+
*/
|
|
574
|
+
checkCircularDependency(entity, visited, recursionStack, path = []) {
|
|
575
|
+
visited.add(entity);
|
|
576
|
+
recursionStack.add(entity);
|
|
577
|
+
path.push(entity);
|
|
578
|
+
const deps = this.entityDependencies.get(entity);
|
|
579
|
+
if (deps) {
|
|
580
|
+
for (const dep of deps.dependsOn) {
|
|
581
|
+
if (!visited.has(dep)) {
|
|
582
|
+
if (this.checkCircularDependency(dep, visited, recursionStack, [...path])) {
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else if (recursionStack.has(dep)) {
|
|
587
|
+
// Found circular dependency
|
|
588
|
+
const cycle = [...path, dep];
|
|
589
|
+
const cycleStart = cycle.indexOf(dep);
|
|
590
|
+
const cyclePath = cycle.slice(cycleStart).join(' → ');
|
|
591
|
+
this.addError({
|
|
592
|
+
type: 'circular',
|
|
593
|
+
severity: 'error',
|
|
594
|
+
entity: entity,
|
|
595
|
+
file: deps.file,
|
|
596
|
+
message: `Circular dependency detected: ${cyclePath}`,
|
|
597
|
+
suggestion: 'Restructure your entities to avoid circular references'
|
|
598
|
+
});
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
recursionStack.delete(entity);
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Get directories in order based on config
|
|
608
|
+
*/
|
|
609
|
+
async getDirectoriesInOrder(rootDir, config) {
|
|
610
|
+
const allDirs = fs.readdirSync(rootDir)
|
|
611
|
+
.filter(f => fs.statSync(path.join(rootDir, f)).isDirectory())
|
|
612
|
+
.filter(d => !d.startsWith('.'));
|
|
613
|
+
if (config.directoryOrder && Array.isArray(config.directoryOrder)) {
|
|
614
|
+
const ordered = config.directoryOrder.filter((d) => allDirs.includes(d));
|
|
615
|
+
const remaining = allDirs.filter(d => !ordered.includes(d)).sort();
|
|
616
|
+
return [...ordered, ...remaining];
|
|
617
|
+
}
|
|
618
|
+
return allDirs.sort();
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Get files matching pattern
|
|
622
|
+
*/
|
|
623
|
+
async getMatchingFiles(dir, pattern) {
|
|
624
|
+
const files = fs.readdirSync(dir)
|
|
625
|
+
.filter(f => fs.statSync(path.join(dir, f)).isFile());
|
|
626
|
+
// Simple glob pattern matching
|
|
627
|
+
if (pattern === '*.json') {
|
|
628
|
+
return files.filter(f => f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
629
|
+
}
|
|
630
|
+
else if (pattern === '.*.json') {
|
|
631
|
+
return files.filter(f => f.startsWith('.') && f.endsWith('.json') && !f.startsWith('.mj-'));
|
|
632
|
+
}
|
|
633
|
+
return files;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Add an error
|
|
637
|
+
*/
|
|
638
|
+
addError(error) {
|
|
639
|
+
this.errors.push(error);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Add a warning
|
|
643
|
+
*/
|
|
644
|
+
addWarning(warning) {
|
|
645
|
+
this.warnings.push(warning);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Check if current directory order satisfies dependencies
|
|
649
|
+
*/
|
|
650
|
+
checkDependencyOrder(directoryOrder) {
|
|
651
|
+
const violations = [];
|
|
652
|
+
const processedEntities = new Set();
|
|
653
|
+
for (const dir of directoryOrder) {
|
|
654
|
+
// In real implementation, we'd read .mj-sync.json to get entity name
|
|
655
|
+
const entityName = dir; // Simplified for now
|
|
656
|
+
const deps = this.entityDependencies.get(entityName);
|
|
657
|
+
if (deps) {
|
|
658
|
+
for (const dep of deps.dependsOn) {
|
|
659
|
+
if (!processedEntities.has(dep) && directoryOrder.includes(dep)) {
|
|
660
|
+
violations.push({
|
|
661
|
+
entity: entityName,
|
|
662
|
+
dependency: dep,
|
|
663
|
+
file: deps.file
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
processedEntities.add(entityName);
|
|
669
|
+
}
|
|
670
|
+
return violations;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Perform topological sort on entity dependencies
|
|
674
|
+
*/
|
|
675
|
+
topologicalSort() {
|
|
676
|
+
const result = [];
|
|
677
|
+
const visited = new Set();
|
|
678
|
+
const tempStack = new Set();
|
|
679
|
+
const visit = (entity) => {
|
|
680
|
+
if (tempStack.has(entity)) {
|
|
681
|
+
// Circular dependency, already handled by checkCircularDependency
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
if (visited.has(entity)) {
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
tempStack.add(entity);
|
|
688
|
+
const deps = this.entityDependencies.get(entity);
|
|
689
|
+
if (deps) {
|
|
690
|
+
for (const dep of deps.dependsOn) {
|
|
691
|
+
if (!visit(dep)) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
tempStack.delete(entity);
|
|
697
|
+
visited.add(entity);
|
|
698
|
+
result.push(entity);
|
|
699
|
+
return true;
|
|
700
|
+
};
|
|
701
|
+
// Visit all entities
|
|
702
|
+
for (const entity of this.entityDependencies.keys()) {
|
|
703
|
+
if (!visited.has(entity)) {
|
|
704
|
+
visit(entity);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Reset validation state
|
|
711
|
+
*/
|
|
712
|
+
reset() {
|
|
713
|
+
this.errors = [];
|
|
714
|
+
this.warnings = [];
|
|
715
|
+
this.entityDependencies.clear();
|
|
716
|
+
this.processedEntities.clear();
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Get validation result
|
|
720
|
+
*/
|
|
721
|
+
getResult() {
|
|
722
|
+
return {
|
|
723
|
+
isValid: this.errors.length === 0,
|
|
724
|
+
errors: this.errors,
|
|
725
|
+
warnings: this.warnings,
|
|
726
|
+
summary: {
|
|
727
|
+
totalFiles: 0,
|
|
728
|
+
totalEntities: 0,
|
|
729
|
+
totalErrors: this.errors.length,
|
|
730
|
+
totalWarnings: this.warnings.length,
|
|
731
|
+
fileResults: new Map()
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
exports.ValidationService = ValidationService;
|
|
737
|
+
//# sourceMappingURL=ValidationService.js.map
|