@graph-ir/core 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/canonical-types-2.d.ts +207 -0
- package/dist/canonical-types-2.d.ts.map +1 -0
- package/dist/canonical-types-2.js +3 -0
- package/dist/canonical-types-2.js.map +1 -0
- package/dist/canonical-types.d.ts +332 -0
- package/dist/canonical-types.d.ts.map +1 -0
- package/dist/canonical-types.js +3 -0
- package/dist/canonical-types.js.map +1 -0
- package/dist/generation/id-generator.d.ts.map +1 -1
- package/dist/generation/id-generator.js +16 -10
- package/dist/generation/id-generator.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/output/types.d.ts.map +1 -1
- package/dist/schemas/graph-ir-2.0.schema.json +416 -0
- package/dist/schemas/graph-ir.schema.json +1684 -228
- package/dist/skills/diff.d.ts +4 -0
- package/dist/skills/diff.d.ts.map +1 -1
- package/dist/skills/diff.js +93 -11
- package/dist/skills/diff.js.map +1 -1
- package/dist/skills/migrate.d.ts +48 -19
- package/dist/skills/migrate.d.ts.map +1 -1
- package/dist/skills/migrate.js +482 -239
- package/dist/skills/migrate.js.map +1 -1
- package/dist/skills/visualize.d.ts.map +1 -1
- package/dist/skills/visualize.js +33 -5
- package/dist/skills/visualize.js.map +1 -1
- package/dist/testing/diff.d.ts +1 -1
- package/dist/testing/diff.d.ts.map +1 -1
- package/dist/testing/diff.js +52 -19
- package/dist/testing/diff.js.map +1 -1
- package/dist/types.d.ts +13 -214
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -2
- package/dist/types.js.map +1 -1
- package/dist/validation/ir-validator.d.ts +35 -44
- package/dist/validation/ir-validator.d.ts.map +1 -1
- package/dist/validation/ir-validator.js +735 -288
- package/dist/validation/ir-validator.js.map +1 -1
- package/dist/validation/validator.d.ts.map +1 -1
- package/package.json +9 -4
- package/specification.json +13 -0
package/dist/skills/migrate.js
CHANGED
|
@@ -1,287 +1,530 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
import Ajv2020Import from 'ajv/dist/2020.js';
|
|
2
|
+
import { validateIR } from '../validation/validator.js';
|
|
3
|
+
import graphIR2Schema from '../schemas/graph-ir-2.0.schema.json' with { type: 'json' };
|
|
4
|
+
const Ajv2020 = Ajv2020Import.default ??
|
|
5
|
+
Ajv2020Import;
|
|
6
|
+
const validateGraphIR2Schema = new Ajv2020({
|
|
7
|
+
allErrors: true, strict: false, validateFormats: false,
|
|
8
|
+
}).compile(graphIR2Schema);
|
|
9
|
+
export function migrateGraphIR2To3(input, options = {}) {
|
|
10
|
+
const changes = [];
|
|
11
|
+
const losses = [];
|
|
12
|
+
const diagnostics = [];
|
|
13
|
+
const finish = (candidate) => {
|
|
14
|
+
const errors = diagnostics
|
|
15
|
+
.filter((entry) => entry.severity === 'error')
|
|
16
|
+
.map((entry) => `${entry.code} ${entry.path || '/'}: ${entry.message}`);
|
|
17
|
+
const warnings = diagnostics
|
|
18
|
+
.filter((entry) => entry.severity === 'warning')
|
|
19
|
+
.map((entry) => `${entry.code} ${entry.path || '/'}: ${entry.message}`);
|
|
20
|
+
const success = errors.length === 0;
|
|
21
|
+
return {
|
|
22
|
+
success,
|
|
23
|
+
migrated: success && !options.dryRun ? candidate : null,
|
|
24
|
+
fromVersion: '2.0.0',
|
|
25
|
+
toVersion: '3.0.0',
|
|
26
|
+
changes,
|
|
27
|
+
losses,
|
|
28
|
+
diagnostics,
|
|
29
|
+
errors,
|
|
30
|
+
warnings,
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
if (!isRecord(input) || input.version !== '2.0.0') {
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
severity: 'error', code: 'MIGRATION_SOURCE_VERSION', path: '/version',
|
|
36
|
+
message: 'migrateGraphIR2To3 accepts only frozen GraphIR 2.0.0 input',
|
|
37
|
+
});
|
|
38
|
+
return finish(null);
|
|
39
|
+
}
|
|
40
|
+
if (!validateGraphIR2Schema(input)) {
|
|
41
|
+
for (const error of validateGraphIR2Schema.errors ?? []) {
|
|
42
|
+
const suffix = error.keyword === 'required'
|
|
43
|
+
? `/${escapePointer(String(error.params.missingProperty))}` : '';
|
|
44
|
+
diagnostics.push({
|
|
45
|
+
severity: 'error', code: 'MIGRATION_SOURCE_INVALID',
|
|
46
|
+
path: `${error.instancePath}${suffix}`,
|
|
47
|
+
message: error.message ?? 'Invalid GraphIR 2 migration input',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return finish(null);
|
|
51
|
+
}
|
|
52
|
+
const working = structuredClone(input);
|
|
53
|
+
working.version = '3.0.0';
|
|
54
|
+
changes.push({
|
|
55
|
+
code: 'SET_SCHEMA_VERSION', path: '/version',
|
|
56
|
+
message: 'Set serialized schema version to 3.0.0', value: '3.0.0',
|
|
57
|
+
});
|
|
58
|
+
const notation = typeof working.metadata?.notation === 'string'
|
|
59
|
+
? working.metadata.notation : undefined;
|
|
60
|
+
const diagramType = typeof working.metadata?.custom?.diagramType === 'string'
|
|
61
|
+
? working.metadata.custom.diagramType : undefined;
|
|
62
|
+
const notationKind = notation === 'uml-class' ? 'class'
|
|
63
|
+
: notation === 'uml-sequence' ? 'sequence' : undefined;
|
|
64
|
+
const customKind = diagramType === 'class' ? 'class'
|
|
65
|
+
: diagramType === 'sequence' ? 'sequence' : undefined;
|
|
66
|
+
if (notationKind && customKind && notationKind !== customKind) {
|
|
67
|
+
diagnostics.push({
|
|
68
|
+
severity: 'error', code: 'MIGRATION_PROFILE_CONTRADICTION',
|
|
69
|
+
path: '/metadata/custom/diagramType',
|
|
70
|
+
message: 'Recognized notation and diagramType select different profiles',
|
|
71
|
+
});
|
|
72
|
+
return finish(null);
|
|
73
|
+
}
|
|
74
|
+
const profileKind = notationKind ?? customKind;
|
|
75
|
+
if (profileKind) {
|
|
76
|
+
working.profile = { id: 'uml', kind: profileKind, version: '1.0.0' };
|
|
77
|
+
changes.push({
|
|
78
|
+
code: 'CREATE_NOTATION_PROFILE', path: '/profile',
|
|
79
|
+
sourcePath: notationKind ? '/metadata/notation' : '/metadata/custom/diagramType',
|
|
80
|
+
message: `Create UML ${profileKind} notation profile`, value: working.profile,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const [value, path, recognized] of [
|
|
84
|
+
[notation, '/metadata/notation', ['uml-class', 'uml-sequence']],
|
|
85
|
+
[diagramType, '/metadata/custom/diagramType', ['class', 'sequence']],
|
|
86
|
+
]) {
|
|
87
|
+
if (value !== undefined && !recognized.includes(value)) {
|
|
88
|
+
losses.push({
|
|
89
|
+
code: 'UNKNOWN_LEGACY_NOTATION', path,
|
|
90
|
+
message: `Unknown legacy notation value ${value} was preserved`, value,
|
|
91
|
+
});
|
|
92
|
+
diagnostics.push({
|
|
93
|
+
severity: 'warning', code: 'UNKNOWN_LEGACY_NOTATION', path,
|
|
94
|
+
message: `Unknown legacy notation value ${value} did not create a profile`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(working.edges)) {
|
|
99
|
+
working.edges.forEach((edge, index) => {
|
|
100
|
+
if (!isRecord(edge) || !isRecord(edge.style))
|
|
101
|
+
return;
|
|
102
|
+
for (const [field, end] of [['sourceArrow', 'sourceEnd'], ['targetArrow', 'targetEnd']]) {
|
|
103
|
+
if (typeof edge.style[field] !== 'string')
|
|
104
|
+
continue;
|
|
105
|
+
edge[end] = { marker: { kind: edge.style[field], fill: 'unspecified' } };
|
|
106
|
+
delete edge.style[field];
|
|
107
|
+
changes.push({
|
|
108
|
+
code: 'CONVERT_ENDPOINT_MARKER', path: `/edges/${index}/${end}/marker`,
|
|
109
|
+
sourcePath: `/edges/${index}/style/${field}`,
|
|
110
|
+
message: `Convert legacy ${field} to relationship-end marker`, value: edge[end],
|
|
65
111
|
});
|
|
66
112
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
if (Object.keys(edge.style).length === 0)
|
|
114
|
+
delete edge.style;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (profileKind === 'class') {
|
|
118
|
+
visitNodes(working.nodes, '/nodes', (node, path) => {
|
|
119
|
+
const members = isRecord(node.properties) && Array.isArray(node.properties.members)
|
|
120
|
+
? node.properties.members : undefined;
|
|
121
|
+
if (!members)
|
|
122
|
+
return;
|
|
123
|
+
const parsed = members.map((value, index) => ({
|
|
124
|
+
value,
|
|
125
|
+
sourcePath: `${path}/properties/members/${index}`,
|
|
126
|
+
member: typeof value === 'string'
|
|
127
|
+
? parseClassifierMember(value, `${path}/properties/members/${index}`) : null,
|
|
128
|
+
}));
|
|
129
|
+
const ambiguous = parsed.filter((entry) => entry.member === null);
|
|
130
|
+
if (ambiguous.length > 0) {
|
|
131
|
+
for (const entry of ambiguous) {
|
|
132
|
+
losses.push({
|
|
133
|
+
code: 'AMBIGUOUS_CLASSIFIER_MEMBER', path: entry.sourcePath,
|
|
134
|
+
message: 'Classifier member could not be converted without guessing',
|
|
135
|
+
value: entry.value,
|
|
136
|
+
});
|
|
137
|
+
diagnostics.push({
|
|
138
|
+
severity: 'error', code: 'AMBIGUOUS_CLASSIFIER_MEMBER',
|
|
139
|
+
path: entry.sourcePath,
|
|
140
|
+
message: 'Classifier member must match the conservative grammar',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
92
144
|
}
|
|
93
|
-
|
|
94
|
-
|
|
145
|
+
const attributes = parsed.map((entry) => entry.member)
|
|
146
|
+
.filter((value) => value?.kind === 'attribute');
|
|
147
|
+
const operations = parsed.map((entry) => entry.member)
|
|
148
|
+
.filter((value) => value?.kind === 'operation');
|
|
149
|
+
const compartments = [{ id: `${node.id}-name`, kind: 'name' }];
|
|
150
|
+
if (attributes.length > 0)
|
|
151
|
+
compartments.push({
|
|
152
|
+
id: `${node.id}-attributes`, kind: 'attributes', items: attributes,
|
|
153
|
+
});
|
|
154
|
+
if (operations.length > 0)
|
|
155
|
+
compartments.push({
|
|
156
|
+
id: `${node.id}-operations`, kind: 'operations', items: operations,
|
|
157
|
+
});
|
|
158
|
+
node.classifier = {
|
|
159
|
+
kind: 'class', name: typeof node.label === 'string' && node.label.length > 0 ? node.label : node.id,
|
|
160
|
+
compartments,
|
|
161
|
+
};
|
|
162
|
+
changes.push({
|
|
163
|
+
code: 'CONVERT_CLASSIFIER_MEMBERS', path: `${path}/classifier`,
|
|
164
|
+
sourcePath: `${path}/properties/members`,
|
|
165
|
+
message: 'Convert unambiguous raw members to structured classifier compartments',
|
|
166
|
+
value: node.classifier,
|
|
167
|
+
});
|
|
168
|
+
for (const entry of parsed) {
|
|
169
|
+
changes.push({
|
|
170
|
+
code: 'CONVERT_CLASSIFIER_MEMBER', path: `${path}/classifier`,
|
|
171
|
+
sourcePath: entry.sourcePath,
|
|
172
|
+
message: 'Convert one raw classifier member to structured meaning',
|
|
173
|
+
value: entry.value,
|
|
174
|
+
});
|
|
95
175
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (profileKind === 'sequence') {
|
|
179
|
+
const participants = [];
|
|
180
|
+
visitNodes(working.nodes, '/nodes', (node, path) => {
|
|
181
|
+
if (typeof node.id !== 'string')
|
|
182
|
+
return;
|
|
183
|
+
participants.push({ id: node.id, nodeId: node.id });
|
|
184
|
+
changes.push({
|
|
185
|
+
code: 'DERIVE_PARTICIPANT_ORDER', path: `/interaction/participantOrder/${participants.length - 1}`,
|
|
186
|
+
sourcePath: `${path}/id`, message: 'Derive participant identity and order', value: node.id,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
const messages = working.edges.map((edge, index) => {
|
|
190
|
+
changes.push({
|
|
191
|
+
code: 'DERIVE_MESSAGE_ORDER', path: `/interaction/messageOrder/${index}`,
|
|
192
|
+
sourcePath: `/edges/${index}/id`, message: 'Derive message identity and order', value: edge.id,
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
id: edge.id, edgeId: edge.id,
|
|
196
|
+
sourceParticipantId: edge.source, targetParticipantId: edge.target,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
working.interaction = {
|
|
200
|
+
participants,
|
|
201
|
+
participantOrder: participants.map((participant) => participant.id),
|
|
202
|
+
messages,
|
|
203
|
+
messageOrder: messages.map((message) => message.id),
|
|
119
204
|
};
|
|
120
205
|
}
|
|
121
|
-
|
|
122
|
-
|
|
206
|
+
if (diagnostics.some((entry) => entry.severity === 'error'))
|
|
207
|
+
return finish(null);
|
|
208
|
+
const candidate = working;
|
|
209
|
+
const validation = validateIR(candidate);
|
|
210
|
+
for (const error of validation.errors) {
|
|
211
|
+
diagnostics.push({
|
|
212
|
+
severity: 'error', code: `MIGRATION_${error.code}`, path: error.path,
|
|
213
|
+
message: error.message,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
for (const warning of validation.warnings) {
|
|
217
|
+
diagnostics.push({
|
|
218
|
+
severity: 'warning', code: `MIGRATION_${warning.code}`, path: warning.path,
|
|
219
|
+
message: warning.message,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return finish(candidate);
|
|
223
|
+
}
|
|
224
|
+
const CURRENT_VERSION = '2.0.0';
|
|
225
|
+
export function migrateIR(input, options = {}) {
|
|
226
|
+
const fromVersion = options.fromVersion ?? detectVersion(input);
|
|
227
|
+
const toVersion = options.toVersion ?? CURRENT_VERSION;
|
|
228
|
+
if (!fromVersion || !isRecord(input)) {
|
|
229
|
+
return failure(fromVersion ?? 'unknown', toVersion, ['Could not detect source version. Specify fromVersion explicitly.']);
|
|
230
|
+
}
|
|
231
|
+
if (toVersion !== CURRENT_VERSION) {
|
|
232
|
+
return failure(fromVersion, toVersion, [`Unsupported migration target ${toVersion}`]);
|
|
233
|
+
}
|
|
234
|
+
if (fromVersion === CURRENT_VERSION) {
|
|
235
|
+
const candidate = structuredClone(input);
|
|
236
|
+
const validationErrors = validateMigrationCandidate(candidate);
|
|
237
|
+
if (validationErrors.length > 0) {
|
|
238
|
+
return invalidCandidate(fromVersion, toVersion, [], [], validationErrors);
|
|
239
|
+
}
|
|
123
240
|
return {
|
|
124
241
|
success: true,
|
|
125
|
-
migrated:
|
|
242
|
+
migrated: options.dryRun ? null : candidate,
|
|
126
243
|
fromVersion,
|
|
127
244
|
toVersion,
|
|
128
245
|
changes: [],
|
|
246
|
+
losses: [],
|
|
129
247
|
errors: [],
|
|
130
248
|
warnings: ['Already at target version'],
|
|
131
249
|
};
|
|
132
250
|
}
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
251
|
+
let working = structuredClone(input);
|
|
252
|
+
const changes = [];
|
|
253
|
+
if (fromVersion === '0.8.0' || fromVersion === '0.9.0') {
|
|
254
|
+
working = normalizeLegacyToOne(working, changes);
|
|
255
|
+
}
|
|
256
|
+
else if (fromVersion !== '1.0.0') {
|
|
257
|
+
return failure(fromVersion, toVersion, [`No migration path from ${fromVersion}`]);
|
|
258
|
+
}
|
|
259
|
+
const losses = collectAmbiguousLosses(working);
|
|
260
|
+
if (losses.length > 0 && (options.ambiguousGeometry ?? 'error') === 'error') {
|
|
136
261
|
return {
|
|
137
262
|
success: false,
|
|
138
263
|
migrated: null,
|
|
139
264
|
fromVersion,
|
|
140
265
|
toVersion,
|
|
141
|
-
changes
|
|
142
|
-
|
|
266
|
+
changes,
|
|
267
|
+
losses,
|
|
268
|
+
errors: [
|
|
269
|
+
'Schema 1.x geometry has ambiguous provenance; choose ambiguousGeometry="drop" or migrate with producer-owned provenance.',
|
|
270
|
+
],
|
|
143
271
|
warnings: [],
|
|
144
272
|
};
|
|
145
273
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
success: false,
|
|
162
|
-
migrated: null,
|
|
163
|
-
fromVersion,
|
|
164
|
-
toVersion,
|
|
165
|
-
changes: allChanges,
|
|
166
|
-
errors: [`Migration failed at ${migration.fromVersion} → ${migration.toVersion}: ${errorMsg}`],
|
|
167
|
-
warnings,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
274
|
+
if (losses.length > 0) {
|
|
275
|
+
removeAmbiguousGeometry(working, changes);
|
|
276
|
+
}
|
|
277
|
+
promoteEdgeDescriptions(working, changes);
|
|
278
|
+
working.version = CURRENT_VERSION;
|
|
279
|
+
changes.push({
|
|
280
|
+
type: 'transform',
|
|
281
|
+
path: '/version',
|
|
282
|
+
description: 'Set serialized schema version to 2.0.0',
|
|
283
|
+
value: CURRENT_VERSION,
|
|
284
|
+
});
|
|
285
|
+
const candidate = working;
|
|
286
|
+
const validationErrors = validateMigrationCandidate(candidate);
|
|
287
|
+
if (validationErrors.length > 0) {
|
|
288
|
+
return invalidCandidate(fromVersion, toVersion, changes, losses, validationErrors);
|
|
172
289
|
}
|
|
173
290
|
return {
|
|
174
291
|
success: true,
|
|
175
|
-
migrated: options.dryRun ? null :
|
|
292
|
+
migrated: options.dryRun ? null : candidate,
|
|
176
293
|
fromVersion,
|
|
177
294
|
toVersion,
|
|
178
|
-
changes
|
|
179
|
-
|
|
180
|
-
|
|
295
|
+
changes,
|
|
296
|
+
losses,
|
|
297
|
+
errors: [],
|
|
298
|
+
warnings: losses.map((loss) => `Dropped ambiguous ${loss.field} at ${loss.path}`),
|
|
181
299
|
};
|
|
182
300
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
301
|
+
function validateMigrationCandidate(candidate) {
|
|
302
|
+
if (validateGraphIR2Schema(candidate))
|
|
303
|
+
return [];
|
|
304
|
+
return (validateGraphIR2Schema.errors ?? []).map((error) => {
|
|
305
|
+
const suffix = error.keyword === 'additionalProperties'
|
|
306
|
+
? `/${escapePointer(String(error.params.additionalProperty))}` : '';
|
|
307
|
+
return `SCHEMA_VIOLATION ${error.instancePath}${suffix || (error.instancePath ? '' : '/')}: ${error.message ?? 'invalid GraphIR 2'}`;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function invalidCandidate(fromVersion, toVersion, changes, losses, errors) {
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
migrated: null,
|
|
314
|
+
fromVersion,
|
|
315
|
+
toVersion,
|
|
316
|
+
changes,
|
|
317
|
+
losses,
|
|
318
|
+
errors: ['Migrated candidate is not valid GraphIR 2.0.0', ...errors],
|
|
319
|
+
warnings: losses.map((loss) => `Would drop ambiguous ${loss.field} at ${loss.path}`),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function normalizeLegacyToOne(input, changes) {
|
|
323
|
+
const result = { ...input };
|
|
324
|
+
if (!result.id) {
|
|
325
|
+
result.id = 'migrated-graph';
|
|
326
|
+
changes.push({ type: 'add', path: '/id', description: 'Add graph ID' });
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(result.nodes)) {
|
|
329
|
+
result.nodes = result.nodes.map((value) => {
|
|
330
|
+
if (!isRecord(value))
|
|
331
|
+
return value;
|
|
332
|
+
const node = { ...value };
|
|
333
|
+
if ('props' in node && !('properties' in node)) {
|
|
334
|
+
node.properties = node.props;
|
|
335
|
+
delete node.props;
|
|
336
|
+
changes.push({ type: 'rename', path: '/nodes/*/props', description: 'Rename props to properties' });
|
|
337
|
+
}
|
|
338
|
+
return node;
|
|
339
|
+
});
|
|
189
340
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
341
|
+
if (Array.isArray(result.edges)) {
|
|
342
|
+
result.edges = result.edges.map((value, index) => {
|
|
343
|
+
if (!isRecord(value))
|
|
344
|
+
return value;
|
|
345
|
+
const edge = { ...value };
|
|
346
|
+
if ('from' in edge && !('source' in edge)) {
|
|
347
|
+
edge.source = edge.from;
|
|
348
|
+
delete edge.from;
|
|
349
|
+
}
|
|
350
|
+
if ('to' in edge && !('target' in edge)) {
|
|
351
|
+
edge.target = edge.to;
|
|
352
|
+
delete edge.to;
|
|
353
|
+
}
|
|
354
|
+
if (!edge.id)
|
|
355
|
+
edge.id = `e${index + 1}`;
|
|
356
|
+
return edge;
|
|
357
|
+
});
|
|
358
|
+
changes.push({ type: 'transform', path: '/edges', description: 'Normalize legacy edge fields and IDs' });
|
|
194
359
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
360
|
+
result.version = '1.0.0';
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
function collectAmbiguousLosses(input) {
|
|
364
|
+
const losses = [];
|
|
365
|
+
visitNodes(input.nodes, '/nodes', (node, path) => {
|
|
366
|
+
for (const field of ['position', 'pinned', 'dimensions']) {
|
|
367
|
+
if (field in node) {
|
|
368
|
+
losses.push({
|
|
369
|
+
path: `${path}/${field}`,
|
|
370
|
+
field,
|
|
371
|
+
reason: `Schema 1.x ${field} did not distinguish authored and computed geometry`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
201
374
|
}
|
|
375
|
+
});
|
|
376
|
+
if (Array.isArray(input.edges)) {
|
|
377
|
+
input.edges.forEach((value, index) => {
|
|
378
|
+
if (isRecord(value) && 'routingPoints' in value) {
|
|
379
|
+
losses.push({
|
|
380
|
+
path: `/edges/${index}/routingPoints`,
|
|
381
|
+
field: 'routingPoints',
|
|
382
|
+
reason: 'Schema 1.x routing points did not distinguish authored waypoints and generated bends',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (isRecord(input.layoutOptions) && 'algorithmOptions' in input.layoutOptions) {
|
|
388
|
+
losses.push({
|
|
389
|
+
path: '/layoutOptions/algorithmOptions',
|
|
390
|
+
field: 'algorithmOptions',
|
|
391
|
+
reason: 'Raw engine options are not portable authored GraphIR 2.0',
|
|
392
|
+
});
|
|
202
393
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
394
|
+
return losses;
|
|
395
|
+
}
|
|
396
|
+
function removeAmbiguousGeometry(input, changes) {
|
|
397
|
+
visitNodes(input.nodes, '/nodes', (node, path) => {
|
|
398
|
+
for (const field of ['position', 'pinned', 'dimensions']) {
|
|
399
|
+
if (field in node) {
|
|
400
|
+
delete node[field];
|
|
401
|
+
changes.push({ type: 'remove', path: `${path}/${field}`, description: `Drop ambiguous ${field}` });
|
|
402
|
+
}
|
|
209
403
|
}
|
|
404
|
+
});
|
|
405
|
+
if (Array.isArray(input.edges)) {
|
|
406
|
+
input.edges.forEach((value, index) => {
|
|
407
|
+
if (isRecord(value) && 'routingPoints' in value) {
|
|
408
|
+
delete value.routingPoints;
|
|
409
|
+
changes.push({ type: 'remove', path: `/edges/${index}/routingPoints`, description: 'Drop ambiguous routing points' });
|
|
410
|
+
}
|
|
411
|
+
});
|
|
210
412
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return '1.0.0';
|
|
413
|
+
if (isRecord(input.layoutOptions) && 'algorithmOptions' in input.layoutOptions) {
|
|
414
|
+
delete input.layoutOptions.algorithmOptions;
|
|
415
|
+
changes.push({ type: 'remove', path: '/layoutOptions/algorithmOptions', description: 'Drop non-portable engine options' });
|
|
215
416
|
}
|
|
216
|
-
|
|
217
|
-
|
|
417
|
+
}
|
|
418
|
+
function promoteEdgeDescriptions(input, changes) {
|
|
419
|
+
if (!Array.isArray(input.edges))
|
|
420
|
+
return;
|
|
421
|
+
input.edges.forEach((value, index) => {
|
|
422
|
+
if (!isRecord(value) || value.description !== undefined || !isRecord(value.properties))
|
|
423
|
+
return;
|
|
424
|
+
if (typeof value.properties.description !== 'string')
|
|
425
|
+
return;
|
|
426
|
+
value.description = value.properties.description;
|
|
427
|
+
delete value.properties.description;
|
|
428
|
+
if (Object.keys(value.properties).length === 0)
|
|
429
|
+
delete value.properties;
|
|
430
|
+
changes.push({
|
|
431
|
+
type: 'transform',
|
|
432
|
+
path: `/edges/${index}/description`,
|
|
433
|
+
description: 'Promote unambiguous properties.description',
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function visitNodes(value, path, visit) {
|
|
438
|
+
if (!Array.isArray(value))
|
|
439
|
+
return;
|
|
440
|
+
value.forEach((candidate, index) => {
|
|
441
|
+
if (!isRecord(candidate))
|
|
442
|
+
return;
|
|
443
|
+
const nodePath = `${path}/${index}`;
|
|
444
|
+
visit(candidate, nodePath);
|
|
445
|
+
visitNodes(candidate.children, `${nodePath}/children`, visit);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
function detectVersion(input) {
|
|
449
|
+
if (!isRecord(input))
|
|
450
|
+
return null;
|
|
451
|
+
if (typeof input.version === 'string')
|
|
452
|
+
return input.version;
|
|
453
|
+
if (!input.id)
|
|
218
454
|
return '0.8.0';
|
|
219
|
-
|
|
455
|
+
if (Array.isArray(input.nodes) || Array.isArray(input.edges))
|
|
456
|
+
return '0.9.0';
|
|
220
457
|
return null;
|
|
221
458
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
459
|
+
function isRecord(value) {
|
|
460
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
461
|
+
}
|
|
462
|
+
function escapePointer(value) {
|
|
463
|
+
return value.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
464
|
+
}
|
|
465
|
+
function parseClassifierMember(raw, path) {
|
|
466
|
+
const visibilityMap = {
|
|
467
|
+
'+': 'public', '#': 'protected', '-': 'private', '~': 'package',
|
|
468
|
+
};
|
|
469
|
+
const ordinal = Number(path.split('/').at(-1) ?? 0);
|
|
470
|
+
const operation = raw.match(/^([+#~-])?\s*([A-Za-z_][A-Za-z0-9_-]*)\s*\(([^)]*)\)\s*(?::\s*([^\s,:()]+))?$/);
|
|
471
|
+
if (operation) {
|
|
472
|
+
const [, visibility, name, parameterText, returnType] = operation;
|
|
473
|
+
const parameters = [];
|
|
474
|
+
if (parameterText.trim().length > 0) {
|
|
475
|
+
for (const rawParameter of parameterText.split(',')) {
|
|
476
|
+
const parameter = rawParameter.trim().match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:\s*:\s*([^\s,:()]+))?$/);
|
|
477
|
+
if (!parameter)
|
|
478
|
+
return null;
|
|
479
|
+
parameters.push({
|
|
480
|
+
name: parameter[1], ...(parameter[2] ? { type: parameter[2] } : {}),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
233
483
|
}
|
|
234
|
-
|
|
235
|
-
|
|
484
|
+
return {
|
|
485
|
+
id: `${name}-${ordinal}`, kind: 'operation',
|
|
486
|
+
...(visibility ? { visibility: visibilityMap[visibility] } : {}),
|
|
487
|
+
name, parameters, ...(returnType ? { returnType } : {}),
|
|
488
|
+
};
|
|
236
489
|
}
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
490
|
+
const attribute = raw.match(/^([+#~-])?\s*([^\s,:()]+)\s+([A-Za-z_][A-Za-z0-9_-]*)$/);
|
|
491
|
+
if (attribute) {
|
|
492
|
+
const [, visibility, type, name] = attribute;
|
|
493
|
+
return {
|
|
494
|
+
id: `${name}-${ordinal}`, kind: 'attribute',
|
|
495
|
+
...(visibility ? { visibility: visibilityMap[visibility] } : {}),
|
|
496
|
+
name, type,
|
|
497
|
+
};
|
|
240
498
|
}
|
|
241
|
-
return
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
function failure(fromVersion, toVersion, errors) {
|
|
502
|
+
return {
|
|
503
|
+
success: false,
|
|
504
|
+
migrated: null,
|
|
505
|
+
fromVersion,
|
|
506
|
+
toVersion,
|
|
507
|
+
changes: [],
|
|
508
|
+
losses: [],
|
|
509
|
+
errors,
|
|
510
|
+
warnings: [],
|
|
511
|
+
};
|
|
242
512
|
}
|
|
243
|
-
/**
|
|
244
|
-
* Format migration result for display
|
|
245
|
-
*/
|
|
246
513
|
export function formatMigrationResult(result, dryRun) {
|
|
247
|
-
const lines = [
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
lines.push(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
lines.push(`Changes (${result.changes.length}):`);
|
|
262
|
-
for (const change of result.changes) {
|
|
263
|
-
const icon = change.type === 'add' ? '+' :
|
|
264
|
-
change.type === 'remove' ? '-' :
|
|
265
|
-
change.type === 'rename' ? '~' : '→';
|
|
266
|
-
lines.push(` ${icon} ${change.description}`);
|
|
267
|
-
lines.push(` Path: ${change.path}`);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
if (result.warnings.length > 0) {
|
|
271
|
-
lines.push('');
|
|
272
|
-
lines.push('Warnings:');
|
|
273
|
-
for (const warning of result.warnings) {
|
|
274
|
-
lines.push(` ⚠ ${warning}`);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (result.errors.length > 0) {
|
|
278
|
-
lines.push('');
|
|
279
|
-
lines.push('Errors:');
|
|
280
|
-
for (const error of result.errors) {
|
|
281
|
-
lines.push(` ✗ ${error}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
lines.push('');
|
|
514
|
+
const lines = [
|
|
515
|
+
dryRun ? 'Migration Plan (Dry Run)' : 'Migration Result',
|
|
516
|
+
'═'.repeat(40),
|
|
517
|
+
`Version: ${result.fromVersion} → ${result.toVersion}`,
|
|
518
|
+
'',
|
|
519
|
+
];
|
|
520
|
+
for (const change of result.changes)
|
|
521
|
+
lines.push(` ${change.type}: ${change.path} — ${change.description}`);
|
|
522
|
+
for (const loss of result.losses)
|
|
523
|
+
lines.push(` loss: ${loss.path} — ${loss.reason}`);
|
|
524
|
+
for (const warning of result.warnings)
|
|
525
|
+
lines.push(` warning: ${warning}`);
|
|
526
|
+
for (const error of result.errors)
|
|
527
|
+
lines.push(` error: ${error}`);
|
|
285
528
|
lines.push(result.success ? '✓ Migration successful' : '✗ Migration failed');
|
|
286
529
|
return lines.join('\n');
|
|
287
530
|
}
|