@ontrails/topography 1.0.0-beta.41
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/CHANGELOG.md +543 -0
- package/README.md +196 -0
- package/package.json +33 -0
- package/src/activation-report.ts +385 -0
- package/src/backend-support.ts +11 -0
- package/src/derive.ts +690 -0
- package/src/diff.ts +1088 -0
- package/src/forces.ts +125 -0
- package/src/hash.ts +55 -0
- package/src/index.ts +272 -0
- package/src/internal/topo-snapshots.ts +682 -0
- package/src/internal/topo-store-read.ts +1102 -0
- package/src/internal/topo-store.ts +1651 -0
- package/src/io.ts +280 -0
- package/src/library-projection.ts +133 -0
- package/src/overlays.ts +155 -0
- package/src/permit.ts +19 -0
- package/src/source-fingerprint.ts +103 -0
- package/src/surface-bindings.ts +101 -0
- package/src/topo-store.ts +716 -0
- package/src/types.ts +749 -0
- package/src/versioning.ts +280 -0
- package/src/wayfind/error-facts.ts +363 -0
- package/src/wayfind/filters.ts +430 -0
- package/src/wayfind/loader.ts +443 -0
- package/src/wayfind/navigation.ts +265 -0
- package/src/wayfind/provenance.ts +152 -0
- package/src/wayfind/queries.ts +1656 -0
- package/src/wayfind/relations.ts +344 -0
- package/src/workspace-topos.ts +483 -0
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
|
+
};
|