@graph-ir/core 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +14 -2
  2. package/dist/canonical-types-2.d.ts +207 -0
  3. package/dist/canonical-types-2.d.ts.map +1 -0
  4. package/dist/canonical-types-2.js +3 -0
  5. package/dist/canonical-types-2.js.map +1 -0
  6. package/dist/canonical-types.d.ts +332 -0
  7. package/dist/canonical-types.d.ts.map +1 -0
  8. package/dist/canonical-types.js +3 -0
  9. package/dist/canonical-types.js.map +1 -0
  10. package/dist/generation/id-generator.d.ts.map +1 -1
  11. package/dist/generation/id-generator.js +16 -10
  12. package/dist/generation/id-generator.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/output/types.d.ts.map +1 -1
  18. package/dist/schemas/graph-ir-2.0.schema.json +416 -0
  19. package/dist/schemas/graph-ir.schema.json +1684 -228
  20. package/dist/skills/diff.d.ts +4 -0
  21. package/dist/skills/diff.d.ts.map +1 -1
  22. package/dist/skills/diff.js +93 -11
  23. package/dist/skills/diff.js.map +1 -1
  24. package/dist/skills/migrate.d.ts +48 -19
  25. package/dist/skills/migrate.d.ts.map +1 -1
  26. package/dist/skills/migrate.js +482 -239
  27. package/dist/skills/migrate.js.map +1 -1
  28. package/dist/skills/visualize.d.ts.map +1 -1
  29. package/dist/skills/visualize.js +33 -5
  30. package/dist/skills/visualize.js.map +1 -1
  31. package/dist/testing/diff.d.ts +1 -1
  32. package/dist/testing/diff.d.ts.map +1 -1
  33. package/dist/testing/diff.js +52 -19
  34. package/dist/testing/diff.js.map +1 -1
  35. package/dist/types.d.ts +13 -214
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +3 -2
  38. package/dist/types.js.map +1 -1
  39. package/dist/validation/ir-validator.d.ts +35 -44
  40. package/dist/validation/ir-validator.d.ts.map +1 -1
  41. package/dist/validation/ir-validator.js +735 -288
  42. package/dist/validation/ir-validator.js.map +1 -1
  43. package/dist/validation/validator.d.ts.map +1 -1
  44. package/package.json +9 -4
  45. package/specification.json +13 -0
