@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.
- package/README.md +15 -2
- package/dist/canonical-types.d.ts +207 -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.schema.json +327 -271
- 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 +13 -18
- package/dist/skills/migrate.d.ts.map +1 -1
- package/dist/skills/migrate.js +224 -247
- package/dist/skills/migrate.js.map +1 -1
- package/dist/skills/visualize.d.ts.map +1 -1
- package/dist/skills/visualize.js +29 -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 +23 -19
- package/dist/testing/diff.js.map +1 -1
- package/dist/types.d.ts +12 -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 +27 -44
- package/dist/validation/ir-validator.d.ts.map +1 -1
- package/dist/validation/ir-validator.js +481 -294
- 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 +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
|
-
|
|
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';
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
128
|
-
if (!
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
dfs(node, []);
|
|
306
|
-
}
|
|
307
|
-
return errors;
|
|
469
|
+
return visit(nodes, base);
|
|
308
470
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
522
|
+
return best ? `Did you mean "${best}"?` : undefined;
|
|
324
523
|
}
|
|
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
|
-
}
|
|
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];
|