@unrdf/project-engine 5.0.1
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/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +58 -0
- package/src/api-contract-validator.mjs +711 -0
- package/src/auto-test-generator.mjs +444 -0
- package/src/autonomic-mapek.mjs +511 -0
- package/src/capabilities-manifest.mjs +125 -0
- package/src/code-complexity-js.mjs +368 -0
- package/src/dependency-graph.mjs +276 -0
- package/src/doc-drift-checker.mjs +172 -0
- package/src/doc-generator.mjs +229 -0
- package/src/domain-infer.mjs +966 -0
- package/src/drift-snapshot.mjs +775 -0
- package/src/file-roles.mjs +94 -0
- package/src/fs-scan.mjs +305 -0
- package/src/gap-finder.mjs +376 -0
- package/src/golden-structure.mjs +149 -0
- package/src/hotspot-analyzer.mjs +412 -0
- package/src/index.mjs +151 -0
- package/src/initialize.mjs +957 -0
- package/src/lens/project-structure.mjs +74 -0
- package/src/mapek-orchestration.mjs +665 -0
- package/src/materialize-apply.mjs +505 -0
- package/src/materialize-plan.mjs +422 -0
- package/src/materialize.mjs +137 -0
- package/src/policy-derivation.mjs +869 -0
- package/src/project-config.mjs +142 -0
- package/src/project-diff.mjs +28 -0
- package/src/project-engine/build-utils.mjs +237 -0
- package/src/project-engine/code-analyzer.mjs +248 -0
- package/src/project-engine/doc-generator.mjs +407 -0
- package/src/project-engine/infrastructure.mjs +213 -0
- package/src/project-engine/metrics.mjs +146 -0
- package/src/project-model.mjs +111 -0
- package/src/project-report.mjs +348 -0
- package/src/refactoring-guide.mjs +242 -0
- package/src/stack-detect.mjs +102 -0
- package/src/stack-linter.mjs +213 -0
- package/src/template-infer.mjs +674 -0
- package/src/type-auditor.mjs +609 -0
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Domain model inference engine - extract entities, fields, relations from code
|
|
3
|
+
* @module project-engine/domain-infer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { UnrdfDataFactory as DataFactory } from '@unrdf/core/rdf/n3-justified-only';
|
|
9
|
+
import { createStore } from '@unrdf/oxigraph'; // TODO: Replace with Oxigraph Store
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
const { namedNode, literal } = DataFactory;
|
|
13
|
+
|
|
14
|
+
/* ========================================================================= */
|
|
15
|
+
/* Namespace prefixes */
|
|
16
|
+
/* ========================================================================= */
|
|
17
|
+
|
|
18
|
+
const NS = {
|
|
19
|
+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
20
|
+
rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
|
|
21
|
+
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
|
22
|
+
dom: 'http://example.org/unrdf/domain#',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/* ========================================================================= */
|
|
26
|
+
/* Zod Schemas */
|
|
27
|
+
/* ========================================================================= */
|
|
28
|
+
|
|
29
|
+
const StackProfileSchema = z.object({
|
|
30
|
+
hasZod: z.boolean().default(false),
|
|
31
|
+
hasPrisma: z.boolean().default(false),
|
|
32
|
+
hasTypeORM: z.boolean().default(false),
|
|
33
|
+
hasSequelize: z.boolean().default(false),
|
|
34
|
+
hasDrizzle: z.boolean().default(false),
|
|
35
|
+
hasTypescript: z.boolean().default(false),
|
|
36
|
+
sourceRoot: z.string().default('src'),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const InferOptionsSchema = z.object({
|
|
40
|
+
fsStore: z.any(),
|
|
41
|
+
stackProfile: StackProfileSchema.optional(),
|
|
42
|
+
baseIri: z.string().default('http://example.org/unrdf/domain#'),
|
|
43
|
+
projectRoot: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} DomainField
|
|
48
|
+
* @property {string} name
|
|
49
|
+
* @property {string} type
|
|
50
|
+
* @property {boolean} optional
|
|
51
|
+
* @property {boolean} array
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} DomainEntity
|
|
56
|
+
* @property {string} name
|
|
57
|
+
* @property {string} source - 'zod' | 'prisma' | 'typescript' | 'typeorm' | 'sequelize'
|
|
58
|
+
* @property {DomainField[]} fields
|
|
59
|
+
* @property {string[]} relations
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} DomainInferResult
|
|
64
|
+
* @property {Store} store
|
|
65
|
+
* @property {{entityCount: number, fieldCount: number, relationshipCount: number}} summary
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/* ========================================================================= */
|
|
69
|
+
/* Type mapping */
|
|
70
|
+
/* ========================================================================= */
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Map common type names to XSD types
|
|
74
|
+
* @param {string} typeName
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function mapToXsdType(typeName) {
|
|
78
|
+
const typeMap = {
|
|
79
|
+
string: `${NS.xsd}string`,
|
|
80
|
+
number: `${NS.xsd}decimal`,
|
|
81
|
+
int: `${NS.xsd}integer`,
|
|
82
|
+
integer: `${NS.xsd}integer`,
|
|
83
|
+
float: `${NS.xsd}float`,
|
|
84
|
+
double: `${NS.xsd}double`,
|
|
85
|
+
boolean: `${NS.xsd}boolean`,
|
|
86
|
+
bool: `${NS.xsd}boolean`,
|
|
87
|
+
date: `${NS.xsd}date`,
|
|
88
|
+
datetime: `${NS.xsd}dateTime`,
|
|
89
|
+
timestamp: `${NS.xsd}dateTime`,
|
|
90
|
+
bigint: `${NS.xsd}integer`,
|
|
91
|
+
json: `${NS.xsd}string`,
|
|
92
|
+
uuid: `${NS.xsd}string`,
|
|
93
|
+
email: `${NS.xsd}string`,
|
|
94
|
+
url: `${NS.xsd}anyURI`,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const normalized = typeName.toLowerCase().replace(/[\[\]?]/g, '');
|
|
98
|
+
return typeMap[normalized] || `${NS.xsd}string`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ========================================================================= */
|
|
102
|
+
/* File reading utilities */
|
|
103
|
+
/* ========================================================================= */
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read file content from filesystem
|
|
107
|
+
* @param {string} filePath
|
|
108
|
+
* @returns {Promise<string|null>}
|
|
109
|
+
*/
|
|
110
|
+
async function readFileContent(filePath) {
|
|
111
|
+
try {
|
|
112
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extract file paths from fsStore
|
|
120
|
+
* @param {Store} fsStore
|
|
121
|
+
* @returns {Set<string>}
|
|
122
|
+
*/
|
|
123
|
+
function extractFilePaths(fsStore) {
|
|
124
|
+
const paths = new Set();
|
|
125
|
+
const quads = fsStore.getQuads(
|
|
126
|
+
null,
|
|
127
|
+
namedNode('http://example.org/unrdf/filesystem#relativePath'),
|
|
128
|
+
null
|
|
129
|
+
);
|
|
130
|
+
for (const quad of quads) {
|
|
131
|
+
paths.add(quad.object.value);
|
|
132
|
+
}
|
|
133
|
+
return paths;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ========================================================================= */
|
|
137
|
+
/* Stack profile detection */
|
|
138
|
+
/* ========================================================================= */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Detect stack profile from package.json and file structure
|
|
142
|
+
* @param {Store} fsStore
|
|
143
|
+
* @param {string} [projectRoot]
|
|
144
|
+
* @returns {Promise<z.infer<typeof StackProfileSchema>>}
|
|
145
|
+
*/
|
|
146
|
+
async function detectStackProfile(fsStore, projectRoot) {
|
|
147
|
+
const profile = {
|
|
148
|
+
hasZod: false,
|
|
149
|
+
hasPrisma: false,
|
|
150
|
+
hasTypeORM: false,
|
|
151
|
+
hasSequelize: false,
|
|
152
|
+
hasDrizzle: false,
|
|
153
|
+
hasTypescript: false,
|
|
154
|
+
sourceRoot: 'src',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const filePaths = extractFilePaths(fsStore);
|
|
158
|
+
|
|
159
|
+
// Check for TypeScript
|
|
160
|
+
if (filePaths.has('tsconfig.json') || filePaths.has('tsconfig.base.json')) {
|
|
161
|
+
profile.hasTypescript = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check for Prisma
|
|
165
|
+
if (filePaths.has('prisma/schema.prisma') || filePaths.has('schema.prisma')) {
|
|
166
|
+
profile.hasPrisma = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Try to read package.json if projectRoot provided
|
|
170
|
+
if (projectRoot) {
|
|
171
|
+
const pkgContent = await readFileContent(path.join(projectRoot, 'package.json'));
|
|
172
|
+
if (pkgContent) {
|
|
173
|
+
try {
|
|
174
|
+
const pkg = JSON.parse(pkgContent);
|
|
175
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
176
|
+
|
|
177
|
+
if (allDeps.zod) profile.hasZod = true;
|
|
178
|
+
if (allDeps['@prisma/client'] || allDeps.prisma) profile.hasPrisma = true;
|
|
179
|
+
if (allDeps.typeorm) profile.hasTypeORM = true;
|
|
180
|
+
if (allDeps.sequelize) profile.hasSequelize = true;
|
|
181
|
+
if (allDeps.drizzle) profile.hasDrizzle = true;
|
|
182
|
+
if (allDeps.typescript) profile.hasTypescript = true;
|
|
183
|
+
} catch {
|
|
184
|
+
// Ignore JSON parse errors
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return profile;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ========================================================================= */
|
|
193
|
+
/* Zod schema parsing */
|
|
194
|
+
/* ========================================================================= */
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse Zod schema definitions from file content
|
|
198
|
+
* @param {string} content
|
|
199
|
+
* @param {string} fileName
|
|
200
|
+
* @returns {DomainEntity[]}
|
|
201
|
+
*/
|
|
202
|
+
function parseZodSchemas(content, _fileName) {
|
|
203
|
+
const entities = [];
|
|
204
|
+
|
|
205
|
+
// Match export const XxxSchema = z.object({ ... })
|
|
206
|
+
const schemaPattern =
|
|
207
|
+
/(?:export\s+)?(?:const|let)\s+(\w+)Schema\s*=\s*z\.object\(\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}\s*\)/g;
|
|
208
|
+
|
|
209
|
+
let match;
|
|
210
|
+
while ((match = schemaPattern.exec(content)) !== null) {
|
|
211
|
+
const entityName = match[1];
|
|
212
|
+
const fieldsBlock = match[2];
|
|
213
|
+
|
|
214
|
+
const fields = parseZodFields(fieldsBlock);
|
|
215
|
+
|
|
216
|
+
if (fields.length > 0) {
|
|
217
|
+
entities.push({
|
|
218
|
+
name: entityName,
|
|
219
|
+
source: 'zod',
|
|
220
|
+
fields,
|
|
221
|
+
relations: extractZodRelations(fieldsBlock),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Also match z.object inline (for simpler schemas)
|
|
227
|
+
const inlinePattern =
|
|
228
|
+
/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*z\.object\(\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}\s*\)/g;
|
|
229
|
+
|
|
230
|
+
while ((match = inlinePattern.exec(content)) !== null) {
|
|
231
|
+
const entityName = match[1];
|
|
232
|
+
if (entityName.endsWith('Schema')) continue; // Already matched above
|
|
233
|
+
|
|
234
|
+
const fieldsBlock = match[2];
|
|
235
|
+
const fields = parseZodFields(fieldsBlock);
|
|
236
|
+
|
|
237
|
+
if (fields.length > 0) {
|
|
238
|
+
entities.push({
|
|
239
|
+
name: entityName,
|
|
240
|
+
source: 'zod',
|
|
241
|
+
fields,
|
|
242
|
+
relations: extractZodRelations(fieldsBlock),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return entities;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse individual Zod fields from object block
|
|
252
|
+
* @param {string} fieldsBlock
|
|
253
|
+
* @returns {DomainField[]}
|
|
254
|
+
*/
|
|
255
|
+
function parseZodFields(fieldsBlock) {
|
|
256
|
+
const fields = [];
|
|
257
|
+
|
|
258
|
+
// Match: fieldName: z.string(), z.number(), z.boolean(), z.array(), etc.
|
|
259
|
+
const fieldPattern =
|
|
260
|
+
/(\w+)\s*:\s*z\.(string|number|boolean|date|bigint|array|enum|object|union|optional|nullable)(\([^)]*\))?/g;
|
|
261
|
+
|
|
262
|
+
let match;
|
|
263
|
+
while ((match = fieldPattern.exec(fieldsBlock)) !== null) {
|
|
264
|
+
const fieldName = match[1];
|
|
265
|
+
let zodType = match[2];
|
|
266
|
+
const _modifier = match[3] || '';
|
|
267
|
+
|
|
268
|
+
// Check for optional/nullable chain
|
|
269
|
+
const isOptional =
|
|
270
|
+
fieldsBlock.includes(`${fieldName}:`) &&
|
|
271
|
+
(fieldsBlock.includes('.optional()') || fieldsBlock.includes('.nullable()'));
|
|
272
|
+
const isArray = zodType === 'array';
|
|
273
|
+
|
|
274
|
+
// Map zod types to our types
|
|
275
|
+
let type = 'string';
|
|
276
|
+
if (zodType === 'number' || zodType === 'bigint') type = 'number';
|
|
277
|
+
else if (zodType === 'boolean') type = 'boolean';
|
|
278
|
+
else if (zodType === 'date') type = 'date';
|
|
279
|
+
else if (zodType === 'array') type = 'array';
|
|
280
|
+
else if (zodType === 'enum') type = 'enum';
|
|
281
|
+
|
|
282
|
+
fields.push({
|
|
283
|
+
name: fieldName,
|
|
284
|
+
type,
|
|
285
|
+
optional: isOptional,
|
|
286
|
+
array: isArray,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return fields;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Extract relations from Zod schema
|
|
295
|
+
* @param {string} fieldsBlock
|
|
296
|
+
* @returns {string[]}
|
|
297
|
+
*/
|
|
298
|
+
function extractZodRelations(fieldsBlock) {
|
|
299
|
+
const relations = [];
|
|
300
|
+
|
|
301
|
+
// Match: z.array(OtherSchema), z.lazy(() => OtherSchema)
|
|
302
|
+
const relationPattern = /z\.(?:array|lazy)\s*\(\s*(?:\(\)\s*=>\s*)?(\w+)Schema\s*\)/g;
|
|
303
|
+
|
|
304
|
+
let match;
|
|
305
|
+
while ((match = relationPattern.exec(fieldsBlock)) !== null) {
|
|
306
|
+
const relatedEntity = match[1];
|
|
307
|
+
if (!relations.includes(relatedEntity)) {
|
|
308
|
+
relations.push(relatedEntity);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return relations;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ========================================================================= */
|
|
316
|
+
/* Prisma schema parsing */
|
|
317
|
+
/* ========================================================================= */
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Parse Prisma schema file
|
|
321
|
+
* @param {string} content
|
|
322
|
+
* @returns {DomainEntity[]}
|
|
323
|
+
*/
|
|
324
|
+
function parsePrismaSchema(content) {
|
|
325
|
+
const entities = [];
|
|
326
|
+
|
|
327
|
+
// Match model blocks
|
|
328
|
+
const modelPattern = /model\s+(\w+)\s*\{([^}]+)\}/g;
|
|
329
|
+
|
|
330
|
+
let match;
|
|
331
|
+
while ((match = modelPattern.exec(content)) !== null) {
|
|
332
|
+
const modelName = match[1];
|
|
333
|
+
const fieldsBlock = match[2];
|
|
334
|
+
|
|
335
|
+
const { fields, relations } = parsePrismaFields(fieldsBlock);
|
|
336
|
+
|
|
337
|
+
entities.push({
|
|
338
|
+
name: modelName,
|
|
339
|
+
source: 'prisma',
|
|
340
|
+
fields,
|
|
341
|
+
relations,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return entities;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Parse Prisma fields from model block
|
|
350
|
+
* @param {string} fieldsBlock
|
|
351
|
+
* @returns {{fields: DomainField[], relations: string[]}}
|
|
352
|
+
*/
|
|
353
|
+
function parsePrismaFields(fieldsBlock) {
|
|
354
|
+
const fields = [];
|
|
355
|
+
const relations = [];
|
|
356
|
+
|
|
357
|
+
const lines = fieldsBlock.split('\n');
|
|
358
|
+
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
const trimmed = line.trim();
|
|
361
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
|
|
362
|
+
|
|
363
|
+
// Match: fieldName Type? @relation(...) or fieldName Type[]
|
|
364
|
+
const fieldMatch = trimmed.match(/^(\w+)\s+(\w+)(\[\])?\??(.*)$/);
|
|
365
|
+
|
|
366
|
+
if (fieldMatch) {
|
|
367
|
+
const fieldName = fieldMatch[1];
|
|
368
|
+
const fieldType = fieldMatch[2];
|
|
369
|
+
const isArray = !!fieldMatch[3];
|
|
370
|
+
const modifiers = fieldMatch[4] || '';
|
|
371
|
+
|
|
372
|
+
// Skip internal Prisma fields
|
|
373
|
+
if (fieldName.startsWith('_')) continue;
|
|
374
|
+
|
|
375
|
+
// Check if it's a relation
|
|
376
|
+
if (modifiers.includes('@relation')) {
|
|
377
|
+
if (!relations.includes(fieldType)) {
|
|
378
|
+
relations.push(fieldType);
|
|
379
|
+
}
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Check if type is another model (relation without @relation)
|
|
384
|
+
const isPrimitiveType = [
|
|
385
|
+
'String',
|
|
386
|
+
'Int',
|
|
387
|
+
'Float',
|
|
388
|
+
'Boolean',
|
|
389
|
+
'DateTime',
|
|
390
|
+
'BigInt',
|
|
391
|
+
'Decimal',
|
|
392
|
+
'Json',
|
|
393
|
+
'Bytes',
|
|
394
|
+
].includes(fieldType);
|
|
395
|
+
|
|
396
|
+
if (!isPrimitiveType) {
|
|
397
|
+
if (!relations.includes(fieldType)) {
|
|
398
|
+
relations.push(fieldType);
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const isOptional = trimmed.includes('?');
|
|
404
|
+
|
|
405
|
+
fields.push({
|
|
406
|
+
name: fieldName,
|
|
407
|
+
type: fieldType.toLowerCase(),
|
|
408
|
+
optional: isOptional,
|
|
409
|
+
array: isArray,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return { fields, relations };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* ========================================================================= */
|
|
418
|
+
/* TypeScript type/interface parsing */
|
|
419
|
+
/* ========================================================================= */
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Parse TypeScript interfaces and types
|
|
423
|
+
* @param {string} content
|
|
424
|
+
* @param {string} fileName
|
|
425
|
+
* @returns {DomainEntity[]}
|
|
426
|
+
*/
|
|
427
|
+
function parseTypeScriptTypes(content, _fileName) {
|
|
428
|
+
const entities = [];
|
|
429
|
+
|
|
430
|
+
// Match interface declarations
|
|
431
|
+
const interfacePattern =
|
|
432
|
+
/(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+[\w,\s]+)?\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
433
|
+
|
|
434
|
+
let match;
|
|
435
|
+
while ((match = interfacePattern.exec(content)) !== null) {
|
|
436
|
+
const entityName = match[1];
|
|
437
|
+
const fieldsBlock = match[2];
|
|
438
|
+
|
|
439
|
+
// Skip common non-entity interfaces
|
|
440
|
+
if (
|
|
441
|
+
entityName.endsWith('Props') ||
|
|
442
|
+
entityName.endsWith('Options') ||
|
|
443
|
+
entityName.endsWith('Config')
|
|
444
|
+
) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const { fields, relations } = parseTypeScriptFields(fieldsBlock);
|
|
449
|
+
|
|
450
|
+
if (fields.length > 0) {
|
|
451
|
+
entities.push({
|
|
452
|
+
name: entityName,
|
|
453
|
+
source: 'typescript',
|
|
454
|
+
fields,
|
|
455
|
+
relations,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Match type declarations with object shape
|
|
461
|
+
const typePattern = /(?:export\s+)?type\s+(\w+)\s*=\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/g;
|
|
462
|
+
|
|
463
|
+
while ((match = typePattern.exec(content)) !== null) {
|
|
464
|
+
const entityName = match[1];
|
|
465
|
+
const fieldsBlock = match[2];
|
|
466
|
+
|
|
467
|
+
// Skip utility types
|
|
468
|
+
if (
|
|
469
|
+
entityName.endsWith('Props') ||
|
|
470
|
+
entityName.endsWith('Options') ||
|
|
471
|
+
entityName.endsWith('Config')
|
|
472
|
+
) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { fields, relations } = parseTypeScriptFields(fieldsBlock);
|
|
477
|
+
|
|
478
|
+
if (fields.length > 0) {
|
|
479
|
+
entities.push({
|
|
480
|
+
name: entityName,
|
|
481
|
+
source: 'typescript',
|
|
482
|
+
fields,
|
|
483
|
+
relations,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return entities;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Parse TypeScript fields from interface/type body
|
|
493
|
+
* @param {string} fieldsBlock
|
|
494
|
+
* @returns {{fields: DomainField[], relations: string[]}}
|
|
495
|
+
*/
|
|
496
|
+
function parseTypeScriptFields(fieldsBlock) {
|
|
497
|
+
const fields = [];
|
|
498
|
+
const relations = [];
|
|
499
|
+
|
|
500
|
+
// Match: fieldName: Type, fieldName?: Type, readonly fieldName: Type
|
|
501
|
+
const fieldPattern = /(?:readonly\s+)?(\w+)(\?)?:\s*([^;,\n]+)/g;
|
|
502
|
+
|
|
503
|
+
let match;
|
|
504
|
+
while ((match = fieldPattern.exec(fieldsBlock)) !== null) {
|
|
505
|
+
const fieldName = match[1];
|
|
506
|
+
const isOptional = !!match[2];
|
|
507
|
+
let rawType = match[3].trim();
|
|
508
|
+
|
|
509
|
+
// Check for array types
|
|
510
|
+
const isArray = rawType.endsWith('[]') || rawType.startsWith('Array<');
|
|
511
|
+
|
|
512
|
+
// Clean up the type
|
|
513
|
+
let baseType = rawType
|
|
514
|
+
.replace(/\[\]$/, '')
|
|
515
|
+
.replace(/^Array<(.+)>$/, '$1')
|
|
516
|
+
.replace(/\s*\|\s*null$/, '')
|
|
517
|
+
.replace(/\s*\|\s*undefined$/, '')
|
|
518
|
+
.trim();
|
|
519
|
+
|
|
520
|
+
// Check if it's a primitive type
|
|
521
|
+
const primitiveTypes = [
|
|
522
|
+
'string',
|
|
523
|
+
'number',
|
|
524
|
+
'boolean',
|
|
525
|
+
'Date',
|
|
526
|
+
'bigint',
|
|
527
|
+
'symbol',
|
|
528
|
+
'any',
|
|
529
|
+
'unknown',
|
|
530
|
+
'never',
|
|
531
|
+
'void',
|
|
532
|
+
];
|
|
533
|
+
const isPrimitive =
|
|
534
|
+
primitiveTypes.includes(baseType) || primitiveTypes.includes(baseType.toLowerCase());
|
|
535
|
+
|
|
536
|
+
if (
|
|
537
|
+
!isPrimitive &&
|
|
538
|
+
/^[A-Z]/.test(baseType) &&
|
|
539
|
+
!baseType.includes('|') &&
|
|
540
|
+
!baseType.includes('&')
|
|
541
|
+
) {
|
|
542
|
+
// Looks like a relation to another entity
|
|
543
|
+
if (!relations.includes(baseType)) {
|
|
544
|
+
relations.push(baseType);
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
fields.push({
|
|
550
|
+
name: fieldName,
|
|
551
|
+
type: baseType.toLowerCase(),
|
|
552
|
+
optional: isOptional,
|
|
553
|
+
array: isArray,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return { fields, relations };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/* ========================================================================= */
|
|
561
|
+
/* TypeORM entity parsing */
|
|
562
|
+
/* ========================================================================= */
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Parse TypeORM entity decorators
|
|
566
|
+
* @param {string} content
|
|
567
|
+
* @param {string} fileName
|
|
568
|
+
* @returns {DomainEntity[]}
|
|
569
|
+
*/
|
|
570
|
+
function parseTypeORMEntities(content, _fileName) {
|
|
571
|
+
const entities = [];
|
|
572
|
+
|
|
573
|
+
// Check if this looks like a TypeORM entity file
|
|
574
|
+
if (!content.includes('@Entity') && !content.includes('typeorm')) {
|
|
575
|
+
return entities;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Match @Entity() class declarations
|
|
579
|
+
const entityPattern =
|
|
580
|
+
/@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)(?:\s+extends\s+\w+)?\s*\{([^]*?)(?=\n\}|$)/g;
|
|
581
|
+
|
|
582
|
+
let match;
|
|
583
|
+
while ((match = entityPattern.exec(content)) !== null) {
|
|
584
|
+
const entityName = match[1];
|
|
585
|
+
const classBody = match[2];
|
|
586
|
+
|
|
587
|
+
const { fields, relations } = parseTypeORMFields(classBody);
|
|
588
|
+
|
|
589
|
+
entities.push({
|
|
590
|
+
name: entityName,
|
|
591
|
+
source: 'typeorm',
|
|
592
|
+
fields,
|
|
593
|
+
relations,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return entities;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Parse TypeORM fields from class body
|
|
602
|
+
* @param {string} classBody
|
|
603
|
+
* @returns {{fields: DomainField[], relations: string[]}}
|
|
604
|
+
*/
|
|
605
|
+
function parseTypeORMFields(classBody) {
|
|
606
|
+
const fields = [];
|
|
607
|
+
const relations = [];
|
|
608
|
+
|
|
609
|
+
// Match @Column() fieldName: Type
|
|
610
|
+
const columnPattern = /@Column\([^)]*\)\s*(\w+)(?:\?)?:\s*([^;\n]+)/g;
|
|
611
|
+
|
|
612
|
+
let match;
|
|
613
|
+
while ((match = columnPattern.exec(classBody)) !== null) {
|
|
614
|
+
const fieldName = match[1];
|
|
615
|
+
const fieldType = match[2].trim();
|
|
616
|
+
|
|
617
|
+
fields.push({
|
|
618
|
+
name: fieldName,
|
|
619
|
+
type: fieldType.toLowerCase(),
|
|
620
|
+
optional: classBody.includes(`${fieldName}?:`),
|
|
621
|
+
array: fieldType.endsWith('[]'),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Match relation decorators
|
|
626
|
+
const relationPattern =
|
|
627
|
+
/@(?:OneToMany|ManyToOne|OneToOne|ManyToMany)\([^)]*\)\s*\w+(?:\?)?:\s*(\w+)/g;
|
|
628
|
+
|
|
629
|
+
while ((match = relationPattern.exec(classBody)) !== null) {
|
|
630
|
+
const relatedType = match[1];
|
|
631
|
+
if (!relations.includes(relatedType)) {
|
|
632
|
+
relations.push(relatedType);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return { fields, relations };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/* ========================================================================= */
|
|
640
|
+
/* File discovery */
|
|
641
|
+
/* ========================================================================= */
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Find schema/type files in the project
|
|
645
|
+
* @param {Set<string>} filePaths
|
|
646
|
+
* @param {z.infer<typeof StackProfileSchema>} stackProfile
|
|
647
|
+
* @returns {string[]}
|
|
648
|
+
*/
|
|
649
|
+
function findSchemaFiles(filePaths, stackProfile) {
|
|
650
|
+
const schemaFiles = [];
|
|
651
|
+
|
|
652
|
+
for (const filePath of filePaths) {
|
|
653
|
+
// Skip node_modules, dist, etc.
|
|
654
|
+
if (
|
|
655
|
+
filePath.includes('node_modules') ||
|
|
656
|
+
filePath.includes('/dist/') ||
|
|
657
|
+
filePath.includes('/build/')
|
|
658
|
+
) {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const ext = path.extname(filePath);
|
|
663
|
+
const basename = path.basename(filePath);
|
|
664
|
+
|
|
665
|
+
// Check for Zod schema files
|
|
666
|
+
if (stackProfile.hasZod) {
|
|
667
|
+
if (
|
|
668
|
+
filePath.includes('/schemas/') ||
|
|
669
|
+
filePath.includes('/schema/') ||
|
|
670
|
+
(filePath.includes('/types/') && (ext === '.ts' || ext === '.mjs')) ||
|
|
671
|
+
basename.includes('schema') ||
|
|
672
|
+
basename.includes('validation')
|
|
673
|
+
) {
|
|
674
|
+
schemaFiles.push(filePath);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Check for TypeScript type files
|
|
679
|
+
if (stackProfile.hasTypescript) {
|
|
680
|
+
if (
|
|
681
|
+
filePath.includes('/types/') ||
|
|
682
|
+
filePath.includes('/interfaces/') ||
|
|
683
|
+
filePath.includes('/models/') ||
|
|
684
|
+
basename.endsWith('.d.ts') ||
|
|
685
|
+
basename.includes('.types.')
|
|
686
|
+
) {
|
|
687
|
+
schemaFiles.push(filePath);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Check for TypeORM entity files
|
|
692
|
+
if (stackProfile.hasTypeORM) {
|
|
693
|
+
if (
|
|
694
|
+
filePath.includes('/entities/') ||
|
|
695
|
+
filePath.includes('/entity/') ||
|
|
696
|
+
basename.includes('.entity.')
|
|
697
|
+
) {
|
|
698
|
+
schemaFiles.push(filePath);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Check for Prisma schema
|
|
703
|
+
if (stackProfile.hasPrisma) {
|
|
704
|
+
if (basename === 'schema.prisma') {
|
|
705
|
+
schemaFiles.push(filePath);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Dedupe
|
|
711
|
+
return [...new Set(schemaFiles)];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* ========================================================================= */
|
|
715
|
+
/* Store building */
|
|
716
|
+
/* ========================================================================= */
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Add entity to RDF store
|
|
720
|
+
* @param {Store} store
|
|
721
|
+
* @param {DomainEntity} entity
|
|
722
|
+
* @param {string} baseIri
|
|
723
|
+
*/
|
|
724
|
+
function addEntityToStore(store, entity, baseIri) {
|
|
725
|
+
const entityIri = namedNode(`${baseIri}${entity.name}`);
|
|
726
|
+
|
|
727
|
+
// Add entity type
|
|
728
|
+
store.addQuad(entityIri, namedNode(`${NS.rdf}type`), namedNode(`${NS.dom}Entity`));
|
|
729
|
+
|
|
730
|
+
// Add label
|
|
731
|
+
store.addQuad(entityIri, namedNode(`${NS.rdfs}label`), literal(entity.name));
|
|
732
|
+
|
|
733
|
+
// Add source
|
|
734
|
+
store.addQuad(entityIri, namedNode(`${NS.dom}source`), literal(entity.source));
|
|
735
|
+
|
|
736
|
+
// Add fields
|
|
737
|
+
for (const field of entity.fields) {
|
|
738
|
+
const fieldIri = namedNode(`${baseIri}${entity.name}.${field.name}`);
|
|
739
|
+
|
|
740
|
+
// Link entity to field
|
|
741
|
+
store.addQuad(entityIri, namedNode(`${NS.dom}hasField`), fieldIri);
|
|
742
|
+
|
|
743
|
+
// Add field type
|
|
744
|
+
store.addQuad(fieldIri, namedNode(`${NS.rdf}type`), namedNode(`${NS.dom}Field`));
|
|
745
|
+
|
|
746
|
+
// Add field name
|
|
747
|
+
store.addQuad(fieldIri, namedNode(`${NS.dom}fieldName`), literal(field.name));
|
|
748
|
+
|
|
749
|
+
// Add field type (XSD)
|
|
750
|
+
store.addQuad(fieldIri, namedNode(`${NS.dom}fieldType`), namedNode(mapToXsdType(field.type)));
|
|
751
|
+
|
|
752
|
+
// Add optional flag
|
|
753
|
+
store.addQuad(
|
|
754
|
+
fieldIri,
|
|
755
|
+
namedNode(`${NS.dom}isOptional`),
|
|
756
|
+
literal(field.optional, namedNode(`${NS.xsd}boolean`))
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
// Add array flag
|
|
760
|
+
store.addQuad(
|
|
761
|
+
fieldIri,
|
|
762
|
+
namedNode(`${NS.dom}isArray`),
|
|
763
|
+
literal(field.array, namedNode(`${NS.xsd}boolean`))
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Add relations
|
|
768
|
+
for (const relation of entity.relations) {
|
|
769
|
+
const relationIri = namedNode(`${baseIri}${relation}`);
|
|
770
|
+
|
|
771
|
+
store.addQuad(entityIri, namedNode(`${NS.dom}relatesTo`), relationIri);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* ========================================================================= */
|
|
776
|
+
/* Main API */
|
|
777
|
+
/* ========================================================================= */
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Infer domain model from filesystem store
|
|
781
|
+
*
|
|
782
|
+
* @param {Object} options
|
|
783
|
+
* @param {Store} options.fsStore - FS store from scanFileSystemToStore
|
|
784
|
+
* @param {Object} [options.stackProfile] - Pre-computed stack profile
|
|
785
|
+
* @param {string} [options.baseIri] - Base IRI for domain resources
|
|
786
|
+
* @param {string} [options.projectRoot] - Project root path for reading files
|
|
787
|
+
* @returns {Promise<DomainInferResult>}
|
|
788
|
+
*/
|
|
789
|
+
export async function inferDomainModel(options) {
|
|
790
|
+
const validated = InferOptionsSchema.parse(options);
|
|
791
|
+
const { fsStore, baseIri, projectRoot } = validated;
|
|
792
|
+
|
|
793
|
+
// Detect or use provided stack profile
|
|
794
|
+
const stackProfile = validated.stackProfile || (await detectStackProfile(fsStore, projectRoot));
|
|
795
|
+
|
|
796
|
+
const store = createStore();
|
|
797
|
+
const allEntities = [];
|
|
798
|
+
|
|
799
|
+
// Get file paths from fsStore
|
|
800
|
+
const filePaths = extractFilePaths(fsStore);
|
|
801
|
+
|
|
802
|
+
// Find schema files
|
|
803
|
+
const schemaFiles = findSchemaFiles(filePaths, stackProfile);
|
|
804
|
+
|
|
805
|
+
// Process each schema file
|
|
806
|
+
for (const relativePath of schemaFiles) {
|
|
807
|
+
if (!projectRoot) continue;
|
|
808
|
+
|
|
809
|
+
const fullPath = path.join(projectRoot, relativePath);
|
|
810
|
+
const content = await readFileContent(fullPath);
|
|
811
|
+
|
|
812
|
+
if (!content) continue;
|
|
813
|
+
|
|
814
|
+
const ext = path.extname(relativePath);
|
|
815
|
+
|
|
816
|
+
// Parse based on file type and stack
|
|
817
|
+
if (relativePath.endsWith('.prisma')) {
|
|
818
|
+
const entities = parsePrismaSchema(content);
|
|
819
|
+
allEntities.push(...entities);
|
|
820
|
+
} else if (ext === '.ts' || ext === '.tsx') {
|
|
821
|
+
// Check for Zod schemas first
|
|
822
|
+
if (
|
|
823
|
+
stackProfile.hasZod &&
|
|
824
|
+
(content.includes("from 'zod'") || content.includes('from "zod"'))
|
|
825
|
+
) {
|
|
826
|
+
const zodEntities = parseZodSchemas(content, relativePath);
|
|
827
|
+
allEntities.push(...zodEntities);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Check for TypeORM entities
|
|
831
|
+
if (stackProfile.hasTypeORM && content.includes('@Entity')) {
|
|
832
|
+
const typeormEntities = parseTypeORMEntities(content, relativePath);
|
|
833
|
+
allEntities.push(...typeormEntities);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Parse TypeScript types/interfaces
|
|
837
|
+
if (stackProfile.hasTypescript) {
|
|
838
|
+
const tsEntities = parseTypeScriptTypes(content, relativePath);
|
|
839
|
+
allEntities.push(...tsEntities);
|
|
840
|
+
}
|
|
841
|
+
} else if (ext === '.mjs' || ext === '.js') {
|
|
842
|
+
// Check for Zod schemas in JS/MJS files
|
|
843
|
+
if (
|
|
844
|
+
stackProfile.hasZod &&
|
|
845
|
+
(content.includes("from 'zod'") || content.includes('from "zod"'))
|
|
846
|
+
) {
|
|
847
|
+
const zodEntities = parseZodSchemas(content, relativePath);
|
|
848
|
+
allEntities.push(...zodEntities);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Deduplicate entities by name (prefer richer sources)
|
|
854
|
+
const entityMap = new Map();
|
|
855
|
+
const sourcePriority = { prisma: 4, typeorm: 3, zod: 2, typescript: 1 };
|
|
856
|
+
|
|
857
|
+
for (const entity of allEntities) {
|
|
858
|
+
const existing = entityMap.get(entity.name);
|
|
859
|
+
if (
|
|
860
|
+
!existing ||
|
|
861
|
+
(sourcePriority[entity.source] || 0) > (sourcePriority[existing.source] || 0)
|
|
862
|
+
) {
|
|
863
|
+
entityMap.set(entity.name, entity);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Add entities to store
|
|
868
|
+
let fieldCount = 0;
|
|
869
|
+
let relationshipCount = 0;
|
|
870
|
+
|
|
871
|
+
for (const entity of entityMap.values()) {
|
|
872
|
+
addEntityToStore(store, entity, baseIri);
|
|
873
|
+
fieldCount += entity.fields.length;
|
|
874
|
+
relationshipCount += entity.relations.length;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return {
|
|
878
|
+
store,
|
|
879
|
+
summary: {
|
|
880
|
+
entityCount: entityMap.size,
|
|
881
|
+
fieldCount,
|
|
882
|
+
relationshipCount,
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Convenience function: infer from project path
|
|
889
|
+
*
|
|
890
|
+
* @param {string} projectRoot - Path to project root
|
|
891
|
+
* @param {Object} [options]
|
|
892
|
+
* @param {string} [options.baseIri] - Base IRI for domain resources
|
|
893
|
+
* @returns {Promise<DomainInferResult>}
|
|
894
|
+
*/
|
|
895
|
+
export async function inferDomainModelFromPath(projectRoot, options = {}) {
|
|
896
|
+
// Import fs-scan dynamically to avoid circular deps
|
|
897
|
+
const { scanFileSystemToStore } = await import('./fs-scan.mjs');
|
|
898
|
+
|
|
899
|
+
const { store: fsStore } = await scanFileSystemToStore({ root: projectRoot });
|
|
900
|
+
|
|
901
|
+
return inferDomainModel({
|
|
902
|
+
fsStore,
|
|
903
|
+
projectRoot,
|
|
904
|
+
baseIri: options.baseIri,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* ========================================================================= */
|
|
909
|
+
/* Domain Model Lens */
|
|
910
|
+
/* ========================================================================= */
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Ontology lens for domain model changes
|
|
914
|
+
*
|
|
915
|
+
* @param {import('../diff.mjs').DiffTriple} triple
|
|
916
|
+
* @param {'added' | 'removed'} direction
|
|
917
|
+
* @returns {import('../diff.mjs').OntologyChange | null}
|
|
918
|
+
*/
|
|
919
|
+
export function DomainModelLens(triple, direction) {
|
|
920
|
+
const { subject, predicate, object } = triple;
|
|
921
|
+
|
|
922
|
+
// Entity added/removed
|
|
923
|
+
if (predicate === `${NS.rdf}type` && object === `${NS.dom}Entity`) {
|
|
924
|
+
const entityName = subject.split('#').pop() || subject.split('/').pop();
|
|
925
|
+
return {
|
|
926
|
+
kind: direction === 'added' ? 'EntityAdded' : 'EntityRemoved',
|
|
927
|
+
entity: subject,
|
|
928
|
+
details: { name: entityName },
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Field added/removed
|
|
933
|
+
if (predicate === `${NS.dom}hasField`) {
|
|
934
|
+
const entityName = subject.split('#').pop() || subject.split('/').pop();
|
|
935
|
+
const fieldName = object.split('.').pop();
|
|
936
|
+
return {
|
|
937
|
+
kind: direction === 'added' ? 'FieldAdded' : 'FieldRemoved',
|
|
938
|
+
entity: subject,
|
|
939
|
+
role: fieldName,
|
|
940
|
+
details: { entityName, fieldName },
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Relation added/removed
|
|
945
|
+
if (predicate === `${NS.dom}relatesTo`) {
|
|
946
|
+
const fromEntity = subject.split('#').pop() || subject.split('/').pop();
|
|
947
|
+
const toEntity = object.split('#').pop() || object.split('/').pop();
|
|
948
|
+
return {
|
|
949
|
+
kind: direction === 'added' ? 'RelationAdded' : 'RelationRemoved',
|
|
950
|
+
entity: subject,
|
|
951
|
+
details: { from: fromEntity, to: toEntity },
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Field type changed (detected as removed + added)
|
|
956
|
+
if (predicate === `${NS.dom}fieldType`) {
|
|
957
|
+
const fieldPath = subject.split('#').pop() || subject.split('/').pop();
|
|
958
|
+
return {
|
|
959
|
+
kind: direction === 'added' ? 'FieldTypeSet' : 'FieldTypeUnset',
|
|
960
|
+
entity: subject,
|
|
961
|
+
details: { field: fieldPath, type: object },
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return null;
|
|
966
|
+
}
|