@graph-ir/core 0.2.0 → 0.2.2
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 +14 -2
- package/dist/canonical-types-2.d.ts +207 -0
- package/dist/canonical-types-2.d.ts.map +1 -0
- package/dist/canonical-types-2.js +3 -0
- package/dist/canonical-types-2.js.map +1 -0
- package/dist/canonical-types.d.ts +332 -0
- package/dist/canonical-types.d.ts.map +1 -0
- package/dist/canonical-types.js +3 -0
- package/dist/canonical-types.js.map +1 -0
- package/dist/generation/id-generator.d.ts.map +1 -1
- package/dist/generation/id-generator.js +16 -10
- package/dist/generation/id-generator.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/output/types.d.ts.map +1 -1
- package/dist/schemas/graph-ir-2.0.schema.json +416 -0
- package/dist/schemas/graph-ir.schema.json +1684 -228
- package/dist/skills/diff.d.ts +4 -0
- package/dist/skills/diff.d.ts.map +1 -1
- package/dist/skills/diff.js +93 -11
- package/dist/skills/diff.js.map +1 -1
- package/dist/skills/migrate.d.ts +48 -19
- package/dist/skills/migrate.d.ts.map +1 -1
- package/dist/skills/migrate.js +482 -239
- package/dist/skills/migrate.js.map +1 -1
- package/dist/skills/visualize.d.ts.map +1 -1
- package/dist/skills/visualize.js +33 -5
- package/dist/skills/visualize.js.map +1 -1
- package/dist/testing/diff.d.ts +1 -1
- package/dist/testing/diff.d.ts.map +1 -1
- package/dist/testing/diff.js +52 -19
- package/dist/testing/diff.js.map +1 -1
- package/dist/types.d.ts +13 -214
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -2
- package/dist/types.js.map +1 -1
- package/dist/validation/ir-validator.d.ts +35 -44
- package/dist/validation/ir-validator.d.ts.map +1 -1
- package/dist/validation/ir-validator.js +735 -288
- package/dist/validation/ir-validator.js.map +1 -1
- package/dist/validation/validator.d.ts.map +1 -1
- package/package.json +9 -4
- package/specification.json +13 -0
|
@@ -1,346 +1,793 @@
|
|
|
1
|
-
|
|
2
|
-
* Graph-IR Validator
|
|
3
|
-
*
|
|
4
|
-
* Validates Graph-IR against schema and semantic rules:
|
|
5
|
-
* 1. JSON Schema validation (structure)
|
|
6
|
-
* 2. ID uniqueness
|
|
7
|
-
* 3. Reference validity (edges -> nodes, ports)
|
|
8
|
-
* 4. Cycle detection in hierarchy
|
|
9
|
-
* 5. Semantic warnings (orphans, self-loops)
|
|
10
|
-
*/
|
|
11
|
-
import { Ajv } from 'ajv';
|
|
12
|
-
// Import schema - will be loaded at runtime
|
|
1
|
+
import Ajv2020Import from 'ajv/dist/2020.js';
|
|
13
2
|
import graphIRSchema from '../schemas/graph-ir.schema.json' with { type: 'json' };
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
3
|
+
const retiredFields = new Set(['routingPoints', 'algorithmOptions', 'shape']);
|
|
4
|
+
const Ajv2020 = Ajv2020Import.default ??
|
|
5
|
+
Ajv2020Import;
|
|
17
6
|
export class IRValidator {
|
|
18
7
|
options;
|
|
19
|
-
ajv;
|
|
20
8
|
schemaValidator;
|
|
21
9
|
constructor(options = {}) {
|
|
22
10
|
this.options = options;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
addDateTimeFormat() {
|
|
29
|
-
this.ajv.addFormat('date-time', {
|
|
30
|
-
type: 'string',
|
|
31
|
-
validate: (dateTimeString) => {
|
|
32
|
-
// ISO 8601 date-time format
|
|
33
|
-
const dateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
|
|
34
|
-
return dateTimeRegex.test(dateTimeString);
|
|
35
|
-
},
|
|
11
|
+
const ajv = new Ajv2020({
|
|
12
|
+
allErrors: true,
|
|
13
|
+
strict: false,
|
|
14
|
+
strictNumbers: true,
|
|
15
|
+
validateFormats: false,
|
|
36
16
|
});
|
|
17
|
+
this.schemaValidator = ajv.compile(graphIRSchema);
|
|
37
18
|
}
|
|
38
|
-
/**
|
|
39
|
-
* Validate from JSON string
|
|
40
|
-
*/
|
|
41
19
|
validateFromString(jsonString) {
|
|
42
|
-
|
|
43
|
-
let ir;
|
|
20
|
+
let value;
|
|
44
21
|
try {
|
|
45
|
-
|
|
22
|
+
value = JSON.parse(jsonString);
|
|
46
23
|
}
|
|
47
|
-
catch (
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
code: 'INVALID_JSON',
|
|
53
|
-
path: '$',
|
|
54
|
-
message: `Invalid JSON: ${e instanceof Error ? e.message : 'Parse error'}`,
|
|
55
|
-
severity: 'error',
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
warnings: [],
|
|
59
|
-
stats: { nodes: 0, edges: 0 },
|
|
60
|
-
};
|
|
24
|
+
catch (error) {
|
|
25
|
+
return this.result([
|
|
26
|
+
this.error('INVALID_JSON', '', `Invalid JSON: ${error instanceof Error ? error.message : 'Parse error'}`),
|
|
27
|
+
]);
|
|
61
28
|
}
|
|
62
|
-
return this.validate(
|
|
29
|
+
return this.validate(value);
|
|
63
30
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
31
|
+
validate(value) {
|
|
32
|
+
const mode = this.resolveMode();
|
|
33
|
+
if (typeof mode !== 'string')
|
|
34
|
+
return this.result([mode]);
|
|
68
35
|
const errors = [];
|
|
69
36
|
const warnings = [];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
37
|
+
const graph = this.isGraphShape(value) ? value : undefined;
|
|
38
|
+
const hierarchyCycle = graph ? this.findHierarchyCycle(graph.nodes) : undefined;
|
|
39
|
+
if (hierarchyCycle) {
|
|
40
|
+
return this.result([
|
|
41
|
+
this.error('CYCLIC_HIERARCHY', hierarchyCycle, 'Node children must form an acyclic containment tree'),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
if (mode !== 'semantic') {
|
|
45
|
+
this.schemaValidator(value);
|
|
46
|
+
errors.push(...this.normalizeSchemaErrors(this.schemaValidator.errors ?? []));
|
|
47
|
+
}
|
|
48
|
+
if (mode !== 'schema' && graph) {
|
|
49
|
+
this.validateSemantic(graph, errors, warnings);
|
|
50
|
+
}
|
|
51
|
+
const uniqueErrors = this.dedupe(errors);
|
|
52
|
+
const uniqueWarnings = this.dedupe(warnings);
|
|
53
|
+
const finalErrors = this.options.strict
|
|
54
|
+
? [...uniqueErrors, ...uniqueWarnings]
|
|
55
|
+
: uniqueErrors;
|
|
56
|
+
const finalWarnings = this.options.strict ? [] : uniqueWarnings;
|
|
57
|
+
return {
|
|
58
|
+
valid: finalErrors.length === 0,
|
|
59
|
+
errors: finalErrors,
|
|
60
|
+
warnings: finalWarnings,
|
|
61
|
+
stats: {
|
|
62
|
+
nodes: graph ? this.collectNodes(graph.nodes).length : 0,
|
|
63
|
+
edges: graph?.edges.length ?? 0,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
resolveMode() {
|
|
68
|
+
const hasSkipSchema = Object.hasOwn(this.options, 'skipSchema');
|
|
69
|
+
const hasSkipSemantic = Object.hasOwn(this.options, 'skipSemantic');
|
|
70
|
+
if (this.options.mode && (hasSkipSchema || hasSkipSemantic)) {
|
|
71
|
+
return this.error('INVALID_VALIDATION_OPTIONS', '', 'Explicit mode cannot be combined with legacy skip flags');
|
|
72
|
+
}
|
|
73
|
+
if (this.options.skipSchema && this.options.skipSemantic) {
|
|
74
|
+
return this.error('INVALID_VALIDATION_OPTIONS', '', 'skipSchema and skipSemantic cannot both be true');
|
|
75
|
+
}
|
|
76
|
+
if (this.options.mode)
|
|
77
|
+
return this.options.mode;
|
|
78
|
+
if (this.options.skipSchema)
|
|
79
|
+
return 'semantic';
|
|
80
|
+
if (this.options.skipSemantic)
|
|
81
|
+
return 'schema';
|
|
82
|
+
return 'full';
|
|
83
|
+
}
|
|
84
|
+
normalizeSchemaErrors(schemaErrors) {
|
|
85
|
+
const errors = [];
|
|
86
|
+
for (const schemaError of schemaErrors) {
|
|
87
|
+
let path = schemaError.instancePath;
|
|
88
|
+
let code = 'SCHEMA_VIOLATION';
|
|
89
|
+
if (schemaError.keyword === 'required') {
|
|
90
|
+
const missing = String(schemaError.params.missingProperty);
|
|
91
|
+
path = this.joinPointer(path, missing);
|
|
92
|
+
if (path === '/version')
|
|
93
|
+
code = 'UNSUPPORTED_SCHEMA_VERSION';
|
|
94
|
+
if (missing === 'position' && this.pathHasPinnedNode(path)) {
|
|
95
|
+
code = 'PIN_REQUIRES_POSITION';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (schemaError.keyword === 'additionalProperties') {
|
|
99
|
+
const property = String(schemaError.params.additionalProperty);
|
|
100
|
+
path = this.joinPointer(path, property);
|
|
101
|
+
if (retiredFields.has(property))
|
|
102
|
+
code = 'RETIRED_FIELD';
|
|
103
|
+
}
|
|
104
|
+
else if (path === '/version' && schemaError.keyword === 'const') {
|
|
105
|
+
code = 'UNSUPPORTED_SCHEMA_VERSION';
|
|
106
|
+
}
|
|
107
|
+
else if (path.endsWith('/labelPlacement/offset') ||
|
|
108
|
+
path.endsWith('/labelPlacement/at')) {
|
|
109
|
+
code = 'INVALID_LABEL_PLACEMENT';
|
|
81
110
|
}
|
|
111
|
+
else if (/^\/constraints\/\d+\/subjects$/.test(path) &&
|
|
112
|
+
(schemaError.keyword === 'minItems' || schemaError.keyword === 'uniqueItems')) {
|
|
113
|
+
code = 'INVALID_ARRANGEMENT';
|
|
114
|
+
}
|
|
115
|
+
errors.push(this.error(code, path, schemaError.message ?? 'Schema violation'));
|
|
82
116
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
117
|
+
return errors;
|
|
118
|
+
}
|
|
119
|
+
pathHasPinnedNode(positionPath) {
|
|
120
|
+
return /^\/nodes(?:\/\d+\/children)*\/\d+\/position$/.test(positionPath);
|
|
121
|
+
}
|
|
122
|
+
validateSemantic(graph, errors, warnings) {
|
|
123
|
+
const entries = this.collectNodes(graph.nodes);
|
|
124
|
+
const nodeById = new Map();
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (typeof entry.node.id === 'string') {
|
|
127
|
+
const existing = nodeById.get(entry.node.id);
|
|
128
|
+
if (existing) {
|
|
129
|
+
errors.push(this.error('DUPLICATE_NODE_ID', `${entry.path}/id`, `Duplicate node ID: "${entry.node.id}"`));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
nodeById.set(entry.node.id, entry);
|
|
133
|
+
}
|
|
97
134
|
}
|
|
98
|
-
|
|
99
|
-
|
|
135
|
+
if (entry.parentId === undefined && entry.node.parent !== undefined) {
|
|
136
|
+
errors.push(this.error('INVALID_ROOT_PARENT', `${entry.path}/parent`, `Root node "${entry.node.id}" must not declare parent`));
|
|
100
137
|
}
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
else if (entry.parentId !== undefined &&
|
|
139
|
+
entry.node.parent !== undefined &&
|
|
140
|
+
entry.node.parent !== entry.parentId) {
|
|
141
|
+
errors.push(this.error('INVALID_PARENT_REFERENCE', `${entry.path}/parent`, `Node "${entry.node.id}" parent must be "${entry.parentId}"`));
|
|
142
|
+
}
|
|
143
|
+
const ports = new Set();
|
|
144
|
+
const nodePorts = Array.isArray(entry.node.ports) ? entry.node.ports : [];
|
|
145
|
+
nodePorts.forEach((port, index) => {
|
|
146
|
+
if (!port || typeof port !== 'object')
|
|
147
|
+
return;
|
|
148
|
+
if (ports.has(port.id)) {
|
|
149
|
+
errors.push(this.error('DUPLICATE_PORT_ID', `${entry.path}/ports/${index}/id`, `Duplicate port ID "${port.id}" on node "${entry.node.id}"`));
|
|
150
|
+
}
|
|
151
|
+
ports.add(port.id);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
103
154
|
const edgeIds = new Set();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
155
|
+
graph.edges.forEach((edge, index) => {
|
|
156
|
+
if (!edge || typeof edge !== 'object')
|
|
157
|
+
return;
|
|
158
|
+
const path = `/edges/${index}`;
|
|
107
159
|
if (edgeIds.has(edge.id)) {
|
|
108
|
-
errors.push({
|
|
109
|
-
code: 'DUPLICATE_EDGE_ID',
|
|
110
|
-
path: `${path}.id`,
|
|
111
|
-
message: `Duplicate edge ID: "${edge.id}"`,
|
|
112
|
-
severity: 'error',
|
|
113
|
-
});
|
|
160
|
+
errors.push(this.error('DUPLICATE_EDGE_ID', `${path}/id`, `Duplicate edge ID: "${edge.id}"`));
|
|
114
161
|
}
|
|
115
162
|
edgeIds.add(edge.id);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
163
|
+
this.validateEdge(edge, path, nodeById, errors);
|
|
164
|
+
});
|
|
165
|
+
this.validatePortableSemantics(graph, entries, nodeById, errors);
|
|
166
|
+
const constraints = Array.isArray(graph.constraints) ? graph.constraints : [];
|
|
167
|
+
this.validateConstraints(constraints, nodeById, errors);
|
|
168
|
+
this.validateDirectionalContradictions(constraints, nodeById, errors);
|
|
169
|
+
this.addWarnings(graph, entries, warnings);
|
|
170
|
+
}
|
|
171
|
+
validatePortableSemantics(graph, entries, nodeById, errors) {
|
|
172
|
+
const record = graph;
|
|
173
|
+
const profile = this.asRecord(record.profile);
|
|
174
|
+
const metadata = this.asRecord(record.metadata);
|
|
175
|
+
if (profile) {
|
|
176
|
+
const expectedNotation = profile.kind === 'class' ? 'uml-class'
|
|
177
|
+
: profile.kind === 'sequence' ? 'uml-sequence' : undefined;
|
|
178
|
+
const recognizedNotation = metadata?.notation === 'uml-class' ||
|
|
179
|
+
metadata?.notation === 'uml-sequence';
|
|
180
|
+
if (expectedNotation && recognizedNotation && metadata?.notation !== expectedNotation) {
|
|
181
|
+
errors.push(this.error('PROFILE_CONTRADICTION', '/metadata/notation', `Notation ${metadata.notation} contradicts profile ${profile.kind}`));
|
|
182
|
+
}
|
|
183
|
+
const custom = this.asRecord(metadata?.custom);
|
|
184
|
+
const expectedDiagramType = profile.kind === 'class' ? 'class'
|
|
185
|
+
: profile.kind === 'sequence' ? 'sequence' : undefined;
|
|
186
|
+
const recognizedDiagramType = custom?.diagramType === 'class' ||
|
|
187
|
+
custom?.diagramType === 'sequence';
|
|
188
|
+
if (expectedDiagramType && recognizedDiagramType &&
|
|
189
|
+
custom?.diagramType !== expectedDiagramType) {
|
|
190
|
+
errors.push(this.error('PROFILE_CONTRADICTION', '/metadata/custom/diagramType', `Diagram type ${custom.diagramType} contradicts profile ${profile.kind}`));
|
|
126
191
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
192
|
+
}
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
const classifier = this.asRecord(entry.node.classifier);
|
|
195
|
+
if (!classifier)
|
|
196
|
+
continue;
|
|
197
|
+
if (profile?.kind !== 'class') {
|
|
198
|
+
errors.push(this.error('PROFILE_CONTRADICTION', `${entry.path}/classifier`, 'Classifier records require the UML class profile'));
|
|
199
|
+
}
|
|
200
|
+
const compartmentIds = new Set();
|
|
201
|
+
const itemIds = new Set();
|
|
202
|
+
this.asArray(classifier.compartments).forEach((value, compartmentIndex) => {
|
|
203
|
+
const compartment = this.asRecord(value);
|
|
204
|
+
if (!compartment)
|
|
205
|
+
return;
|
|
206
|
+
if (typeof compartment.id === 'string') {
|
|
207
|
+
if (compartmentIds.has(compartment.id)) {
|
|
208
|
+
errors.push(this.error('DUPLICATE_CLASSIFIER_ID', `${entry.path}/classifier/compartments/${compartmentIndex}/id`, `Duplicate classifier compartment ID ${compartment.id}`));
|
|
209
|
+
}
|
|
210
|
+
compartmentIds.add(compartment.id);
|
|
211
|
+
}
|
|
212
|
+
this.asArray(compartment.items).forEach((itemValue, itemIndex) => {
|
|
213
|
+
const item = this.asRecord(itemValue);
|
|
214
|
+
if (!item || typeof item.id !== 'string')
|
|
215
|
+
return;
|
|
216
|
+
if (itemIds.has(item.id)) {
|
|
217
|
+
errors.push(this.error('DUPLICATE_CLASSIFIER_ID', `${entry.path}/classifier/compartments/${compartmentIndex}/items/${itemIndex}/id`, `Duplicate classifier item ID ${item.id}`));
|
|
218
|
+
}
|
|
219
|
+
itemIds.add(item.id);
|
|
136
220
|
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
graph.edges.forEach((edge, edgeIndex) => {
|
|
224
|
+
const edgeRecord = edge;
|
|
225
|
+
for (const endName of ['sourceEnd', 'targetEnd']) {
|
|
226
|
+
const end = this.asRecord(edgeRecord[endName]);
|
|
227
|
+
const multiplicity = this.asRecord(end?.multiplicity);
|
|
228
|
+
const marker = this.asRecord(end?.marker);
|
|
229
|
+
const base = `/edges/${edgeIndex}/${endName}`;
|
|
230
|
+
if (multiplicity && typeof multiplicity.lower === 'number' &&
|
|
231
|
+
typeof multiplicity.upper === 'number' &&
|
|
232
|
+
multiplicity.upper < multiplicity.lower) {
|
|
233
|
+
errors.push(this.error('INVALID_MULTIPLICITY_BOUNDS', `${base}/multiplicity/upper`, 'Multiplicity upper bound must not be less than lower bound'));
|
|
234
|
+
}
|
|
235
|
+
const bounds = {
|
|
236
|
+
'zero-or-one': [0, 1], one: [1, 1],
|
|
237
|
+
'zero-or-many': [0, '*'], 'one-or-many': [1, '*'],
|
|
238
|
+
};
|
|
239
|
+
if (marker && typeof marker.kind === 'string' && multiplicity &&
|
|
240
|
+
bounds[marker.kind]) {
|
|
241
|
+
const [lower, upper] = bounds[marker.kind];
|
|
242
|
+
if (multiplicity.lower !== lower || multiplicity.upper !== upper) {
|
|
243
|
+
errors.push(this.error('MARKER_MULTIPLICITY_MISMATCH', `${base}/marker/kind`, `Marker ${marker.kind} contradicts the declared multiplicity`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
137
246
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
247
|
+
});
|
|
248
|
+
const interaction = this.asRecord(record.interaction);
|
|
249
|
+
if (!interaction)
|
|
250
|
+
return;
|
|
251
|
+
if (profile?.kind !== 'sequence') {
|
|
252
|
+
errors.push(this.error('PROFILE_CONTRADICTION', '/interaction', 'Interaction records require the UML sequence profile'));
|
|
253
|
+
}
|
|
254
|
+
this.validateInteraction(interaction, nodeById, graph, errors);
|
|
255
|
+
}
|
|
256
|
+
validateInteraction(interaction, nodeById, graph, errors) {
|
|
257
|
+
const participants = this.asArray(interaction.participants);
|
|
258
|
+
const messages = this.asArray(interaction.messages);
|
|
259
|
+
const activations = this.asArray(interaction.activations);
|
|
260
|
+
const fragments = this.asArray(interaction.fragments);
|
|
261
|
+
const notes = this.asArray(interaction.notes);
|
|
262
|
+
const participantById = this.indexEntities(participants, '/interaction/participants', 'participant', errors);
|
|
263
|
+
const messageById = this.indexEntities(messages, '/interaction/messages', 'message', errors);
|
|
264
|
+
const edgeById = new Map(graph.edges
|
|
265
|
+
.filter((edge) => edge && typeof edge === 'object' && typeof edge.id === 'string')
|
|
266
|
+
.map((edge) => [edge.id, edge]));
|
|
267
|
+
participants.forEach((value, index) => {
|
|
268
|
+
const participant = this.asRecord(value);
|
|
269
|
+
if (!participant || typeof participant.nodeId !== 'string')
|
|
270
|
+
return;
|
|
271
|
+
if (!nodeById.has(participant.nodeId)) {
|
|
272
|
+
errors.push(this.error('INVALID_INTERACTION_REFERENCE', `/interaction/participants/${index}/nodeId`, `Missing participant node ${participant.nodeId}`));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
messages.forEach((value, index) => {
|
|
276
|
+
const message = this.asRecord(value);
|
|
277
|
+
if (!message)
|
|
278
|
+
return;
|
|
279
|
+
const path = `/interaction/messages/${index}`;
|
|
280
|
+
const edge = typeof message.edgeId === 'string' ? edgeById.get(message.edgeId) : undefined;
|
|
281
|
+
if (typeof message.edgeId === 'string' && !edge) {
|
|
282
|
+
errors.push(this.referenceError(message.edgeId, `${path}/edgeId`, messageById, participantById));
|
|
283
|
+
}
|
|
284
|
+
for (const key of ['sourceParticipantId', 'targetParticipantId']) {
|
|
285
|
+
const id = message[key];
|
|
286
|
+
if (typeof id !== 'string')
|
|
287
|
+
continue;
|
|
288
|
+
if (!participantById.has(id)) {
|
|
289
|
+
errors.push(this.referenceError(id, `${path}/${key}`, participantById, messageById));
|
|
148
290
|
}
|
|
149
291
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
});
|
|
292
|
+
if (edge) {
|
|
293
|
+
const source = typeof message.sourceParticipantId === 'string'
|
|
294
|
+
? this.asRecord(participantById.get(message.sourceParticipantId)?.value) : undefined;
|
|
295
|
+
const target = typeof message.targetParticipantId === 'string'
|
|
296
|
+
? this.asRecord(participantById.get(message.targetParticipantId)?.value) : undefined;
|
|
297
|
+
if (source && source.nodeId !== edge.source) {
|
|
298
|
+
errors.push(this.error('INTERACTION_EDGE_ENDPOINT_MISMATCH', `${path}/sourceParticipantId`, 'Message source participant does not match its graph edge source'));
|
|
299
|
+
}
|
|
300
|
+
if (target && target.nodeId !== edge.target) {
|
|
301
|
+
errors.push(this.error('INTERACTION_EDGE_ENDPOINT_MISMATCH', `${path}/targetParticipantId`, 'Message target participant does not match its graph edge target'));
|
|
160
302
|
}
|
|
161
303
|
}
|
|
162
304
|
});
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
305
|
+
const participantOrder = this.stringArray(interaction.participantOrder);
|
|
306
|
+
const messageOrder = this.stringArray(interaction.messageOrder);
|
|
307
|
+
this.validateCompleteOrder(participantOrder, participantById, '/interaction/participantOrder', errors);
|
|
308
|
+
this.validateCompleteOrder(messageOrder, messageById, '/interaction/messageOrder', errors);
|
|
309
|
+
const messagePosition = new Map(messageOrder.map((id, index) => [id, index]));
|
|
310
|
+
this.indexEntities(activations, '/interaction/activations', 'activation', errors);
|
|
311
|
+
activations.forEach((value, index) => {
|
|
312
|
+
const activation = this.asRecord(value);
|
|
313
|
+
if (!activation)
|
|
314
|
+
return;
|
|
315
|
+
const path = `/interaction/activations/${index}`;
|
|
316
|
+
if (typeof activation.participantId === 'string' &&
|
|
317
|
+
!participantById.has(activation.participantId)) {
|
|
318
|
+
errors.push(this.referenceError(activation.participantId, `${path}/participantId`, participantById, messageById));
|
|
319
|
+
}
|
|
320
|
+
for (const key of ['startMessageId', 'endMessageId']) {
|
|
321
|
+
const id = activation[key];
|
|
322
|
+
if (typeof id === 'string' && !messageById.has(id)) {
|
|
323
|
+
errors.push(this.referenceError(id, `${path}/${key}`, messageById, participantById));
|
|
179
324
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
});
|
|
187
|
-
allNodes.forEach(({ node, path }) => {
|
|
188
|
-
// Skip nodes that have children (compound nodes)
|
|
189
|
-
if (!node.children?.length && !connectedNodes.has(node.id)) {
|
|
190
|
-
warnings.push({
|
|
191
|
-
code: 'ORPHAN_NODE',
|
|
192
|
-
path,
|
|
193
|
-
message: `Node "${node.id}" has no connections`,
|
|
194
|
-
severity: 'warning',
|
|
195
|
-
});
|
|
325
|
+
else if (typeof id === 'string' && typeof activation.participantId === 'string') {
|
|
326
|
+
const message = this.asRecord(messageById.get(id)?.value);
|
|
327
|
+
if (message && message.sourceParticipantId !== activation.participantId &&
|
|
328
|
+
message.targetParticipantId !== activation.participantId) {
|
|
329
|
+
errors.push(this.error('ACTIVATION_PARTICIPANT_MISMATCH', `${path}/${key}`, `Activation message ${id} does not involve participant ${activation.participantId}`));
|
|
330
|
+
}
|
|
196
331
|
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
332
|
+
}
|
|
333
|
+
if (typeof activation.startMessageId === 'string' &&
|
|
334
|
+
typeof activation.endMessageId === 'string' &&
|
|
335
|
+
(messagePosition.get(activation.startMessageId) ?? -1) >
|
|
336
|
+
(messagePosition.get(activation.endMessageId) ?? Number.MAX_SAFE_INTEGER)) {
|
|
337
|
+
errors.push(this.error('REVERSED_ACTIVATION_SPAN', `${path}/startMessageId`, 'Activation start occurs after its end in messageOrder'));
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
this.indexEntities(fragments, '/interaction/fragments', 'fragment', errors);
|
|
341
|
+
const operandIds = new Set();
|
|
342
|
+
const guardIds = new Set();
|
|
343
|
+
fragments.forEach((fragmentValue, fragmentIndex) => {
|
|
344
|
+
const fragment = this.asRecord(fragmentValue);
|
|
345
|
+
if (!fragment)
|
|
346
|
+
return;
|
|
347
|
+
const memberships = new Set();
|
|
348
|
+
this.asArray(fragment.operands).forEach((operandValue, operandIndex) => {
|
|
349
|
+
const operand = this.asRecord(operandValue);
|
|
350
|
+
if (!operand)
|
|
351
|
+
return;
|
|
352
|
+
const path = `/interaction/fragments/${fragmentIndex}/operands/${operandIndex}`;
|
|
353
|
+
if (typeof operand.id === 'string') {
|
|
354
|
+
if (operandIds.has(operand.id))
|
|
355
|
+
errors.push(this.error('DUPLICATE_INTERACTION_ID', `${path}/id`, `Duplicate operand ID ${operand.id}`));
|
|
356
|
+
operandIds.add(operand.id);
|
|
357
|
+
}
|
|
358
|
+
const guard = this.asRecord(operand.guard);
|
|
359
|
+
if (guard && typeof guard.id === 'string') {
|
|
360
|
+
if (guardIds.has(guard.id))
|
|
361
|
+
errors.push(this.error('DUPLICATE_INTERACTION_ID', `${path}/guard/id`, `Duplicate guard ID ${guard.id}`));
|
|
362
|
+
guardIds.add(guard.id);
|
|
363
|
+
}
|
|
364
|
+
let prior = -1;
|
|
365
|
+
this.stringArray(operand.messageIds).forEach((id, messageIndex) => {
|
|
366
|
+
const referencePath = `${path}/messageIds/${messageIndex}`;
|
|
367
|
+
if (!messageById.has(id)) {
|
|
368
|
+
errors.push(this.referenceError(id, referencePath, messageById, participantById));
|
|
216
369
|
}
|
|
370
|
+
if (memberships.has(id))
|
|
371
|
+
errors.push(this.error('DUPLICATE_OPERAND_MEMBERSHIP', referencePath, `Message ${id} occurs in more than one operand of the fragment`));
|
|
372
|
+
memberships.add(id);
|
|
373
|
+
const position = messagePosition.get(id);
|
|
374
|
+
if (position !== undefined && position < prior)
|
|
375
|
+
errors.push(this.error('INVALID_OPERAND_ORDER', referencePath, 'Operand messages must follow global messageOrder'));
|
|
376
|
+
if (position !== undefined)
|
|
377
|
+
prior = position;
|
|
217
378
|
});
|
|
218
379
|
});
|
|
380
|
+
});
|
|
381
|
+
this.indexEntities(notes, '/interaction/notes', 'note', errors);
|
|
382
|
+
notes.forEach((value, index) => {
|
|
383
|
+
const note = this.asRecord(value);
|
|
384
|
+
if (!note)
|
|
385
|
+
return;
|
|
386
|
+
for (const [key, target] of [
|
|
387
|
+
['participantIds', participantById], ['messageIds', messageById],
|
|
388
|
+
]) {
|
|
389
|
+
this.stringArray(note[key]).forEach((id, targetIndex) => {
|
|
390
|
+
if (!target.has(id))
|
|
391
|
+
errors.push(this.error('INVALID_NOTE_TARGET', `/interaction/notes/${index}/${key}/${targetIndex}`, `Note target ${id} does not exist`));
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
indexEntities(values, path, kind, errors) {
|
|
397
|
+
const result = new Map();
|
|
398
|
+
values.forEach((value, index) => {
|
|
399
|
+
const record = this.asRecord(value);
|
|
400
|
+
if (!record || typeof record.id !== 'string')
|
|
401
|
+
return;
|
|
402
|
+
if (result.has(record.id))
|
|
403
|
+
errors.push(this.error('DUPLICATE_INTERACTION_ID', `${path}/${index}/id`, `Duplicate ${kind} ID ${record.id}`));
|
|
404
|
+
else
|
|
405
|
+
result.set(record.id, { value, index });
|
|
406
|
+
});
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
validateCompleteOrder(order, entities, path, errors) {
|
|
410
|
+
if (order.length !== entities.size || new Set(order).size !== order.length ||
|
|
411
|
+
order.some((id) => !entities.has(id))) {
|
|
412
|
+
errors.push(this.error('INCOMPLETE_INTERACTION_ORDER', path, 'Order must contain every corresponding entity ID exactly once'));
|
|
219
413
|
}
|
|
220
|
-
// Apply strict mode
|
|
221
|
-
const finalErrors = this.options.strict ? [...errors, ...warnings] : errors;
|
|
222
|
-
const finalWarnings = this.options.strict ? [] : warnings;
|
|
223
|
-
return {
|
|
224
|
-
valid: finalErrors.length === 0,
|
|
225
|
-
errors: finalErrors,
|
|
226
|
-
warnings: finalWarnings,
|
|
227
|
-
stats: {
|
|
228
|
-
nodes: allNodes.length,
|
|
229
|
-
edges: graphIR.edges.length,
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
414
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
415
|
+
referenceError(id, path, expected, other) {
|
|
416
|
+
return this.error(other.has(id) && !expected.has(id)
|
|
417
|
+
? 'WRONG_INTERACTION_REFERENCE_KIND'
|
|
418
|
+
: 'INVALID_INTERACTION_REFERENCE', path, `Invalid interaction reference ${id}`);
|
|
419
|
+
}
|
|
420
|
+
asRecord(value) {
|
|
421
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
422
|
+
? value : undefined;
|
|
423
|
+
}
|
|
424
|
+
asArray(value) {
|
|
425
|
+
return Array.isArray(value) ? value : [];
|
|
426
|
+
}
|
|
427
|
+
stringArray(value) {
|
|
428
|
+
return this.asArray(value).filter((item) => typeof item === 'string');
|
|
429
|
+
}
|
|
430
|
+
validateEdge(edge, path, nodeById, errors) {
|
|
431
|
+
const source = typeof edge.source === 'string' ? nodeById.get(edge.source) : undefined;
|
|
432
|
+
const target = typeof edge.target === 'string' ? nodeById.get(edge.target) : undefined;
|
|
433
|
+
if (typeof edge.source === 'string' && !source) {
|
|
434
|
+
errors.push(this.error('INVALID_EDGE_SOURCE', `${path}/source`, `Edge "${edge.id}" references missing source "${edge.source}"`, this.findSimilarId(edge.source, [...nodeById.keys()])));
|
|
248
435
|
}
|
|
249
|
-
|
|
436
|
+
if (typeof edge.target === 'string' && !target) {
|
|
437
|
+
errors.push(this.error('INVALID_EDGE_TARGET', `${path}/target`, `Edge "${edge.id}" references missing target "${edge.target}"`, this.findSimilarId(edge.target, [...nodeById.keys()])));
|
|
438
|
+
}
|
|
439
|
+
if (edge.sourcePort && source && !this.hasPort(source.node, edge.sourcePort)) {
|
|
440
|
+
errors.push(this.error('INVALID_PORT_REFERENCE', `${path}/sourcePort`, `Missing source port "${edge.sourcePort}"`));
|
|
441
|
+
}
|
|
442
|
+
if (edge.targetPort && target && !this.hasPort(target.node, edge.targetPort)) {
|
|
443
|
+
errors.push(this.error('INVALID_PORT_REFERENCE', `${path}/targetPort`, `Missing target port "${edge.targetPort}"`));
|
|
444
|
+
}
|
|
445
|
+
const waypointIds = new Set();
|
|
446
|
+
const waypoints = Array.isArray(edge.routeIntent?.waypoints)
|
|
447
|
+
? edge.routeIntent.waypoints
|
|
448
|
+
: [];
|
|
449
|
+
waypoints.forEach((waypoint, index) => {
|
|
450
|
+
if (!waypoint || typeof waypoint !== 'object')
|
|
451
|
+
return;
|
|
452
|
+
const waypointPath = `${path}/routeIntent/waypoints/${index}`;
|
|
453
|
+
if (waypointIds.has(waypoint.id)) {
|
|
454
|
+
errors.push(this.error('DUPLICATE_WAYPOINT_ID', `${waypointPath}/id`, `Duplicate waypoint ID "${waypoint.id}"`));
|
|
455
|
+
}
|
|
456
|
+
waypointIds.add(waypoint.id);
|
|
457
|
+
if (!waypoint.placement || typeof waypoint.placement !== 'object')
|
|
458
|
+
return;
|
|
459
|
+
if (waypoint.placement.kind !== 'relative')
|
|
460
|
+
return;
|
|
461
|
+
const anchor = waypoint.placement.anchor;
|
|
462
|
+
if (!anchor || typeof anchor !== 'object')
|
|
463
|
+
return;
|
|
464
|
+
if (anchor.kind === 'node' && !nodeById.has(anchor.nodeId)) {
|
|
465
|
+
errors.push(this.error('INVALID_ROUTE_NODE_REFERENCE', `${waypointPath}/placement/anchor/nodeId`, `Missing route anchor node "${anchor.nodeId}"`));
|
|
466
|
+
}
|
|
467
|
+
if (anchor.kind === 'port') {
|
|
468
|
+
const owner = nodeById.get(anchor.nodeId);
|
|
469
|
+
if (!owner) {
|
|
470
|
+
errors.push(this.error('INVALID_ROUTE_NODE_REFERENCE', `${waypointPath}/placement/anchor/nodeId`, `Missing route anchor node "${anchor.nodeId}"`));
|
|
471
|
+
}
|
|
472
|
+
else if (!this.hasPort(owner.node, anchor.portId)) {
|
|
473
|
+
errors.push(this.error('INVALID_ROUTE_PORT_REFERENCE', `${waypointPath}/placement/anchor/portId`, `Missing route anchor port "${anchor.portId}"`));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
250
477
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
478
|
+
validateConstraints(constraints, nodeById, errors) {
|
|
479
|
+
const ids = new Set();
|
|
480
|
+
constraints.forEach((constraint, index) => {
|
|
481
|
+
if (!constraint || typeof constraint !== 'object')
|
|
482
|
+
return;
|
|
483
|
+
const path = `/constraints/${index}`;
|
|
484
|
+
if (ids.has(constraint.id)) {
|
|
485
|
+
errors.push(this.error('DUPLICATE_CONSTRAINT_ID', `${path}/id`, `Duplicate constraint ID "${constraint.id}"`));
|
|
486
|
+
}
|
|
487
|
+
ids.add(constraint.id);
|
|
488
|
+
if (constraint.kind === 'arrangement' && Array.isArray(constraint.subjects)) {
|
|
489
|
+
const subjectIds = constraint.subjects
|
|
490
|
+
.filter((subject) => subject && typeof subject === 'object')
|
|
491
|
+
.map((subject) => subject.nodeId);
|
|
492
|
+
if (subjectIds.length < 2 || new Set(subjectIds).size !== subjectIds.length) {
|
|
493
|
+
errors.push(this.error('INVALID_ARRANGEMENT', `${path}/subjects`, 'Arrangement requires at least two distinct node subjects'));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const references = constraint.kind === 'arrangement'
|
|
497
|
+
? (Array.isArray(constraint.subjects)
|
|
498
|
+
? constraint.subjects.map((subject, subjectIndex) => ({ subject, path: `${path}/subjects/${subjectIndex}/nodeId` }))
|
|
499
|
+
: [])
|
|
500
|
+
: constraint.subject && typeof constraint.subject === 'object'
|
|
501
|
+
? [{ subject: constraint.subject, path: `${path}/subject/nodeId` }]
|
|
502
|
+
: [];
|
|
503
|
+
if (constraint.kind === 'relative') {
|
|
504
|
+
if (constraint.target && typeof constraint.target === 'object') {
|
|
505
|
+
references.push({ subject: constraint.target, path: `${path}/target/nodeId` });
|
|
506
|
+
}
|
|
507
|
+
if (constraint.subject && typeof constraint.subject === 'object' &&
|
|
508
|
+
constraint.target && typeof constraint.target === 'object' &&
|
|
509
|
+
constraint.subject.nodeId === constraint.target.nodeId) {
|
|
510
|
+
errors.push(this.error('SELF_RELATION', path, `Constraint "${constraint.id}" relates a node to itself`));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const reference of references) {
|
|
514
|
+
if (!reference.subject || typeof reference.subject !== 'object')
|
|
515
|
+
continue;
|
|
516
|
+
if (!nodeById.has(reference.subject.nodeId)) {
|
|
517
|
+
errors.push(this.error('INVALID_CONSTRAINT_REFERENCE', reference.path, `Missing constraint node "${reference.subject.nodeId}"`));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (constraint.kind === 'boundary' &&
|
|
521
|
+
constraint.scope && typeof constraint.scope === 'object' &&
|
|
522
|
+
constraint.scope.kind === 'node' &&
|
|
523
|
+
constraint.subject && typeof constraint.subject === 'object') {
|
|
524
|
+
const subject = nodeById.get(constraint.subject.nodeId);
|
|
525
|
+
const scope = nodeById.get(constraint.scope.nodeId);
|
|
526
|
+
if (!scope) {
|
|
527
|
+
errors.push(this.error('INVALID_CONSTRAINT_REFERENCE', `${path}/scope/nodeId`, `Missing boundary scope "${constraint.scope.nodeId}"`));
|
|
528
|
+
}
|
|
529
|
+
else if (!subject || !subject.ancestors.includes(scope.node.id)) {
|
|
530
|
+
errors.push(this.error('INVALID_BOUNDARY_SCOPE', `${path}/scope/nodeId`, `Boundary scope "${scope.node.id}" is not an ancestor`));
|
|
531
|
+
}
|
|
261
532
|
}
|
|
262
533
|
});
|
|
263
|
-
return result;
|
|
264
534
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
535
|
+
validateDirectionalContradictions(constraints, nodeById, errors) {
|
|
536
|
+
const horizontal = [];
|
|
537
|
+
const vertical = [];
|
|
538
|
+
constraints.forEach((constraint, index) => {
|
|
539
|
+
if (!constraint || typeof constraint !== 'object')
|
|
540
|
+
return;
|
|
541
|
+
if ((constraint.strength ?? 'required') !== 'required')
|
|
542
|
+
return;
|
|
543
|
+
const path = `/constraints/${index}`;
|
|
544
|
+
if (constraint.kind === 'relative') {
|
|
545
|
+
this.addRelativeEdge(constraint, path, nodeById, horizontal, vertical);
|
|
546
|
+
this.checkPinnedRelation(constraint, path, nodeById, errors);
|
|
547
|
+
}
|
|
548
|
+
else if (constraint.kind === 'arrangement') {
|
|
549
|
+
if (!Array.isArray(constraint.subjects) ||
|
|
550
|
+
constraint.subjects.length < 2 ||
|
|
551
|
+
new Set(constraint.subjects.map((subject) => subject?.nodeId)).size !== constraint.subjects.length)
|
|
552
|
+
return;
|
|
553
|
+
this.addArrangementEdges(constraint, path, nodeById, horizontal, vertical, errors);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
for (const axisEdges of [horizontal, vertical]) {
|
|
557
|
+
const positiveInCycle = axisEdges.find((edge) => edge.weight > 0 && this.canReach(edge.to, edge.from, axisEdges));
|
|
558
|
+
if (positiveInCycle) {
|
|
559
|
+
errors.push(this.error('REQUIRED_CONTRADICTION', positiveInCycle.path, 'Required directional constraints contain a strictly positive cycle'));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
270
562
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
563
|
+
addRelativeEdge(constraint, path, nodes, horizontal, vertical) {
|
|
564
|
+
if (!constraint.subject || typeof constraint.subject !== 'object' ||
|
|
565
|
+
!constraint.target || typeof constraint.target !== 'object')
|
|
566
|
+
return;
|
|
567
|
+
const subject = nodes.get(constraint.subject.nodeId)?.node;
|
|
568
|
+
const target = nodes.get(constraint.target.nodeId)?.node;
|
|
569
|
+
if (!subject || !target)
|
|
570
|
+
return;
|
|
571
|
+
const gap = constraint.gap ?? 0;
|
|
572
|
+
if (constraint.relation === 'left-of') {
|
|
573
|
+
horizontal.push({ from: subject.id, to: target.id, weight: (subject.dimensions?.width ?? 0) + gap, path });
|
|
574
|
+
}
|
|
575
|
+
else if (constraint.relation === 'right-of') {
|
|
576
|
+
horizontal.push({ from: target.id, to: subject.id, weight: (target.dimensions?.width ?? 0) + gap, path });
|
|
577
|
+
}
|
|
578
|
+
else if (constraint.relation === 'above') {
|
|
579
|
+
vertical.push({ from: subject.id, to: target.id, weight: (subject.dimensions?.height ?? 0) + gap, path });
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
vertical.push({ from: target.id, to: subject.id, weight: (target.dimensions?.height ?? 0) + gap, path });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
addArrangementEdges(constraint, path, nodes, horizontal, vertical, errors) {
|
|
586
|
+
for (let index = 0; index < constraint.subjects.length - 1; index += 1) {
|
|
587
|
+
const firstRef = constraint.subjects[index];
|
|
588
|
+
const secondRef = constraint.subjects[index + 1];
|
|
589
|
+
if (!firstRef || typeof firstRef !== 'object' ||
|
|
590
|
+
!secondRef || typeof secondRef !== 'object')
|
|
591
|
+
continue;
|
|
592
|
+
const first = nodes.get(firstRef.nodeId)?.node;
|
|
593
|
+
const second = nodes.get(secondRef.nodeId)?.node;
|
|
594
|
+
if (!first || !second)
|
|
595
|
+
continue;
|
|
596
|
+
const reverse = (constraint.direction ?? 'forward') === 'reverse';
|
|
597
|
+
const from = reverse ? second : first;
|
|
598
|
+
const to = reverse ? first : second;
|
|
599
|
+
const dimension = constraint.axis === 'row'
|
|
600
|
+
? from.dimensions?.width ?? 0
|
|
601
|
+
: from.dimensions?.height ?? 0;
|
|
602
|
+
const edge = { from: from.id, to: to.id, weight: dimension + (constraint.gap ?? 0), path };
|
|
603
|
+
(constraint.axis === 'row' ? horizontal : vertical).push(edge);
|
|
604
|
+
this.checkPinnedPair(from, to, constraint.axis === 'row' ? 'left-of' : 'above', constraint.gap ?? 0, constraint.alignment ?? 'center', path, nodes, errors);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
checkPinnedRelation(constraint, path, nodes, errors) {
|
|
608
|
+
if (!constraint.subject || typeof constraint.subject !== 'object' ||
|
|
609
|
+
!constraint.target || typeof constraint.target !== 'object')
|
|
610
|
+
return;
|
|
611
|
+
const subject = nodes.get(constraint.subject.nodeId)?.node;
|
|
612
|
+
const target = nodes.get(constraint.target.nodeId)?.node;
|
|
613
|
+
if (!subject || !target)
|
|
614
|
+
return;
|
|
615
|
+
this.checkPinnedPair(subject, target, constraint.relation, constraint.gap ?? 0, constraint.alignment ?? 'center', path, nodes, errors);
|
|
616
|
+
}
|
|
617
|
+
checkPinnedPair(subject, target, relation, gap, alignment, path, nodes, errors) {
|
|
618
|
+
const subjectEntry = nodes.get(subject.id);
|
|
619
|
+
const targetEntry = nodes.get(target.id);
|
|
620
|
+
if (!subject.pinned || !target.pinned ||
|
|
621
|
+
!subject.position || !target.position ||
|
|
622
|
+
!subject.dimensions || !target.dimensions ||
|
|
623
|
+
subjectEntry?.parentId !== targetEntry?.parentId)
|
|
624
|
+
return;
|
|
625
|
+
const s = { ...subject.position, ...subject.dimensions };
|
|
626
|
+
const t = { ...target.position, ...target.dimensions };
|
|
627
|
+
const relationHolds = relation === 'above' ? s.y + s.height + gap <= t.y :
|
|
628
|
+
relation === 'below' ? s.y >= t.y + t.height + gap :
|
|
629
|
+
relation === 'left-of' ? s.x + s.width + gap <= t.x :
|
|
630
|
+
s.x >= t.x + t.width + gap;
|
|
631
|
+
const horizontalRelation = relation === 'above' || relation === 'below';
|
|
632
|
+
const aligned = alignment === 'start'
|
|
633
|
+
? (horizontalRelation ? s.x === t.x : s.y === t.y)
|
|
634
|
+
: alignment === 'center'
|
|
635
|
+
? (horizontalRelation
|
|
636
|
+
? s.x + s.width / 2 === t.x + t.width / 2
|
|
637
|
+
: s.y + s.height / 2 === t.y + t.height / 2)
|
|
638
|
+
: (horizontalRelation
|
|
639
|
+
? s.x + s.width === t.x + t.width
|
|
640
|
+
: s.y + s.height === t.y + t.height);
|
|
641
|
+
if (!relationHolds || !aligned) {
|
|
642
|
+
errors.push(this.error('REQUIRED_CONTRADICTION', path, 'Required relation conflicts with pinned geometry in one coordinate frame'));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
canReach(start, target, edges) {
|
|
646
|
+
const adjacency = new Map();
|
|
647
|
+
for (const edge of edges) {
|
|
648
|
+
const next = adjacency.get(edge.from) ?? [];
|
|
649
|
+
next.push(edge.to);
|
|
650
|
+
adjacency.set(edge.from, next);
|
|
651
|
+
}
|
|
652
|
+
const stack = [start];
|
|
276
653
|
const visited = new Set();
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
// Found a cycle
|
|
281
|
-
const cycleStart = path.indexOf(node.id);
|
|
282
|
-
const cyclePath = path.slice(cycleStart).concat(node.id);
|
|
283
|
-
errors.push({
|
|
284
|
-
code: 'CYCLIC_HIERARCHY',
|
|
285
|
-
path: '$.nodes',
|
|
286
|
-
message: `Cyclic hierarchy detected: ${cyclePath.join(' -> ')}`,
|
|
287
|
-
severity: 'error',
|
|
288
|
-
});
|
|
654
|
+
while (stack.length > 0) {
|
|
655
|
+
const current = stack.pop();
|
|
656
|
+
if (current === target)
|
|
289
657
|
return true;
|
|
658
|
+
if (visited.has(current))
|
|
659
|
+
continue;
|
|
660
|
+
visited.add(current);
|
|
661
|
+
stack.push(...(adjacency.get(current) ?? []));
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
addWarnings(graph, entries, warnings) {
|
|
666
|
+
const connected = new Set();
|
|
667
|
+
const usedPorts = new Set();
|
|
668
|
+
graph.edges.forEach((edge, index) => {
|
|
669
|
+
if (!edge || typeof edge !== 'object')
|
|
670
|
+
return;
|
|
671
|
+
connected.add(edge.source);
|
|
672
|
+
connected.add(edge.target);
|
|
673
|
+
if (edge.sourcePort)
|
|
674
|
+
usedPorts.add(this.portIdentity(edge.source, edge.sourcePort));
|
|
675
|
+
if (edge.targetPort)
|
|
676
|
+
usedPorts.add(this.portIdentity(edge.target, edge.targetPort));
|
|
677
|
+
if (edge.source === edge.target) {
|
|
678
|
+
warnings.push(this.warning('SELF_LOOP_EDGE', `/edges/${index}`, `Edge "${edge.id}" is a self-loop`));
|
|
290
679
|
}
|
|
291
|
-
|
|
292
|
-
|
|
680
|
+
});
|
|
681
|
+
entries.forEach((entry) => {
|
|
682
|
+
if (!entry.node.children?.length && !connected.has(entry.node.id)) {
|
|
683
|
+
warnings.push(this.warning('ORPHAN_NODE', entry.path, `Node "${entry.node.id}" has no connections`));
|
|
293
684
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
685
|
+
const ports = Array.isArray(entry.node.ports) ? entry.node.ports : [];
|
|
686
|
+
ports.forEach((port, index) => {
|
|
687
|
+
if (!port || typeof port !== 'object')
|
|
688
|
+
return;
|
|
689
|
+
if (!usedPorts.has(this.portIdentity(entry.node.id, port.id))) {
|
|
690
|
+
warnings.push(this.warning('ORPHAN_PORT', `${entry.path}/ports/${index}`, `Port "${port.id}" is never referenced`));
|
|
299
691
|
}
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
collectNodes(nodes, base = '/nodes', parentId, ancestors = []) {
|
|
696
|
+
const entries = [];
|
|
697
|
+
nodes.forEach((node, index) => {
|
|
698
|
+
if (!node || typeof node !== 'object')
|
|
699
|
+
return;
|
|
700
|
+
const path = `${base}/${index}`;
|
|
701
|
+
entries.push({ node, path, parentId, ancestors });
|
|
702
|
+
if (Array.isArray(node.children)) {
|
|
703
|
+
entries.push(...this.collectNodes(node.children, `${path}/children`, node.id, [...ancestors, node.id]));
|
|
300
704
|
}
|
|
301
|
-
|
|
302
|
-
|
|
705
|
+
});
|
|
706
|
+
return entries;
|
|
707
|
+
}
|
|
708
|
+
findHierarchyCycle(nodes, base = '/nodes') {
|
|
709
|
+
const active = new WeakSet();
|
|
710
|
+
const visit = (values, path) => {
|
|
711
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
712
|
+
const node = values[index];
|
|
713
|
+
if (!node || typeof node !== 'object' || Array.isArray(node))
|
|
714
|
+
continue;
|
|
715
|
+
const nodePath = `${path}/${index}`;
|
|
716
|
+
if (active.has(node))
|
|
717
|
+
return nodePath;
|
|
718
|
+
active.add(node);
|
|
719
|
+
const children = node.children;
|
|
720
|
+
if (Array.isArray(children)) {
|
|
721
|
+
const cycle = visit(children, `${nodePath}/children`);
|
|
722
|
+
if (cycle)
|
|
723
|
+
return cycle;
|
|
724
|
+
}
|
|
725
|
+
active.delete(node);
|
|
726
|
+
}
|
|
727
|
+
return undefined;
|
|
303
728
|
};
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
729
|
+
return visit(nodes, base);
|
|
730
|
+
}
|
|
731
|
+
isGraphShape(value) {
|
|
732
|
+
if (!value || typeof value !== 'object')
|
|
733
|
+
return false;
|
|
734
|
+
const record = value;
|
|
735
|
+
return Array.isArray(record.nodes) && Array.isArray(record.edges);
|
|
736
|
+
}
|
|
737
|
+
hasPort(node, portId) {
|
|
738
|
+
if (!Array.isArray(node.ports))
|
|
739
|
+
return false;
|
|
740
|
+
return node.ports.some((port) => Boolean(port && typeof port === 'object' && port.id === portId));
|
|
741
|
+
}
|
|
742
|
+
portIdentity(nodeId, portId) {
|
|
743
|
+
return JSON.stringify([nodeId, portId]);
|
|
744
|
+
}
|
|
745
|
+
joinPointer(base, token) {
|
|
746
|
+
const escaped = token.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
747
|
+
return `${base}/${escaped}`;
|
|
748
|
+
}
|
|
749
|
+
error(code, path, message, suggestion) {
|
|
750
|
+
return { code, path, message, severity: 'error', suggestion };
|
|
751
|
+
}
|
|
752
|
+
warning(code, path, message) {
|
|
753
|
+
return { code, path, message, severity: 'warning' };
|
|
308
754
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
755
|
+
result(errors) {
|
|
756
|
+
return { valid: false, errors, warnings: [], stats: { nodes: 0, edges: 0 } };
|
|
757
|
+
}
|
|
758
|
+
dedupe(values) {
|
|
759
|
+
const seen = new Set();
|
|
760
|
+
return values.filter((value) => {
|
|
761
|
+
const key = `${value.code}\0${value.path}`;
|
|
762
|
+
if (seen.has(key))
|
|
763
|
+
return false;
|
|
764
|
+
seen.add(key);
|
|
765
|
+
return true;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
findSimilarId(input, candidates) {
|
|
769
|
+
if (typeof input !== 'string')
|
|
770
|
+
return undefined;
|
|
771
|
+
let best;
|
|
772
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
316
773
|
for (const candidate of candidates) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
774
|
+
if (typeof candidate !== 'string')
|
|
775
|
+
continue;
|
|
776
|
+
const next = this.levenshtein(input, candidate);
|
|
777
|
+
if (next < distance && next <= Math.max(2, Math.floor(input.length / 3))) {
|
|
778
|
+
best = candidate;
|
|
779
|
+
distance = next;
|
|
321
780
|
}
|
|
322
781
|
}
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
for (let j = 0; j <= a.length; j++) {
|
|
334
|
-
matrix[0][j] = j;
|
|
335
|
-
}
|
|
336
|
-
for (let i = 1; i <= b.length; i++) {
|
|
337
|
-
for (let j = 1; j <= a.length; j++) {
|
|
338
|
-
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
339
|
-
matrix[i][j] = matrix[i - 1][j - 1];
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
343
|
-
}
|
|
782
|
+
return best ? `Did you mean "${best}"?` : undefined;
|
|
783
|
+
}
|
|
784
|
+
levenshtein(a, b) {
|
|
785
|
+
const matrix = Array.from({ length: b.length + 1 }, (_, row) => Array.from({ length: a.length + 1 }, (_, column) => row === 0 ? column : column === 0 ? row : 0));
|
|
786
|
+
for (let row = 1; row <= b.length; row += 1) {
|
|
787
|
+
for (let column = 1; column <= a.length; column += 1) {
|
|
788
|
+
matrix[row][column] = b[row - 1] === a[column - 1]
|
|
789
|
+
? matrix[row - 1][column - 1]
|
|
790
|
+
: Math.min(matrix[row - 1][column - 1] + 1, matrix[row][column - 1] + 1, matrix[row - 1][column] + 1);
|
|
344
791
|
}
|
|
345
792
|
}
|
|
346
793
|
return matrix[b.length][a.length];
|