@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.
Files changed (45) hide show
  1. package/README.md +14 -2
  2. package/dist/canonical-types-2.d.ts +207 -0
  3. package/dist/canonical-types-2.d.ts.map +1 -0
  4. package/dist/canonical-types-2.js +3 -0
  5. package/dist/canonical-types-2.js.map +1 -0
  6. package/dist/canonical-types.d.ts +332 -0
  7. package/dist/canonical-types.d.ts.map +1 -0
  8. package/dist/canonical-types.js +3 -0
  9. package/dist/canonical-types.js.map +1 -0
  10. package/dist/generation/id-generator.d.ts.map +1 -1
  11. package/dist/generation/id-generator.js +16 -10
  12. package/dist/generation/id-generator.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/output/types.d.ts.map +1 -1
  18. package/dist/schemas/graph-ir-2.0.schema.json +416 -0
  19. package/dist/schemas/graph-ir.schema.json +1684 -228
  20. package/dist/skills/diff.d.ts +4 -0
  21. package/dist/skills/diff.d.ts.map +1 -1
  22. package/dist/skills/diff.js +93 -11
  23. package/dist/skills/diff.js.map +1 -1
  24. package/dist/skills/migrate.d.ts +48 -19
  25. package/dist/skills/migrate.d.ts.map +1 -1
  26. package/dist/skills/migrate.js +482 -239
  27. package/dist/skills/migrate.js.map +1 -1
  28. package/dist/skills/visualize.d.ts.map +1 -1
  29. package/dist/skills/visualize.js +33 -5
  30. package/dist/skills/visualize.js.map +1 -1
  31. package/dist/testing/diff.d.ts +1 -1
  32. package/dist/testing/diff.d.ts.map +1 -1
  33. package/dist/testing/diff.js +52 -19
  34. package/dist/testing/diff.js.map +1 -1
  35. package/dist/types.d.ts +13 -214
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +3 -2
  38. package/dist/types.js.map +1 -1
  39. package/dist/validation/ir-validator.d.ts +35 -44
  40. package/dist/validation/ir-validator.d.ts.map +1 -1
  41. package/dist/validation/ir-validator.js +735 -288
  42. package/dist/validation/ir-validator.js.map +1 -1
  43. package/dist/validation/validator.d.ts.map +1 -1
  44. package/package.json +9 -4
  45. 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
- // Use draft-07 compatible schema by removing the $schema field at runtime
15
- const schema = { ...graphIRSchema };
16
- delete schema['$schema'];
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
- this.ajv = new Ajv({ allErrors: true, verbose: true, strict: false });
24
- // Add formats manually since ESM import is problematic
25
- this.addDateTimeFormat();
26
- this.schemaValidator = this.ajv.compile(schema);
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
- // Parse JSON
43
- let ir;
20
+ let value;
44
21
  try {
45
- ir = JSON.parse(jsonString);
22
+ value = JSON.parse(jsonString);
46
23
  }