@@ -1,287 +1,530 @@
1
- /**
2
- * IR Migrate Skill
3
- *
4
- * Migrate Graph-IR files between schema versions.
5
- */
6
- // Current schema version
7
- const CURRENT_VERSION = '1.0.0';
8
- // Define migrations between versions
9
- const MIGRATIONS = [
10
- // Example: 0.9.0 -> 1.0.0
11
- {
12
- fromVersion: '0.9.0',
13
- toVersion: '1.0.0',
14
- changes: [
15
- {
16
- type: 'rename',
17
- path: 'nodes[*].props',
18
- description: 'Rename "props" to "properties"',
19
- },
20
- {
21
- type: 'rename',
22
- path: 'edges[*].from',
23
- description: 'Rename "from" to "source"',
24
- },
25
- {
26
- type: 'rename',
27
- path: 'edges[*].to',
28
- description: 'Rename "to" to "target"',
29
- },
30
- {
31
- type: 'add',
32
- path: 'edges[*].id',
33
- description: 'Add required edge IDs',
34
- },
35
- ],
36
- migrate: (ir) => {
37
- const result = { ...ir };
38
- // Rename node props -> properties
39
- if (Array.isArray(result.nodes)) {
40
- result.nodes = result.nodes.map((node, index) => {
41
- const newNode = { ...node };
42
- if ('props' in newNode) {
43
- newNode.properties = newNode.props;
44
- delete newNode.props;
45
- }
46
- return newNode;
47
- });
48
- }
49
- // Rename edge from/to -> source/target and add IDs
50
- if (Array.isArray(result.edges)) {
51
- result.edges = result.edges.map((edge, index) => {
52
- const newEdge = { ...edge };
53
- if ('from' in newEdge) {
54
- newEdge.source = newEdge.from;
55
- delete newEdge.from;
56
- }
57
- if ('to' in newEdge) {
58
- newEdge.target = newEdge.to;
59
- delete newEdge.to;
60
- }
61
- if (!('id' in newEdge)) {
62
- newEdge.id = `e${index + 1}`;
63
- }
64
- return newEdge;
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
- result.version = '1.0.0';
68
- return result;
69
- },
70
- },
71
- // Example: 0.8.0 -> 0.9.0
72
- {
73
- fromVersion: '0.8.0',
74
- toVersion: '0.9.0',
75
- changes: [
76
- {
77
- type: 'add',
78
- path: 'version',
79
- description: 'Add required version field',
80
- value: '0.9.0',
81
- },
82
- {
83
- type: 'add',
84
- path: 'id',
85
- description: 'Add required graph ID',
86
- },
87
- ],
88
- migrate: (ir) => {
89
- const result = { ...ir };
90
- if (!result.version) {
91
- result.version = '0.9.0';
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
- if (!result.id) {
94
- result.id = 'migrated-graph';
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
- return result;
97
- },
98
- },
99
- ];
100
- /**
101
- * Migrate a Graph-IR to the target version
102
- */
103
- export function migrateIR(ir, options = {}) {
104
- const errors = [];
105
- const warnings = [];
106
- const allChanges = [];
107
- // Detect source version
108
- const fromVersion = options.fromVersion || detectVersion(ir);
109
- const toVersion = options.toVersion || CURRENT_VERSION;
110
- if (!fromVersion) {
111
- return {
112
- success: false,
113
- migrated: null,
114
- fromVersion: 'unknown',
115
- toVersion,
116
- changes: [],
117
- errors: ['Could not detect source version. Please specify --from <version>'],
118
- warnings: [],
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
- // Already at target version?
122
- if (fromVersion === toVersion) {
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: ir,
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
- // Build migration path
134
- const path = buildMigrationPath(fromVersion, toVersion);
135
- if (path.length === 0) {
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
- errors: [`No migration path found from ${fromVersion} to ${toVersion}`],
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
- // Apply migrations
147
- let current = ir;
148
- for (const migration of path) {
149
- allChanges.push(...migration.changes);
150
- if (!options.dryRun) {
151
- try {
152
- current = migration.migrate(current);
153
- }
154
- catch (e) {
155
- const errorMsg = e instanceof Error ? e.message : 'Unknown migration error';
156
- if (options.force) {
157
- warnings.push(`Migration ${migration.fromVersion} ${migration.toVersion} failed: ${errorMsg}`);
158
- }
159
- else {
160
- return {
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 : current,
292
+ migrated: options.dryRun ? null : candidate,
176
293
  fromVersion,
177
294
  toVersion,
178
- changes: allChanges,
179
- errors,
180
- warnings,
295
+ changes,
296
+ losses,
297
+ errors: [],
298
+ warnings: losses.map((loss) => `Dropped ambiguous ${loss.field} at ${loss.path}`),
181
299
  };
182
300
  }
183
- /**
184
- * Detect the version of a Graph-IR object
185
- */
186
- function detectVersion(ir) {
187
- if (!ir || typeof ir !== 'object') {
188
- return null;
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
- const obj = ir;
191
- // Check explicit version field
192
- if (typeof obj.version === 'string') {
193
- return obj.version;
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
- // Infer version from structure
196
- if (Array.isArray(obj.nodes) && obj.nodes.length > 0) {
197
- const firstNode = obj.nodes[0];
198
- // v0.9.0: has 'props' instead of 'properties'
199
- if ('props' in firstNode && !('properties' in firstNode)) {
200
- return '0.9.0';
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
- if (Array.isArray(obj.edges) && obj.edges.length > 0) {
204
- const firstEdge = obj.edges[0];
205
- // v0.9.0: has 'from'/'to' instead of 'source'/'target'
206
- if (('from' in firstEdge || 'to' in firstEdge) &&
207
- !('source' in firstEdge || 'target' in firstEdge)) {
208
- return '0.9.0';
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
- // No version field but has modern structure
212
- if (!obj.version && obj.id && Array.isArray(obj.nodes) && Array.isArray(obj.edges)) {
213
- // Could be 1.0.0 without version field
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
- // Very old version without id field
217
- if (!obj.id && !obj.version) {
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
- * Build the migration path from source to target version
224
- */
225
- function buildMigrationPath(from, to) {
226
- const path = [];
227
- let current = from;
228
- // Simple linear search - could be optimized with graph traversal
229
- while (current !== to) {
230
- const migration = MIGRATIONS.find((m) => m.fromVersion === current);
231
- if (!migration) {
232
- break;
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
- path.push(migration);
235
- current = migration.toVersion;
484
+ return {
485
+ id: `${name}-${ordinal}`, kind: 'operation',
486
+ ...(visibility ? { visibility: visibilityMap[visibility] } : {}),
487
+ name, parameters, ...(returnType ? { returnType } : {}),
488
+ };
236
489
  }
237
- // Verify we reached target
238
- if (current !== to) {
239
- return [];
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 path;
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
- if (dryRun) {
249
- lines.push('Migration Plan (Dry Run)');
250
- }
251
- else {
252
- lines.push('Migration Result');
253
- }
254
- lines.push('═'.repeat(40));
255
- lines.push(`Version: ${result.fromVersion} ${result.toVersion}`);
256
- lines.push('');
257
- if (result.changes.length === 0) {
258
- lines.push('No changes needed');
259
- }
260
- else {
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
  }