@ontrails/topography 0.2.0

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/src/diff.ts ADDED
@@ -0,0 +1,1088 @@
1
+ /**
2
+ * Semantic diffing of topo graphs.
3
+ */
4
+
5
+ import type {
6
+ DiffEntry,
7
+ DiffResult,
8
+ JsonSchema,
9
+ TopoGraph,
10
+ TopoGraphEntry,
11
+ TopoGraphTrailheadEntry,
12
+ TopoGraphForceEntry,
13
+ TopoGraphVersionEntry,
14
+ } from './types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ type Severity = DiffEntry['severity'];
21
+
22
+ interface DetailAccumulator {
23
+ readonly details: string[];
24
+ severity: Severity;
25
+ }
26
+
27
+ const escalate = (acc: DetailAccumulator, severity: Severity): void => {
28
+ const rank: Record<Severity, number> = { breaking: 2, info: 0, warning: 1 };
29
+ if (rank[severity] > rank[acc.severity]) {
30
+ acc.severity = severity;
31
+ }
32
+ };
33
+
34
+ const addDetail = (
35
+ acc: DetailAccumulator,
36
+ severity: Severity,
37
+ message: string
38
+ ): void => {
39
+ acc.details.push(message);
40
+ escalate(acc, severity);
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Utility
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const capitalize = (s: string): string =>
48
+ `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
49
+
50
+ const labelForKind = (kind: DiffEntry['kind']): string => {
51
+ if (kind === 'trailhead') {
52
+ return 'Trailhead';
53
+ }
54
+ if (kind === 'entity') {
55
+ return 'Entity';
56
+ }
57
+ if (kind === 'resource') {
58
+ return 'Resource';
59
+ }
60
+ if (kind === 'signal') {
61
+ return 'Signal';
62
+ }
63
+ return 'Trail';
64
+ };
65
+
66
+ const stableStringify = (value: unknown): string =>
67
+ JSON.stringify(value, (_key, nested) => {
68
+ if (Array.isArray(nested)) {
69
+ return [...nested].toSorted((a, b) =>
70
+ JSON.stringify(a).localeCompare(JSON.stringify(b))
71
+ );
72
+ }
73
+ if (
74
+ nested !== null &&
75
+ typeof nested === 'object' &&
76
+ !Array.isArray(nested)
77
+ ) {
78
+ return Object.fromEntries(Object.entries(nested).toSorted());
79
+ }
80
+ return nested;
81
+ });
82
+
83
+ const permitScopes = (
84
+ permit: TopoGraphEntry['permit'] | undefined
85
+ ): ReadonlySet<string> | undefined =>
86
+ permit === undefined || permit === 'public'
87
+ ? undefined
88
+ : new Set(permit.scopes);
89
+
90
+ const permitChangeSeverity = (
91
+ prevPermit: TopoGraphEntry['permit'] | undefined,
92
+ currPermit: TopoGraphEntry['permit'] | undefined
93
+ ): Severity => {
94
+ const prevScopes = permitScopes(prevPermit);
95
+ const currScopes = permitScopes(currPermit);
96
+ if (currScopes === undefined) {
97
+ return 'warning';
98
+ }
99
+ if (prevScopes === undefined) {
100
+ return 'breaking';
101
+ }
102
+ for (const scope of currScopes) {
103
+ if (!prevScopes.has(scope)) {
104
+ return 'breaking';
105
+ }
106
+ }
107
+ return 'warning';
108
+ };
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Schema field diffing
112
+ // ---------------------------------------------------------------------------
113
+
114
+ interface SchemaProperties {
115
+ readonly properties?: Readonly<Record<string, JsonSchema>>;
116
+ readonly required?: readonly string[];
117
+ }
118
+
119
+ const getProperties = (
120
+ schema: JsonSchema | undefined
121
+ ): Record<string, JsonSchema> => {
122
+ if (!schema) {
123
+ return {};
124
+ }
125
+ const props = (schema as SchemaProperties).properties;
126
+ return props ? { ...props } : {};
127
+ };
128
+
129
+ const getRequired = (schema: JsonSchema | undefined): ReadonlySet<string> => {
130
+ if (!schema) {
131
+ return new Set();
132
+ }
133
+ const req = (schema as SchemaProperties).required;
134
+ return new Set(req);
135
+ };
136
+
137
+ const inferSchemaType = (schema: JsonSchema): string => {
138
+ if (typeof schema['type'] === 'string') {
139
+ return schema['type'];
140
+ }
141
+ if (schema['const'] !== undefined) {
142
+ return `const(${String(schema['const'])})`;
143
+ }
144
+ if (schema['anyOf']) {
145
+ return 'union';
146
+ }
147
+ if (schema['enum']) {
148
+ return 'enum';
149
+ }
150
+ return 'unknown';
151
+ };
152
+
153
+ const getType = (schema: JsonSchema | undefined): string =>
154
+ schema ? inferSchemaType(schema) : 'unknown';
155
+
156
+ const getAddedFieldLabel = (
157
+ direction: 'entity' | 'input' | 'output'
158
+ ): string => {
159
+ if (direction === 'output') {
160
+ return 'Output';
161
+ }
162
+ if (direction === 'entity') {
163
+ return 'Optional entity';
164
+ }
165
+ return 'Optional input';
166
+ };
167
+
168
+ /** Diff a single field that was added. */
169
+ const diffAddedField = (
170
+ acc: DetailAccumulator,
171
+ direction: 'entity' | 'input' | 'output',
172
+ key: string,
173
+ currRequired: ReadonlySet<string>
174
+ ): void => {
175
+ if (
176
+ (direction === 'entity' || direction === 'input') &&
177
+ currRequired.has(key)
178
+ ) {
179
+ addDetail(acc, 'breaking', `Required ${direction} field "${key}" added`);
180
+ } else {
181
+ const label = getAddedFieldLabel(direction);
182
+ addDetail(acc, 'info', `${label} field "${key}" added`);
183
+ }
184
+ };
185
+
186
+ /** Diff a single field present in both prev and curr. */
187
+ const diffModifiedField = (
188
+ acc: DetailAccumulator,
189
+ direction: 'entity' | 'input' | 'output',
190
+ key: string,
191
+ prevProps: Record<string, JsonSchema>,
192
+ currProps: Record<string, JsonSchema>,
193
+ prevRequired: ReadonlySet<string>,
194
+ currRequired: ReadonlySet<string>
195
+ ): void => {
196
+ const prevType = getType(prevProps[key]);
197
+ const currType = getType(currProps[key]);
198
+ if (prevType !== currType) {
199
+ addDetail(
200
+ acc,
201
+ 'breaking',
202
+ `${capitalize(direction)} field "${key}" type changed: ${prevType} -> ${currType}`
203
+ );
204
+ }
205
+ if (
206
+ (direction === 'entity' || direction === 'input') &&
207
+ !prevRequired.has(key) &&
208
+ currRequired.has(key)
209
+ ) {
210
+ addDetail(
211
+ acc,
212
+ 'breaking',
213
+ `${capitalize(direction)} field "${key}" changed from optional to required`
214
+ );
215
+ }
216
+ };
217
+
218
+ /** Diff a single key across prev/curr schemas. */
219
+ const diffKey = (
220
+ acc: DetailAccumulator,
221
+ direction: 'entity' | 'input' | 'output',
222
+ key: string,
223
+ prevProps: Record<string, JsonSchema>,
224
+ currProps: Record<string, JsonSchema>,
225
+ prevRequired: ReadonlySet<string>,
226
+ currRequired: ReadonlySet<string>
227
+ ): void => {
228
+ const inPrev = key in prevProps;
229
+ const inCurr = key in currProps;
230
+ if (!inPrev && inCurr) {
231
+ diffAddedField(acc, direction, key, currRequired);
232
+ } else if (inPrev && !inCurr) {
233
+ addDetail(
234
+ acc,
235
+ 'breaking',
236
+ `${capitalize(direction)} field "${key}" removed`
237
+ );
238
+ } else if (inPrev && inCurr) {
239
+ diffModifiedField(
240
+ acc,
241
+ direction,
242
+ key,
243
+ prevProps,
244
+ currProps,
245
+ prevRequired,
246
+ currRequired
247
+ );
248
+ }
249
+ };
250
+
251
+ const diffSchemaFields = (
252
+ acc: DetailAccumulator,
253
+ direction: 'entity' | 'input' | 'output',
254
+ prev: JsonSchema | undefined,
255
+ curr: JsonSchema | undefined
256
+ ): void => {
257
+ const prevProps = getProperties(prev);
258
+ const currProps = getProperties(curr);
259
+ const prevRequired = getRequired(prev);
260
+ const currRequired = getRequired(curr);
261
+ const allKeys = new Set([
262
+ ...Object.keys(prevProps),
263
+ ...Object.keys(currProps),
264
+ ]);
265
+
266
+ for (const key of [...allKeys].toSorted()) {
267
+ diffKey(
268
+ acc,
269
+ direction,
270
+ key,
271
+ prevProps,
272
+ currProps,
273
+ prevRequired,
274
+ currRequired
275
+ );
276
+ }
277
+ };
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Per-entry diffing
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /** Diff surface additions and removals. */
284
+ const diffSurfaces = (
285
+ acc: DetailAccumulator,
286
+ prev: TopoGraphEntry,
287
+ curr: TopoGraphEntry
288
+ ): void => {
289
+ const prevSurfaces = new Set(prev.surfaces);
290
+ const currSurfaces = new Set(curr.surfaces);
291
+ for (const surface of [...currSurfaces].toSorted()) {
292
+ if (!prevSurfaces.has(surface)) {
293
+ addDetail(acc, 'info', `Surface "${surface}" added`);
294
+ }
295
+ }
296
+ for (const surface of [...prevSurfaces].toSorted()) {
297
+ if (!currSurfaces.has(surface)) {
298
+ addDetail(acc, 'breaking', `Surface "${surface}" removed`);
299
+ }
300
+ }
301
+ };
302
+
303
+ /** Diff safety markers, description, and deprecation. */
304
+ const diffMetadata = (
305
+ acc: DetailAccumulator,
306
+ prev: TopoGraphEntry,
307
+ curr: TopoGraphEntry
308
+ ): void => {
309
+ if (prev.intent !== curr.intent) {
310
+ addDetail(
311
+ acc,
312
+ 'warning',
313
+ `intent changed: ${String(prev.intent ?? 'write')} -> ${String(curr.intent ?? 'write')}`
314
+ );
315
+ }
316
+ if (prev.idempotent !== curr.idempotent) {
317
+ addDetail(
318
+ acc,
319
+ 'warning',
320
+ `idempotent changed: ${String(prev.idempotent ?? false)} -> ${String(curr.idempotent ?? false)}`
321
+ );
322
+ }
323
+ if (prev.dryRunCapable !== curr.dryRunCapable) {
324
+ addDetail(
325
+ acc,
326
+ 'warning',
327
+ `dryRunCapable changed: ${String(prev.dryRunCapable ?? false)} -> ${String(curr.dryRunCapable ?? false)}`
328
+ );
329
+ }
330
+ const prevPermit = stableStringify(prev.permit ?? 'undeclared');
331
+ const currPermit = stableStringify(curr.permit ?? 'undeclared');
332
+ if (prevPermit !== currPermit) {
333
+ addDetail(
334
+ acc,
335
+ permitChangeSeverity(prev.permit, curr.permit),
336
+ `permit changed: ${prevPermit} -> ${currPermit}`
337
+ );
338
+ }
339
+ if (prev.description !== curr.description) {
340
+ addDetail(acc, 'info', 'Description updated');
341
+ }
342
+ if (!prev.deprecated && curr.deprecated) {
343
+ const msg = curr.replacedBy
344
+ ? `Deprecated (replaced by ${curr.replacedBy})`
345
+ : 'Deprecated';
346
+ addDetail(acc, 'warning', msg);
347
+ } else if (prev.deprecated && !curr.deprecated) {
348
+ addDetail(acc, 'info', 'Undeprecated');
349
+ }
350
+ };
351
+
352
+ const diffCliPath = (
353
+ acc: DetailAccumulator,
354
+ prev: TopoGraphEntry,
355
+ curr: TopoGraphEntry
356
+ ): void => {
357
+ const prevPath = prev.cli?.path.join(' ');
358
+ const currPath = curr.cli?.path.join(' ');
359
+
360
+ if (prevPath === currPath) {
361
+ return;
362
+ }
363
+
364
+ // First-time CLI path recording (upgrade from a lockfile without paths)
365
+ // is informational, not a breaking change.
366
+ if (prevPath === undefined && currPath !== undefined) {
367
+ addDetail(acc, 'info', `CLI path recorded: ${currPath}`);
368
+ return;
369
+ }
370
+
371
+ addDetail(
372
+ acc,
373
+ 'breaking',
374
+ `CLI path changed: ${prevPath ?? '(none)'} -> ${currPath ?? '(none)'}`
375
+ );
376
+ };
377
+
378
+ /** Build a composing-changed description from added/removed arrays. */
379
+ const buildComposesMessage = (added: string[], removed: string[]): string => {
380
+ const parts: string[] = [];
381
+ if (added.length > 0) {
382
+ parts.push(`added "${added.join('", "')}"`);
383
+ }
384
+ if (removed.length > 0) {
385
+ parts.push(`removed "${removed.join('", "')}"`);
386
+ }
387
+ return `Composes changed: ${parts.join(', ')}`;
388
+ };
389
+
390
+ const buildResourcesMessage = (added: string[], removed: string[]): string => {
391
+ const parts: string[] = [];
392
+ if (added.length > 0) {
393
+ parts.push(`added "${added.join('", "')}"`);
394
+ }
395
+ if (removed.length > 0) {
396
+ parts.push(`removed "${removed.join('", "')}"`);
397
+ }
398
+ return `Resources changed: ${parts.join(', ')}`;
399
+ };
400
+
401
+ const buildEntitiesMessage = (added: string[], removed: string[]): string => {
402
+ const parts: string[] = [];
403
+ if (added.length > 0) {
404
+ parts.push(`added "${added.join('", "')}"`);
405
+ }
406
+ if (removed.length > 0) {
407
+ parts.push(`removed "${removed.join('", "')}"`);
408
+ }
409
+ return `Entities changed: ${parts.join(', ')}`;
410
+ };
411
+
412
+ /** Diff composes arrays. */
413
+ const diffComposes = (
414
+ acc: DetailAccumulator,
415
+ prev: TopoGraphEntry,
416
+ curr: TopoGraphEntry
417
+ ): void => {
418
+ const prevComposes = new Set(prev.composes);
419
+ const currComposes = new Set(curr.composes);
420
+ const added = [...currComposes]
421
+ .filter((composedId) => !prevComposes.has(composedId))
422
+ .toSorted();
423
+ const removed = [...prevComposes]
424
+ .filter((composedId) => !currComposes.has(composedId))
425
+ .toSorted();
426
+ if (added.length > 0 || removed.length > 0) {
427
+ addDetail(acc, 'warning', buildComposesMessage(added, removed));
428
+ }
429
+ };
430
+
431
+ /** Diff declared resource arrays on trail entries. */
432
+ const diffResources = (
433
+ acc: DetailAccumulator,
434
+ prev: TopoGraphEntry,
435
+ curr: TopoGraphEntry
436
+ ): void => {
437
+ const prevResources = new Set(prev.resources);
438
+ const currResources = new Set(curr.resources);
439
+ const added = [...currResources]
440
+ .filter((resource) => !prevResources.has(resource))
441
+ .toSorted();
442
+ const removed = [...prevResources]
443
+ .filter((resource) => !currResources.has(resource))
444
+ .toSorted();
445
+ if (added.length > 0 || removed.length > 0) {
446
+ addDetail(acc, 'warning', buildResourcesMessage(added, removed));
447
+ }
448
+ };
449
+
450
+ const isLiveVersionEntry = (entry: TopoGraphVersionEntry): boolean =>
451
+ entry.status?.state !== 'archived';
452
+
453
+ const statusLabel = (
454
+ status: TopoGraphVersionEntry['status'] | undefined
455
+ ): string => status?.state ?? 'live';
456
+
457
+ const diffNumberSet = (
458
+ acc: DetailAccumulator,
459
+ label: string,
460
+ previous: readonly number[] | undefined,
461
+ current: readonly number[] | undefined,
462
+ removedSeverity: Severity
463
+ ): void => {
464
+ const prevValues = new Set(previous);
465
+ const currValues = new Set(current);
466
+ const added = [...currValues]
467
+ .filter((value) => !prevValues.has(value))
468
+ .toSorted((left, right) => left - right);
469
+ const removed = [...prevValues]
470
+ .filter((value) => !currValues.has(value))
471
+ .toSorted((left, right) => left - right);
472
+
473
+ if (added.length > 0) {
474
+ addDetail(acc, 'info', `${label} added: ${added.join(', ')}`);
475
+ }
476
+ if (removed.length > 0) {
477
+ addDetail(acc, removedSeverity, `${label} removed: ${removed.join(', ')}`);
478
+ }
479
+ };
480
+
481
+ const diffVersionSchemaFields = (
482
+ acc: DetailAccumulator,
483
+ version: string,
484
+ prev: TopoGraphVersionEntry,
485
+ curr: TopoGraphVersionEntry
486
+ ): void => {
487
+ const versionAcc: DetailAccumulator = { details: [], severity: 'info' };
488
+ diffSchemaFields(versionAcc, 'input', prev.input, curr.input);
489
+ diffSchemaFields(versionAcc, 'output', prev.output, curr.output);
490
+
491
+ for (const detail of versionAcc.details) {
492
+ addDetail(acc, versionAcc.severity, `Version ${version} ${detail}`);
493
+ }
494
+ };
495
+
496
+ const diffVersionEntry = (
497
+ acc: DetailAccumulator,
498
+ version: string,
499
+ prev: TopoGraphVersionEntry,
500
+ curr: TopoGraphVersionEntry
501
+ ): void => {
502
+ if (prev.kind !== curr.kind) {
503
+ addDetail(
504
+ acc,
505
+ 'breaking',
506
+ `Version ${version} kind changed: ${prev.kind} -> ${curr.kind}`
507
+ );
508
+ }
509
+
510
+ const prevStatus = statusLabel(prev.status);
511
+ const currStatus = statusLabel(curr.status);
512
+ if (prevStatus !== currStatus) {
513
+ addDetail(
514
+ acc,
515
+ currStatus === 'archived' ? 'warning' : 'info',
516
+ `Version ${version} status changed: ${prevStatus} -> ${currStatus}`
517
+ );
518
+ }
519
+
520
+ if (prev.marker !== curr.marker) {
521
+ addDetail(
522
+ acc,
523
+ 'info',
524
+ `Version ${version} marker changed: ${prev.marker} -> ${curr.marker}`
525
+ );
526
+ }
527
+
528
+ diffVersionSchemaFields(acc, version, prev, curr);
529
+ };
530
+
531
+ const diffVersionEntries = (
532
+ acc: DetailAccumulator,
533
+ prev: TopoGraphEntry,
534
+ curr: TopoGraphEntry
535
+ ): void => {
536
+ diffNumberSet(
537
+ acc,
538
+ 'Supported versions',
539
+ prev.supports,
540
+ curr.supports,
541
+ 'breaking'
542
+ );
543
+
544
+ if (prev.version !== curr.version) {
545
+ addDetail(
546
+ acc,
547
+ 'info',
548
+ `Current version changed: ${String(prev.version ?? '(none)')} -> ${String(curr.version ?? '(none)')}`
549
+ );
550
+ }
551
+
552
+ if (prev.marker !== curr.marker) {
553
+ addDetail(
554
+ acc,
555
+ 'info',
556
+ `Current marker changed: ${String(prev.marker ?? '(none)')} -> ${String(curr.marker ?? '(none)')}`
557
+ );
558
+ }
559
+
560
+ const prevVersions = prev.versions ?? {};
561
+ const currVersions = curr.versions ?? {};
562
+ const versions = new Set([
563
+ ...Object.keys(prevVersions),
564
+ ...Object.keys(currVersions),
565
+ ]);
566
+
567
+ for (const version of [...versions].toSorted(
568
+ (left, right) => Number(left) - Number(right)
569
+ )) {
570
+ const previous = prevVersions[version];
571
+ const current = currVersions[version];
572
+ if (previous === undefined && current !== undefined) {
573
+ addDetail(
574
+ acc,
575
+ isLiveVersionEntry(current) ? 'warning' : 'info',
576
+ `Version ${version} added (${statusLabel(current.status)})`
577
+ );
578
+ continue;
579
+ }
580
+ if (previous !== undefined && current === undefined) {
581
+ addDetail(
582
+ acc,
583
+ isLiveVersionEntry(previous) ? 'breaking' : 'warning',
584
+ `Version ${version} removed (${statusLabel(previous.status)})`
585
+ );
586
+ continue;
587
+ }
588
+ if (previous !== undefined && current !== undefined) {
589
+ diffVersionEntry(acc, version, previous, current);
590
+ }
591
+ }
592
+ };
593
+
594
+ const diffVersionExampleCoverage = (
595
+ acc: DetailAccumulator,
596
+ prev: TopoGraphEntry,
597
+ curr: TopoGraphEntry
598
+ ): void => {
599
+ const previousVersions = prev.versions ?? {};
600
+ for (const [version, entry] of Object.entries(curr.versions ?? {}).toSorted(
601
+ ([left], [right]) => Number(left) - Number(right)
602
+ )) {
603
+ if (!isLiveVersionEntry(entry)) {
604
+ continue;
605
+ }
606
+
607
+ const previous = previousVersions[version];
608
+ if (entry.exampleCount === 0 && previous === undefined) {
609
+ addDetail(
610
+ acc,
611
+ 'warning',
612
+ `Live version ${version} added without examples`
613
+ );
614
+ continue;
615
+ }
616
+
617
+ if (
618
+ previous !== undefined &&
619
+ isLiveVersionEntry(previous) &&
620
+ previous.exampleCount > 0 &&
621
+ entry.exampleCount === 0
622
+ ) {
623
+ addDetail(
624
+ acc,
625
+ 'warning',
626
+ `Live version ${version} example coverage removed`
627
+ );
628
+ continue;
629
+ }
630
+
631
+ if (previous?.exampleCount !== entry.exampleCount) {
632
+ addDetail(
633
+ acc,
634
+ 'info',
635
+ `Live version ${version} examples: ${previous?.exampleCount ?? 0} -> ${entry.exampleCount}`
636
+ );
637
+ }
638
+ }
639
+ };
640
+
641
+ const forceKey = (force: TopoGraphForceEntry): string =>
642
+ stableStringify({
643
+ change: force.change,
644
+ detail: force.detail,
645
+ id: force.id,
646
+ kind: force.kind,
647
+ reason: force.reason,
648
+ severity: force.severity,
649
+ source: force.source,
650
+ });
651
+
652
+ const diffForces = (
653
+ acc: DetailAccumulator,
654
+ prev: TopoGraphEntry,
655
+ curr: TopoGraphEntry
656
+ ): void => {
657
+ const prevForces = new Map(
658
+ (prev.forces ?? []).map((force) => [forceKey(force), force])
659
+ );
660
+ const currForces = new Map(
661
+ (curr.forces ?? []).map((force) => [forceKey(force), force])
662
+ );
663
+
664
+ for (const [key, force] of [...currForces].toSorted(([left], [right]) =>
665
+ left.localeCompare(right)
666
+ )) {
667
+ if (!prevForces.has(key)) {
668
+ addDetail(
669
+ acc,
670
+ 'warning',
671
+ `Force event recorded: ${force.change} ${force.detail}`
672
+ );
673
+ }
674
+ }
675
+
676
+ for (const [key, force] of [...prevForces].toSorted(([left], [right]) =>
677
+ left.localeCompare(right)
678
+ )) {
679
+ if (!currForces.has(key)) {
680
+ addDetail(
681
+ acc,
682
+ 'warning',
683
+ `Force event removed: ${force.change} ${force.detail}`
684
+ );
685
+ }
686
+ }
687
+ };
688
+
689
+ const forceDiffEntry = (
690
+ force: TopoGraphForceEntry,
691
+ direction: 'recorded' | 'removed'
692
+ ): DiffEntry => ({
693
+ change: 'modified',
694
+ details: [`Force event ${direction}: ${force.change} ${force.detail}`],
695
+ id: force.id,
696
+ kind: force.kind,
697
+ severity: 'warning',
698
+ });
699
+
700
+ const diffGraphForces = (prev: TopoGraph, curr: TopoGraph): DiffEntry[] => {
701
+ const prevForces = new Map(
702
+ (prev.forces ?? []).map((force) => [forceKey(force), force])
703
+ );
704
+ const currForces = new Map(
705
+ (curr.forces ?? []).map((force) => [forceKey(force), force])
706
+ );
707
+ const entries: DiffEntry[] = [];
708
+
709
+ for (const [key, force] of [...currForces].toSorted(([left], [right]) =>
710
+ left.localeCompare(right)
711
+ )) {
712
+ if (!prevForces.has(key)) {
713
+ entries.push(forceDiffEntry(force, 'recorded'));
714
+ }
715
+ }
716
+
717
+ for (const [key, force] of [...prevForces].toSorted(([left], [right]) =>
718
+ left.localeCompare(right)
719
+ )) {
720
+ if (!currForces.has(key)) {
721
+ entries.push(forceDiffEntry(force, 'removed'));
722
+ }
723
+ }
724
+
725
+ return entries;
726
+ };
727
+
728
+ const setDelta = (
729
+ prevValues: readonly string[] | undefined,
730
+ currValues: readonly string[] | undefined
731
+ ): {
732
+ readonly added: readonly string[];
733
+ readonly removed: readonly string[];
734
+ } => {
735
+ const prevSet = new Set(prevValues);
736
+ const currSet = new Set(currValues);
737
+ return {
738
+ added: [...currSet].filter((value) => !prevSet.has(value)).toSorted(),
739
+ removed: [...prevSet].filter((value) => !currSet.has(value)).toSorted(),
740
+ };
741
+ };
742
+
743
+ const addSetChangeDetail = (
744
+ acc: DetailAccumulator,
745
+ label: string,
746
+ previous: readonly string[] | undefined,
747
+ current: readonly string[] | undefined,
748
+ removedSeverity: Severity
749
+ ): void => {
750
+ const { added, removed } = setDelta(previous, current);
751
+ if (added.length > 0) {
752
+ addDetail(acc, 'info', `${label} added: "${added.join('", "')}"`);
753
+ }
754
+ if (removed.length > 0) {
755
+ addDetail(
756
+ acc,
757
+ removedSeverity,
758
+ `${label} removed: "${removed.join('", "')}"`
759
+ );
760
+ }
761
+ };
762
+
763
+ const diffTrailhead = (
764
+ prev: TopoGraphTrailheadEntry,
765
+ curr: TopoGraphTrailheadEntry
766
+ ): DiffEntry | undefined => {
767
+ const acc: DetailAccumulator = { details: [], severity: 'info' };
768
+
769
+ addSetChangeDetail(
770
+ acc,
771
+ 'Trailhead member',
772
+ prev.memberIds,
773
+ curr.memberIds,
774
+ 'warning'
775
+ );
776
+ addSetChangeDetail(
777
+ acc,
778
+ 'Trailhead surface',
779
+ prev.surfaces,
780
+ curr.surfaces,
781
+ 'warning'
782
+ );
783
+
784
+ if (prev.memberSetHash !== curr.memberSetHash) {
785
+ addDetail(acc, 'warning', 'Trailhead member-set hash changed');
786
+ }
787
+ if (prev.description !== curr.description) {
788
+ addDetail(acc, 'info', 'Description updated');
789
+ }
790
+ if (prev.visibility !== curr.visibility) {
791
+ addDetail(
792
+ acc,
793
+ 'warning',
794
+ `visibility changed: ${String(prev.visibility ?? 'public')} -> ${String(curr.visibility ?? 'public')}`
795
+ );
796
+ }
797
+ if (prev.descriptionStableThrough !== curr.descriptionStableThrough) {
798
+ addDetail(acc, 'info', 'descriptionStableThrough updated');
799
+ }
800
+ if (prev.visibilityWideningAccepted !== curr.visibilityWideningAccepted) {
801
+ addDetail(acc, 'info', 'visibilityWideningAccepted updated');
802
+ }
803
+
804
+ if (acc.details.length === 0) {
805
+ return undefined;
806
+ }
807
+
808
+ return {
809
+ change: 'modified',
810
+ details: acc.details,
811
+ id: curr.id,
812
+ kind: 'trailhead',
813
+ severity: acc.severity,
814
+ };
815
+ };
816
+
817
+ const diffTrailheads = (prev: TopoGraph, curr: TopoGraph): DiffEntry[] => {
818
+ const prevById = new Map(
819
+ (prev.trailheads ?? []).map((trailhead) => [trailhead.id, trailhead])
820
+ );
821
+ const currById = new Map(
822
+ (curr.trailheads ?? []).map((trailhead) => [trailhead.id, trailhead])
823
+ );
824
+ const entries: DiffEntry[] = [];
825
+
826
+ for (const [id, trailhead] of [...currById.entries()].toSorted(([a], [b]) =>
827
+ a.localeCompare(b)
828
+ )) {
829
+ const previous = prevById.get(id);
830
+ if (previous === undefined) {
831
+ entries.push({
832
+ change: 'added',
833
+ details: [`Trailhead "${id}" added`],
834
+ id,
835
+ kind: 'trailhead',
836
+ severity: 'info',
837
+ });
838
+ continue;
839
+ }
840
+ const diff = diffTrailhead(previous, trailhead);
841
+ if (diff !== undefined) {
842
+ entries.push(diff);
843
+ }
844
+ }
845
+
846
+ for (const [id] of [...prevById.entries()].toSorted(([a], [b]) =>
847
+ a.localeCompare(b)
848
+ )) {
849
+ if (!currById.has(id)) {
850
+ entries.push({
851
+ change: 'removed',
852
+ details: [`Trailhead "${id}" removed`],
853
+ id,
854
+ kind: 'trailhead',
855
+ severity: 'warning',
856
+ });
857
+ }
858
+ }
859
+
860
+ return entries;
861
+ };
862
+
863
+ /** Diff declared entity arrays on trail entries. */
864
+ const diffEntities = (
865
+ acc: DetailAccumulator,
866
+ prev: TopoGraphEntry,
867
+ curr: TopoGraphEntry
868
+ ): void => {
869
+ const prevEntities = new Set(prev.entities);
870
+ const currEntities = new Set(curr.entities);
871
+ const added = [...currEntities]
872
+ .filter((entity) => !prevEntities.has(entity))
873
+ .toSorted();
874
+ const removed = [...prevEntities]
875
+ .filter((entity) => !currEntities.has(entity))
876
+ .toSorted();
877
+ if (added.length > 0 || removed.length > 0) {
878
+ addDetail(acc, 'warning', buildEntitiesMessage(added, removed));
879
+ }
880
+ };
881
+
882
+ const diffEntitySchema = (
883
+ acc: DetailAccumulator,
884
+ prev: TopoGraphEntry,
885
+ curr: TopoGraphEntry
886
+ ): void => {
887
+ diffSchemaFields(acc, 'entity', prev.schema, curr.schema);
888
+
889
+ if (prev.identity !== curr.identity) {
890
+ addDetail(
891
+ acc,
892
+ 'breaking',
893
+ `Entity identity changed: ${String(prev.identity ?? '(none)')} -> ${String(curr.identity ?? '(none)')}`
894
+ );
895
+ }
896
+ };
897
+
898
+ const referenceLabel = (reference: {
899
+ readonly entity: string;
900
+ readonly field: string;
901
+ readonly identity: string;
902
+ }): string => `${reference.field}:${reference.entity}.${reference.identity}`;
903
+
904
+ const diffEntityReferences = (
905
+ acc: DetailAccumulator,
906
+ prev: TopoGraphEntry,
907
+ curr: TopoGraphEntry
908
+ ): void => {
909
+ const prevReferences = new Set(
910
+ (prev.references ?? []).map((reference) => referenceLabel(reference))
911
+ );
912
+ const currReferences = new Set(
913
+ (curr.references ?? []).map((reference) => referenceLabel(reference))
914
+ );
915
+ const added = [...currReferences]
916
+ .filter((reference) => !prevReferences.has(reference))
917
+ .toSorted();
918
+ const removed = [...prevReferences]
919
+ .filter((reference) => !currReferences.has(reference))
920
+ .toSorted();
921
+
922
+ if (added.length > 0) {
923
+ addDetail(
924
+ acc,
925
+ 'warning',
926
+ `Entity references added: "${added.join('", "')}"`
927
+ );
928
+ }
929
+
930
+ if (removed.length > 0) {
931
+ addDetail(
932
+ acc,
933
+ 'breaking',
934
+ `Entity references removed: "${removed.join('", "')}"`
935
+ );
936
+ }
937
+ };
938
+
939
+ const diffTrailEntryDetails = (
940
+ acc: DetailAccumulator,
941
+ prev: TopoGraphEntry,
942
+ curr: TopoGraphEntry
943
+ ): void => {
944
+ diffSchemaFields(acc, 'input', prev.input, curr.input);
945
+ diffSchemaFields(acc, 'output', prev.output, curr.output);
946
+ diffCliPath(acc, prev, curr);
947
+ diffComposes(acc, prev, curr);
948
+ diffEntities(acc, prev, curr);
949
+ diffResources(acc, prev, curr);
950
+ diffVersionEntries(acc, prev, curr);
951
+ diffVersionExampleCoverage(acc, prev, curr);
952
+ diffForces(acc, prev, curr);
953
+ };
954
+
955
+ const diffEntryDetails = (
956
+ acc: DetailAccumulator,
957
+ prev: TopoGraphEntry,
958
+ curr: TopoGraphEntry
959
+ ): void => {
960
+ diffSurfaces(acc, prev, curr);
961
+ diffMetadata(acc, prev, curr);
962
+
963
+ if (curr.kind === 'entity') {
964
+ diffEntitySchema(acc, prev, curr);
965
+ diffEntityReferences(acc, prev, curr);
966
+ return;
967
+ }
968
+
969
+ diffTrailEntryDetails(acc, prev, curr);
970
+ };
971
+
972
+ const diffEntry = (
973
+ prev: TopoGraphEntry,
974
+ curr: TopoGraphEntry
975
+ ): DiffEntry | undefined => {
976
+ const acc: DetailAccumulator = { details: [], severity: 'info' };
977
+
978
+ diffEntryDetails(acc, prev, curr);
979
+
980
+ if (acc.details.length === 0) {
981
+ return undefined;
982
+ }
983
+
984
+ return {
985
+ change: 'modified',
986
+ details: acc.details,
987
+ id: curr.id,
988
+ kind: curr.kind,
989
+ severity: acc.severity,
990
+ };
991
+ };
992
+
993
+ // ---------------------------------------------------------------------------
994
+ // Public API
995
+ // ---------------------------------------------------------------------------
996
+
997
+ /**
998
+ * Compute a semantic diff between two topo graphs.
999
+ *
1000
+ * Classifies each change with a severity:
1001
+ * - `info`: new trail, optional field added, output field added, description change
1002
+ * - `warning`: safety marker change, deprecation, composing change
1003
+ * - `breaking`: trail removed, required input added, field removed, type change, surface removed
1004
+ */
1005
+ /** Find entries added in curr that don't exist in prev. */
1006
+ const findAdded = (
1007
+ prevById: Map<string, TopoGraphEntry>,
1008
+ currById: Map<string, TopoGraphEntry>
1009
+ ): DiffEntry[] =>
1010
+ [...currById.entries()]
1011
+ .filter(([id]) => !prevById.has(id))
1012
+ .map(([id, entry]) => ({
1013
+ change: 'added' as const,
1014
+ details: [`${labelForKind(entry.kind)} "${id}" added`],
1015
+ id,
1016
+ kind: entry.kind,
1017
+ severity: 'info' as const,
1018
+ }));
1019
+
1020
+ /** Find entries removed from prev that don't exist in curr. */
1021
+ const findRemoved = (
1022
+ prevById: Map<string, TopoGraphEntry>,
1023
+ currById: Map<string, TopoGraphEntry>
1024
+ ): DiffEntry[] =>
1025
+ [...prevById.entries()]
1026
+ .filter(([id]) => !currById.has(id))
1027
+ .map(([id, entry]) => ({
1028
+ change: 'removed' as const,
1029
+ details: [`${labelForKind(entry.kind)} "${id}" removed`],
1030
+ id,
1031
+ kind: entry.kind,
1032
+ severity: 'breaking' as const,
1033
+ }));
1034
+
1035
+ /** Find entries modified between prev and curr. */
1036
+ const findModified = (
1037
+ prevById: Map<string, TopoGraphEntry>,
1038
+ currById: Map<string, TopoGraphEntry>
1039
+ ): DiffEntry[] => {
1040
+ const results: DiffEntry[] = [];
1041
+ for (const [id, currEntry] of currById) {
1042
+ const prevEntry = prevById.get(id);
1043
+ if (prevEntry) {
1044
+ const diff = diffEntry(prevEntry, currEntry);
1045
+ if (diff) {
1046
+ results.push(diff);
1047
+ }
1048
+ }
1049
+ }
1050
+ return results;
1051
+ };
1052
+
1053
+ /** Collect all diff entries (added, removed, modified) between two maps. */
1054
+ const collectDiffEntries = (
1055
+ prev: TopoGraph,
1056
+ curr: TopoGraph,
1057
+ prevById: Map<string, TopoGraphEntry>,
1058
+ currById: Map<string, TopoGraphEntry>
1059
+ ): DiffEntry[] => [
1060
+ ...findAdded(prevById, currById),
1061
+ ...findRemoved(prevById, currById),
1062
+ ...findModified(prevById, currById),
1063
+ ...diffGraphForces(prev, curr),
1064
+ ...diffTrailheads(prev, curr),
1065
+ ];
1066
+
1067
+ export const deriveTopoGraphDiff = (
1068
+ prev: TopoGraph,
1069
+ curr: TopoGraph
1070
+ ): DiffResult => {
1071
+ const prevById = new Map(prev.entries.map((e) => [e.id, e]));
1072
+ const currById = new Map(curr.entries.map((e) => [e.id, e]));
1073
+ const sorted = collectDiffEntries(prev, curr, prevById, currById).toSorted(
1074
+ (a, b) => a.id.localeCompare(b.id)
1075
+ );
1076
+
1077
+ const breaking = sorted.filter((e) => e.severity === 'breaking');
1078
+ const warnings = sorted.filter((e) => e.severity === 'warning');
1079
+ const info = sorted.filter((e) => e.severity === 'info');
1080
+
1081
+ return {
1082
+ breaking,
1083
+ entries: sorted,
1084
+ hasBreaking: breaking.length > 0,
1085
+ info,
1086
+ warnings,
1087
+ };
1088
+ };