47
- catch (e) {
48
- return {
49
- valid: false,
50
- errors: [
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(ir);
29
+ return this.validate(value);
63
30
  }
64
- /**
65
- * Validate a Graph-IR object
66
- */
67
- validate(ir) {
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
- // 1. Schema validation
71
- if (!this.options.skipSchema) {
72
- const schemaErrors = this.validateSchema(ir);
73
- if (schemaErrors.length > 0) {
74
- // Schema errors are fatal - return early
75
- return {
76
- valid: false,
77
- errors: schemaErrors,
78
- warnings: [],
79
- stats: { nodes: 0, edges: 0 },
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
- // At this point, ir should be a valid GraphIR structure
84
- const graphIR = ir;
85
- // 2. Collect all nodes (including nested children)
86
- const allNodes = this.collectAllNodes(graphIR.nodes);
87
- const nodeIds = new Map();
88
- // 3. Check node ID uniqueness
89
- allNodes.forEach(({ node, path }) => {
90
- if (nodeIds.has(node.id)) {
91
- errors.push({
92
- code: 'DUPLICATE_NODE_ID',
93
- path: `${path}.id`,
94
- message: `Duplicate node ID: "${node.id}"`,
95
- severity: 'error',
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
- else {
99
- nodeIds.set(node.id, { node, path });
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
- // 4. Check edge ID uniqueness and references
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
- graphIR.edges.forEach((edge, index) => {
105
- const path = `$.edges[${index}]`;
106
- // Check edge ID uniqueness
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
- // Check source reference
117
- if (!nodeIds.has(edge.source)) {
118
- const suggestion = this.findSimilarId(edge.source, Array.from(nodeIds.keys()));
119
- errors.push({
120
- code: 'INVALID_EDGE_SOURCE',
121
- path: `${path}.source`,
122
- message: `Edge "${edge.id}" references non-existent source node: "${edge.source}"`,
123
- severity: 'error',
124
- suggestion,
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
- // Check target reference
128
- if (!nodeIds.has(edge.target)) {
129
- const suggestion = this.findSimilarId(edge.target, Array.from(nodeIds.keys()));
130
- errors.push({
131
- code: 'INVALID_EDGE_TARGET',
132
- path: `${path}.target`,
133
- message: `Edge "${edge.id}" references non-existent target node: "${edge.target}"`,
134
- severity: 'error',
135
- suggestion,
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
- // Check source port reference
139
- if (edge.sourcePort && nodeIds.has(edge.source)) {
140
- const sourceNode = nodeIds.get(edge.source).node;
141
- if (!this.hasPort(sourceNode, edge.sourcePort)) {
142
- errors.push({
143
- code: 'INVALID_PORT_REFERENCE',
144
- path: `${path}.sourcePort`,
145
- message: `Edge "${edge.id}" references non-existent source port: "${edge.sourcePort}" on node "${edge.source}"`,
146
- severity: 'error',
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
- // Check target port reference
151
- if (edge.targetPort && nodeIds.has(edge.target)) {
152
- const targetNode = nodeIds.get(edge.target).node;
153
- if (!this.hasPort(targetNode, edge.targetPort)) {
154
- errors.push({
155
- code: 'INVALID_PORT_REFERENCE',
156
- path: `${path}.targetPort`,
157
- message: `Edge "${edge.id}" references non-existent target port: "${edge.targetPort}" on node "${edge.target}"`,
158
- severity: 'error',
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
- // 5. Check for cycles in hierarchy
164
- if (!this.options.skipSemantic) {
165
- const cycleErrors = this.checkHierarchyCycles(graphIR.nodes);
166
- errors.push(...cycleErrors);
167
- }
168
- // 6. Semantic warnings
169
- if (!this.options.skipSemantic) {
170
- // Check for self-loops
171
- graphIR.edges.forEach((edge, index) => {
172
- if (edge.source === edge.target) {
173
- warnings.push({
174
- code: 'SELF_LOOP_EDGE',
175
- path: `$.edges[${index}]`,
176
- message: `Edge "${edge.id}" connects node "${edge.source}" to itself`,
177
- severity: 'warning',
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
- // Check for orphan nodes (nodes with no connections)
182
- const connectedNodes = new Set();
183
- graphIR.edges.forEach((edge) => {
184
- connectedNodes.add(edge.source);
185
- connectedNodes.add(edge.target);
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
- // Check for orphan ports
199
- const usedPorts = new Set();
200
- graphIR.edges.forEach((edge) => {
201
- if (edge.sourcePort)
202
- usedPorts.add(`${edge.source}:${edge.sourcePort}`);
203
- if (edge.targetPort)
204
- usedPorts.add(`${edge.target}:${edge.targetPort}`);
205
- });
206
- allNodes.forEach(({ node, path }) => {
207
- node.ports?.forEach((port, portIndex) => {
208
- const portKey = `${node.id}:${port.id}`;
209
- if (!usedPorts.has(portKey)) {
210
- warnings.push({
211
- code: 'ORPHAN_PORT',
212
- path: `${path}.ports[${portIndex}]`,
213
- message: `Port "${port.id}" on node "${node.id}" is never referenced`,
214
- severity: 'warning',
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
- * Validate against JSON Schema
235
- */
236
- validateSchema(ir) {
237
- const valid = this.schemaValidator(ir);
238
- if (valid)
239
- return [];
240
- const errors = [];
241
- for (const error of this.schemaValidator.errors || []) {
242
- errors.push({
243
- code: 'SCHEMA_VIOLATION',
244
- path: error.instancePath || '$',
245
- message: error.message || 'Schema validation failed',
246
- severity: 'error',
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
- return errors;
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
- * Collect all nodes including nested children
253
- */
254
- collectAllNodes(nodes, parentPath = '$.nodes') {
255
- const result = [];
256
- nodes.forEach((node, index) => {
257
- const path = `${parentPath}[${index}]`;
258
- result.push({ node, path });
259
- if (node.children?.length) {
260
- result.push(...this.collectAllNodes(node.children, `${path}.children`));
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
- * Check if a node has a port with the given ID
267
- */
268
- hasPort(node, portId) {
269
- return node.ports?.some((p) => p.id === portId) ?? false;
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
- * Check for cycles in the node hierarchy
273
- */
274
- checkHierarchyCycles(nodes) {
275
- const errors = [];
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
- const recursionStack = new Set();
278
- const dfs = (node, path) => {
279
- if (recursionStack.has(node.id)) {
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
- if (visited.has(node.id)) {
292
- return false;
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
- visited.add(node.id);
295
- recursionStack.add(node.id);
296
- for (const child of node.children || []) {
297
- if (dfs(child, [...path, node.id])) {
298
- return true;
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
- recursionStack.delete(node.id);
302
- return false;
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
- for (const node of nodes) {
305
- dfs(node, []);
306
- }
307
- return errors;
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
- * Find a similar ID for suggestions
311
- */
312
- findSimilarId(target, candidates) {
313
- const targetLower = target.toLowerCase();
314
- let bestMatch;
315
- let bestScore = Infinity;
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
- const distance = this.levenshteinDistance(targetLower, candidate.toLowerCase());
318
- if (distance < bestScore && distance <= 3) {
319
- bestScore = distance;
320
- bestMatch = candidate;
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 bestMatch ? `Did you mean "${bestMatch}"?` : undefined;
324
- }
325
- /**
326
- * Calculate Levenshtein distance for similarity
327
- */
328
- levenshteinDistance(a, b) {
329
- const matrix = [];
330
- for (let i = 0; i <= b.length; i++) {
331
- matrix[i] = [i];
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];