@graph-ir/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +15 -2
  2. package/dist/canonical-types.d.ts +207 -0
  3. package/dist/canonical-types.d.ts.map +1 -0
  4. package/dist/canonical-types.js +3 -0
  5. package/dist/canonical-types.js.map +1 -0
  6. package/dist/generation/id-generator.d.ts.map +1 -1
  7. package/dist/generation/id-generator.js +16 -10
  8. package/dist/generation/id-generator.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/output/types.d.ts.map +1 -1
  14. package/dist/schemas/graph-ir.schema.json +327 -271
  15. package/dist/skills/diff.d.ts +4 -0
  16. package/dist/skills/diff.d.ts.map +1 -1
  17. package/dist/skills/diff.js +93 -11
  18. package/dist/skills/diff.js.map +1 -1
  19. package/dist/skills/migrate.d.ts +13 -18
  20. package/dist/skills/migrate.d.ts.map +1 -1
  21. package/dist/skills/migrate.js +224 -247
  22. package/dist/skills/migrate.js.map +1 -1
  23. package/dist/skills/visualize.d.ts.map +1 -1
  24. package/dist/skills/visualize.js +29 -5
  25. package/dist/skills/visualize.js.map +1 -1
  26. package/dist/testing/diff.d.ts +1 -1
  27. package/dist/testing/diff.d.ts.map +1 -1
  28. package/dist/testing/diff.js +23 -19
  29. package/dist/testing/diff.js.map +1 -1
  30. package/dist/types.d.ts +12 -214
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +3 -2
  33. package/dist/types.js.map +1 -1
  34. package/dist/validation/ir-validator.d.ts +27 -44
  35. package/dist/validation/ir-validator.d.ts.map +1 -1
  36. package/dist/validation/ir-validator.js +481 -294
  37. package/dist/validation/ir-validator.js.map +1 -1
  38. package/dist/validation/validator.d.ts.map +1 -1
  39. package/package.json +9 -4
  40. package/specification.json +9 -0
@@ -1,346 +1,533 @@
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';
81
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';
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
+ const constraints = Array.isArray(graph.constraints) ? graph.constraints : [];
166
+ this.validateConstraints(constraints, nodeById, errors);
167
+ this.validateDirectionalContradictions(constraints, nodeById, errors);
168
+ this.addWarnings(graph, entries, warnings);
169
+ }
170
+ validateEdge(edge, path, nodeById, errors) {
171
+ const source = typeof edge.source === 'string' ? nodeById.get(edge.source) : undefined;
172
+ const target = typeof edge.target === 'string' ? nodeById.get(edge.target) : undefined;
173
+ if (typeof edge.source === 'string' && !source) {
174
+ errors.push(this.error('INVALID_EDGE_SOURCE', `${path}/source`, `Edge "${edge.id}" references missing source "${edge.source}"`, this.findSimilarId(edge.source, [...nodeById.keys()])));
175
+ }
176
+ if (typeof edge.target === 'string' && !target) {
177
+ errors.push(this.error('INVALID_EDGE_TARGET', `${path}/target`, `Edge "${edge.id}" references missing target "${edge.target}"`, this.findSimilarId(edge.target, [...nodeById.keys()])));
178
+ }
179
+ if (edge.sourcePort && source && !this.hasPort(source.node, edge.sourcePort)) {
180
+ errors.push(this.error('INVALID_PORT_REFERENCE', `${path}/sourcePort`, `Missing source port "${edge.sourcePort}"`));
181
+ }
182
+ if (edge.targetPort && target && !this.hasPort(target.node, edge.targetPort)) {
183
+ errors.push(this.error('INVALID_PORT_REFERENCE', `${path}/targetPort`, `Missing target port "${edge.targetPort}"`));
184
+ }
185
+ const waypointIds = new Set();
186
+ const waypoints = Array.isArray(edge.routeIntent?.waypoints)
187
+ ? edge.routeIntent.waypoints
188
+ : [];
189
+ waypoints.forEach((waypoint, index) => {
190
+ if (!waypoint || typeof waypoint !== 'object')
191
+ return;
192
+ const waypointPath = `${path}/routeIntent/waypoints/${index}`;
193
+ if (waypointIds.has(waypoint.id)) {
194
+ errors.push(this.error('DUPLICATE_WAYPOINT_ID', `${waypointPath}/id`, `Duplicate waypoint ID "${waypoint.id}"`));
126
195
  }
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,
136
- });
196
+ waypointIds.add(waypoint.id);
197
+ if (!waypoint.placement || typeof waypoint.placement !== 'object')
198
+ return;
199
+ if (waypoint.placement.kind !== 'relative')
200
+ return;
201
+ const anchor = waypoint.placement.anchor;
202
+ if (!anchor || typeof anchor !== 'object')
203
+ return;
204
+ if (anchor.kind === 'node' && !nodeById.has(anchor.nodeId)) {
205
+ errors.push(this.error('INVALID_ROUTE_NODE_REFERENCE', `${waypointPath}/placement/anchor/nodeId`, `Missing route anchor node "${anchor.nodeId}"`));
137
206
  }
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
- });
207
+ if (anchor.kind === 'port') {
208
+ const owner = nodeById.get(anchor.nodeId);
209
+ if (!owner) {
210
+ errors.push(this.error('INVALID_ROUTE_NODE_REFERENCE', `${waypointPath}/placement/anchor/nodeId`, `Missing route anchor node "${anchor.nodeId}"`));
148
211
  }
149
- }
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
- });
212
+ else if (!this.hasPort(owner.node, anchor.portId)) {
213
+ errors.push(this.error('INVALID_ROUTE_PORT_REFERENCE', `${waypointPath}/placement/anchor/portId`, `Missing route anchor port "${anchor.portId}"`));
160
214
  }
161
215
  }
162
216
  });
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
- });
217
+ }
218
+ validateConstraints(constraints, nodeById, errors) {
219
+ const ids = new Set();
220
+ constraints.forEach((constraint, index) => {
221
+ if (!constraint || typeof constraint !== 'object')
222
+ return;
223
+ const path = `/constraints/${index}`;
224
+ if (ids.has(constraint.id)) {
225
+ errors.push(this.error('DUPLICATE_CONSTRAINT_ID', `${path}/id`, `Duplicate constraint ID "${constraint.id}"`));
226
+ }
227
+ ids.add(constraint.id);
228
+ if (constraint.kind === 'arrangement' && Array.isArray(constraint.subjects)) {
229
+ const subjectIds = constraint.subjects
230
+ .filter((subject) => subject && typeof subject === 'object')
231
+ .map((subject) => subject.nodeId);
232
+ if (subjectIds.length < 2 || new Set(subjectIds).size !== subjectIds.length) {
233
+ errors.push(this.error('INVALID_ARRANGEMENT', `${path}/subjects`, 'Arrangement requires at least two distinct node subjects'));
179
234
  }
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
- });
235
+ }
236
+ const references = constraint.kind === 'arrangement'
237
+ ? (Array.isArray(constraint.subjects)
238
+ ? constraint.subjects.map((subject, subjectIndex) => ({ subject, path: `${path}/subjects/${subjectIndex}/nodeId` }))
239
+ : [])
240
+ : constraint.subject && typeof constraint.subject === 'object'
241
+ ? [{ subject: constraint.subject, path: `${path}/subject/nodeId` }]
242
+ : [];
243
+ if (constraint.kind === 'relative') {
244
+ if (constraint.target && typeof constraint.target === 'object') {
245
+ references.push({ subject: constraint.target, path: `${path}/target/nodeId` });
196
246
  }
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
- });
216
- }
217
- });
218
- });
247
+ if (constraint.subject && typeof constraint.subject === 'object' &&
248
+ constraint.target && typeof constraint.target === 'object' &&
249
+ constraint.subject.nodeId === constraint.target.nodeId) {
250
+ errors.push(this.error('SELF_RELATION', path, `Constraint "${constraint.id}" relates a node to itself`));
251
+ }
252
+ }
253
+ for (const reference of references) {
254
+ if (!reference.subject || typeof reference.subject !== 'object')
255
+ continue;
256
+ if (!nodeById.has(reference.subject.nodeId)) {
257
+ errors.push(this.error('INVALID_CONSTRAINT_REFERENCE', reference.path, `Missing constraint node "${reference.subject.nodeId}"`));
258
+ }
259
+ }
260
+ if (constraint.kind === 'boundary' &&
261
+ constraint.scope && typeof constraint.scope === 'object' &&
262
+ constraint.scope.kind === 'node' &&
263
+ constraint.subject && typeof constraint.subject === 'object') {
264
+ const subject = nodeById.get(constraint.subject.nodeId);
265
+ const scope = nodeById.get(constraint.scope.nodeId);
266
+ if (!scope) {
267
+ errors.push(this.error('INVALID_CONSTRAINT_REFERENCE', `${path}/scope/nodeId`, `Missing boundary scope "${constraint.scope.nodeId}"`));
268
+ }
269
+ else if (!subject || !subject.ancestors.includes(scope.node.id)) {
270
+ errors.push(this.error('INVALID_BOUNDARY_SCOPE', `${path}/scope/nodeId`, `Boundary scope "${scope.node.id}" is not an ancestor`));
271
+ }
272
+ }
273
+ });
274
+ }
275
+ validateDirectionalContradictions(constraints, nodeById, errors) {
276
+ const horizontal = [];
277
+ const vertical = [];
278
+ constraints.forEach((constraint, index) => {
279
+ if (!constraint || typeof constraint !== 'object')
280
+ return;
281
+ if ((constraint.strength ?? 'required') !== 'required')
282
+ return;
283
+ const path = `/constraints/${index}`;
284
+ if (constraint.kind === 'relative') {
285
+ this.addRelativeEdge(constraint, path, nodeById, horizontal, vertical);
286
+ this.checkPinnedRelation(constraint, path, nodeById, errors);
287
+ }
288
+ else if (constraint.kind === 'arrangement') {
289
+ if (!Array.isArray(constraint.subjects) ||
290
+ constraint.subjects.length < 2 ||
291
+ new Set(constraint.subjects.map((subject) => subject?.nodeId)).size !== constraint.subjects.length)
292
+ return;
293
+ this.addArrangementEdges(constraint, path, nodeById, horizontal, vertical, errors);
294
+ }
295
+ });
296
+ for (const axisEdges of [horizontal, vertical]) {
297
+ const positiveInCycle = axisEdges.find((edge) => edge.weight > 0 && this.canReach(edge.to, edge.from, axisEdges));
298
+ if (positiveInCycle) {
299
+ errors.push(this.error('REQUIRED_CONTRADICTION', positiveInCycle.path, 'Required directional constraints contain a strictly positive cycle'));
300
+ }
219
301
  }
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
302
  }
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
- });
303
+ addRelativeEdge(constraint, path, nodes, horizontal, vertical) {
304
+ if (!constraint.subject || typeof constraint.subject !== 'object' ||
305
+ !constraint.target || typeof constraint.target !== 'object')
306
+ return;
307
+ const subject = nodes.get(constraint.subject.nodeId)?.node;
308
+ const target = nodes.get(constraint.target.nodeId)?.node;
309
+ if (!subject || !target)
310
+ return;
311
+ const gap = constraint.gap ?? 0;
312
+ if (constraint.relation === 'left-of') {
313
+ horizontal.push({ from: subject.id, to: target.id, weight: (subject.dimensions?.width ?? 0) + gap, path });
314
+ }
315
+ else if (constraint.relation === 'right-of') {
316
+ horizontal.push({ from: target.id, to: subject.id, weight: (target.dimensions?.width ?? 0) + gap, path });
317
+ }
318
+ else if (constraint.relation === 'above') {
319
+ vertical.push({ from: subject.id, to: target.id, weight: (subject.dimensions?.height ?? 0) + gap, path });
320
+ }
321
+ else {
322
+ vertical.push({ from: target.id, to: subject.id, weight: (target.dimensions?.height ?? 0) + gap, path });
248
323
  }
249
- return errors;
250
324
  }
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`));
261
- }
262
- });
263
- return result;
325
+ addArrangementEdges(constraint, path, nodes, horizontal, vertical, errors) {
326
+ for (let index = 0; index < constraint.subjects.length - 1; index += 1) {
327
+ const firstRef = constraint.subjects[index];
328
+ const secondRef = constraint.subjects[index + 1];
329
+ if (!firstRef || typeof firstRef !== 'object' ||
330
+ !secondRef || typeof secondRef !== 'object')
331
+ continue;
332
+ const first = nodes.get(firstRef.nodeId)?.node;
333
+ const second = nodes.get(secondRef.nodeId)?.node;
334
+ if (!first || !second)
335
+ continue;
336
+ const reverse = (constraint.direction ?? 'forward') === 'reverse';
337
+ const from = reverse ? second : first;
338
+ const to = reverse ? first : second;
339
+ const dimension = constraint.axis === 'row'
340
+ ? from.dimensions?.width ?? 0
341
+ : from.dimensions?.height ?? 0;
342
+ const edge = { from: from.id, to: to.id, weight: dimension + (constraint.gap ?? 0), path };
343
+ (constraint.axis === 'row' ? horizontal : vertical).push(edge);
344
+ this.checkPinnedPair(from, to, constraint.axis === 'row' ? 'left-of' : 'above', constraint.gap ?? 0, constraint.alignment ?? 'center', path, nodes, errors);
345
+ }
264
346
  }
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;
347
+ checkPinnedRelation(constraint, path, nodes, errors) {
348
+ if (!constraint.subject || typeof constraint.subject !== 'object' ||
349
+ !constraint.target || typeof constraint.target !== 'object')
350
+ return;
351
+ const subject = nodes.get(constraint.subject.nodeId)?.node;
352
+ const target = nodes.get(constraint.target.nodeId)?.node;
353
+ if (!subject || !target)
354
+ return;
355
+ this.checkPinnedPair(subject, target, constraint.relation, constraint.gap ?? 0, constraint.alignment ?? 'center', path, nodes, errors);
270
356
  }
271
- /**
272
- * Check for cycles in the node hierarchy
273
- */
274
- checkHierarchyCycles(nodes) {
275
- const errors = [];
357
+ checkPinnedPair(subject, target, relation, gap, alignment, path, nodes, errors) {
358
+ const subjectEntry = nodes.get(subject.id);
359
+ const targetEntry = nodes.get(target.id);
360
+ if (!subject.pinned || !target.pinned ||
361
+ !subject.position || !target.position ||
362
+ !subject.dimensions || !target.dimensions ||
363
+ subjectEntry?.parentId !== targetEntry?.parentId)
364
+ return;
365
+ const s = { ...subject.position, ...subject.dimensions };
366
+ const t = { ...target.position, ...target.dimensions };
367
+ const relationHolds = relation === 'above' ? s.y + s.height + gap <= t.y :
368
+ relation === 'below' ? s.y >= t.y + t.height + gap :
369
+ relation === 'left-of' ? s.x + s.width + gap <= t.x :
370
+ s.x >= t.x + t.width + gap;
371
+ const horizontalRelation = relation === 'above' || relation === 'below';
372
+ const aligned = alignment === 'start'
373
+ ? (horizontalRelation ? s.x === t.x : s.y === t.y)
374
+ : alignment === 'center'
375
+ ? (horizontalRelation
376
+ ? s.x + s.width / 2 === t.x + t.width / 2
377
+ : s.y + s.height / 2 === t.y + t.height / 2)
378
+ : (horizontalRelation
379
+ ? s.x + s.width === t.x + t.width
380
+ : s.y + s.height === t.y + t.height);
381
+ if (!relationHolds || !aligned) {
382
+ errors.push(this.error('REQUIRED_CONTRADICTION', path, 'Required relation conflicts with pinned geometry in one coordinate frame'));
383
+ }
384
+ }
385
+ canReach(start, target, edges) {
386
+ const adjacency = new Map();
387
+ for (const edge of edges) {
388
+ const next = adjacency.get(edge.from) ?? [];
389
+ next.push(edge.to);
390
+ adjacency.set(edge.from, next);
391
+ }
392
+ const stack = [start];
276
393
  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
- });
394
+ while (stack.length > 0) {
395
+ const current = stack.pop();
396
+ if (current === target)
289
397
  return true;
398
+ if (visited.has(current))
399
+ continue;
400
+ visited.add(current);
401
+ stack.push(...(adjacency.get(current) ?? []));
402
+ }
403
+ return false;
404
+ }
405
+ addWarnings(graph, entries, warnings) {
406
+ const connected = new Set();
407
+ const usedPorts = new Set();
408
+ graph.edges.forEach((edge, index) => {
409
+ if (!edge || typeof edge !== 'object')
410
+ return;
411
+ connected.add(edge.source);
412
+ connected.add(edge.target);
413
+ if (edge.sourcePort)
414
+ usedPorts.add(this.portIdentity(edge.source, edge.sourcePort));
415
+ if (edge.targetPort)
416
+ usedPorts.add(this.portIdentity(edge.target, edge.targetPort));
417
+ if (edge.source === edge.target) {
418
+ warnings.push(this.warning('SELF_LOOP_EDGE', `/edges/${index}`, `Edge "${edge.id}" is a self-loop`));
290
419
  }
291
- if (visited.has(node.id)) {
292
- return false;
420
+ });
421
+ entries.forEach((entry) => {
422
+ if (!entry.node.children?.length && !connected.has(entry.node.id)) {
423
+ warnings.push(this.warning('ORPHAN_NODE', entry.path, `Node "${entry.node.id}" has no connections`));
293
424
  }
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;
425
+ const ports = Array.isArray(entry.node.ports) ? entry.node.ports : [];
426
+ ports.forEach((port, index) => {
427
+ if (!port || typeof port !== 'object')
428
+ return;
429
+ if (!usedPorts.has(this.portIdentity(entry.node.id, port.id))) {
430
+ warnings.push(this.warning('ORPHAN_PORT', `${entry.path}/ports/${index}`, `Port "${port.id}" is never referenced`));
299
431
  }
432
+ });
433
+ });
434
+ }
435
+ collectNodes(nodes, base = '/nodes', parentId, ancestors = []) {
436
+ const entries = [];
437
+ nodes.forEach((node, index) => {
438
+ if (!node || typeof node !== 'object')
439
+ return;
440
+ const path = `${base}/${index}`;
441
+ entries.push({ node, path, parentId, ancestors });
442
+ if (Array.isArray(node.children)) {
443
+ entries.push(...this.collectNodes(node.children, `${path}/children`, node.id, [...ancestors, node.id]));
300
444
  }
301
- recursionStack.delete(node.id);
302
- return false;
445
+ });
446
+ return entries;
447
+ }
448
+ findHierarchyCycle(nodes, base = '/nodes') {
449
+ const active = new WeakSet();
450
+ const visit = (values, path) => {
451
+ for (let index = 0; index < values.length; index += 1) {
452
+ const node = values[index];
453
+ if (!node || typeof node !== 'object' || Array.isArray(node))
454
+ continue;
455
+ const nodePath = `${path}/${index}`;
456
+ if (active.has(node))
457
+ return nodePath;
458
+ active.add(node);
459
+ const children = node.children;
460
+ if (Array.isArray(children)) {
461
+ const cycle = visit(children, `${nodePath}/children`);
462
+ if (cycle)
463
+ return cycle;
464
+ }
465
+ active.delete(node);
466
+ }
467
+ return undefined;
303
468
  };
304
- for (const node of nodes) {
305
- dfs(node, []);
306
- }
307
- return errors;
469
+ return visit(nodes, base);
308
470
  }
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;
471
+ isGraphShape(value) {
472
+ if (!value || typeof value !== 'object')
473
+ return false;
474
+ const record = value;
475
+ return Array.isArray(record.nodes) && Array.isArray(record.edges);
476
+ }
477
+ hasPort(node, portId) {
478
+ if (!Array.isArray(node.ports))
479
+ return false;
480
+ return node.ports.some((port) => Boolean(port && typeof port === 'object' && port.id === portId));
481
+ }
482
+ portIdentity(nodeId, portId) {
483
+ return JSON.stringify([nodeId, portId]);
484
+ }
485
+ joinPointer(base, token) {
486
+ const escaped = token.replace(/~/g, '~0').replace(/\//g, '~1');
487
+ return `${base}/${escaped}`;
488
+ }
489
+ error(code, path, message, suggestion) {
490
+ return { code, path, message, severity: 'error', suggestion };
491
+ }
492
+ warning(code, path, message) {
493
+ return { code, path, message, severity: 'warning' };
494
+ }
495
+ result(errors) {
496
+ return { valid: false, errors, warnings: [], stats: { nodes: 0, edges: 0 } };
497
+ }
498
+ dedupe(values) {
499
+ const seen = new Set();
500
+ return values.filter((value) => {
501
+ const key = `${value.code}\0${value.path}`;
502
+ if (seen.has(key))
503
+ return false;
504
+ seen.add(key);
505
+ return true;
506
+ });
507
+ }
508
+ findSimilarId(input, candidates) {
509
+ if (typeof input !== 'string')
510
+ return undefined;
511
+ let best;
512
+ let distance = Number.POSITIVE_INFINITY;
316
513
  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;
514
+ if (typeof candidate !== 'string')
515
+ continue;
516
+ const next = this.levenshtein(input, candidate);
517
+ if (next < distance && next <= Math.max(2, Math.floor(input.length / 3))) {
518
+ best = candidate;
519
+ distance = next;
321
520
  }
322
521
  }
323
- return bestMatch ? `Did you mean "${bestMatch}"?` : undefined;
522
+ return best ? `Did you mean "${best}"?` : undefined;
324
523
  }
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
- }
524
+ levenshtein(a, b) {
525
+ const matrix = Array.from({ length: b.length + 1 }, (_, row) => Array.from({ length: a.length + 1 }, (_, column) => row === 0 ? column : column === 0 ? row : 0));
526
+ for (let row = 1; row <= b.length; row += 1) {
527
+ for (let column = 1; column <= a.length; column += 1) {
528
+ matrix[row][column] = b[row - 1] === a[column - 1]
529
+ ? matrix[row - 1][column - 1]
530
+ : Math.min(matrix[row - 1][column - 1] + 1, matrix[row][column - 1] + 1, matrix[row - 1][column] + 1);
344
531
  }
345
532
  }
346
533
  return matrix[b.length][a.length];