Repository navigation
Expand file tree
/
Copy pathdiff.mjs
More file actions
648 lines (589 loc) · 25.9 KB
/
Copy pathdiff.mjs
File metadata and controls
648 lines (589 loc) · 25.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
// tools/lib/diff.mjs
//
// Pack arithmetic — set operations on the artefact symbols of two adapted
// layered packs. Pure ESM, no Node APIs, so the client could (and may, in
// a future restoration) call it directly.
//
// MATCHING
// Two artefacts "are the same" when they share a behavioural identity —
// NOT a name. The behavioural model (tools/lib/artefact-model.mjs) builds a
// typed object per artefact family: an `identity` derived from what the
// artefact DOES (a backend's product+signal, a metric's series name, a
// rule's output series, a panel's binding target) and a `behavior` that
// captures its full deployed contract. `identityKeyOf` pairs A↔B by
// behaviour; `behaviorEqual` / `deltasOf` decide aligned vs drifted.
//
// SET OPS
// For each layer we produce these buckets:
// - onlyInA artefacts present in A but not B
// - onlyInB artefacts present in B but not A, in a family A also declares
// - inBoth matched pairs, with both A's and B's projection so the
// caller can show spec differences side-by-side
// - outOfScope present in B, in a family A declares NOTHING of — the rest
// of the platform's inventory, kept out of the drift headline
// - scaffold artefacts whose `source` is 'Scaffold' on EITHER side — the
// schema-forced placeholders a crawler or the live fetcher had
// to invent (crawler.scaffold.* / mcp.scaffold.*). A
// placeholder is not a declaration and not live evidence, so
// it never pairs: a declared burn-rate alert must not read
// `aligned` against the live pack's fallback entry, and a repo
// placeholder must not read `declared, not live`. Parked here,
// outside every ratio.
// - notObserved artefacts one pack holds in a family the OTHER pack says
// it had no way to look at (its `observogram.unobserved.<kind>`
// annotation: no tool for it, or the tool failed). "Declared,
// and the live side could not check" is a different statement
// from "declared, and not there": a Grafana that refuses the
// fetcher's login does not make seven dashboards missing.
// Each entry carries the `side` that holds the artefact and
// the other side's `reason`. Outside every ratio.
// A live snapshot read with a SCOPE (metric prefixes, folder
// uids — its `observogram.scope.<kind>` annotations,
// tools/lib/live-fetch.mjs) did not look outside it either:
// an unmatched artefact on the other side, in a scoped
// family, lands here when it is outside the scope ("outside
// the snapshot's metric scope (prefixes …)") or when it does
// not say what the scope needs — a crawled dashboard names no
// folder uid — with a reason that states only that. An
// artefact inside the scope is compared as usual.
//
// METRIC FAMILIES
// Metrics are compared as families, not series: a declared histogram and
// the `_bucket` / `_count` / `_sum` series a store lists for it are one
// metric (artefact-model.mjs, foldMetricFamilies). A family's entry carries
// `series`, the names it stands for on that side.
//
// IDENTITY MODES
// `diffPacks(a, b, { identity })` pairs by another key: a mode id of
// tools/lib/identity-modes.mjs (`'behaviour'`, `'name'`, `'id'`) or a
// function `(artefact, { side }) → '<kind>::…'`. Behaviour still decides
// aligned vs drifted. Omitted, `'behaviour'` or `identityKeyOf` itself is
// the default path, byte-identical, with no `identity` key in the answer;
// any other adds `identity: { mode: 'name' | 'id' | 'custom' }`. The key
// must keep the `<kind>::` prefix (scope, notObserved and collisions read
// it): a function that drops it is a TypeError. The `side` lets a key that
// must never pair (an artefact with no name) differ between A and B.
//
// The classic operations follow (over the concrete, non-scaffold artefacts):
// A ∪ B = onlyInA ∪ inBoth ∪ onlyInB
// A ∩ B = inBoth
// A − B = onlyInA
// B − A = onlyInB ∪ outOfScope
import {
classify,
identityKeyOf,
behaviorOf,
deltasOf,
foldMetricFamilies,
} from './artefact-model.mjs';
import { scopeOf, inScope, scopeReason } from './live-fetch.mjs';
import { identityMode, DEFAULT_IDENTITY_MODE } from './identity-modes.mjs';
const LAYER_ORDER = ['L1', 'L2', 'L2X', 'L3', 'L4', 'L5', 'GOV'];
// Behavioural identity key for pairing A↔B. Delegates to the artefact model so
// matching is driven by what an artefact does, never by its name or position.
// Exported under the historical `keyOf` name for backward compatibility.
export function keyOf(artefact) {
return identityKeyOf(artefact);
}
// The full behavioural contract object of an artefact — what "compare the
// contents" actually compares. Retained under the `projectOf` name for
// backward compatibility; delegates to the artefact model.
export function projectOf(artefact) {
return behaviorOf(artefact);
}
// Re-export the behavioural delta helper so existing importers keep working.
export { deltasOf };
function layerArtefacts(layered, layerId) {
const ls = layered?.layers || {};
const comparable = (artefact) => classify(artefact) !== 'panel';
if (layerId === 'L4') {
return [
...(ls.L4?.policy || []),
...(ls.L4?.alerting || []),
...(ls.L4?.healing || []),
].filter(comparable);
}
return (ls[layerId] || []).filter(comparable);
}
function packMeta(layered) {
return {
id: layered?.id,
name: layered?.name,
service: layered?.meta?.service,
criticality: layered?.meta?.criticality,
environment: layered?.meta?.environment,
version: layered?.meta?.version,
binding: layered?.meta?.binding,
};
}
// The pairing key of `opts.identity`: null on the default path (behaviour),
// else { mode, keyOf(artefact, { side }) }.
function resolveIdentity(identity) {
if (identity == null || identity === DEFAULT_IDENTITY_MODE || identity === identityKeyOf) return null;
if (typeof identity === 'string') {
const row = identityMode(identity);
return { mode: row.id, keyOf: row.keyOf };
}
if (typeof identity === 'function') return { mode: 'custom', keyOf: prefixChecked(identity) };
throw new TypeError(`diffPacks: identity must be a mode id or a function (got ${typeof identity})`);
}
function prefixChecked(fn) {
return (artefact, ctx) => {
const key = fn(artefact, ctx);
const kind = classify(artefact);
if (typeof key !== 'string' || !key.startsWith(`${kind}::`)) {
throw new TypeError(`diffPacks: an identity function must return "${kind}::…" for each artefact (got ${JSON.stringify(key)} for a ${kind})`);
}
return key;
};
}
export function diffPacks(aLayered, bLayered, opts = {}) {
if (!aLayered || !bLayered) throw new Error('diffPacks: both packs required');
const identity = resolveIdentity(opts.identity);
const pairKey = identity ? identity.keyOf : keyOf;
const scopeMode = normalizeScopeMode(opts.scopeMode);
const serviceScope = buildServiceScope(aLayered, opts.service);
// The artefact families each pack says it could not observe, with why.
const aBlind = unobservedKinds(aLayered);
const bBlind = unobservedKinds(bLayered);
// The families each pack read only within a scope (a live snapshot's).
const aScoped = scopeOf(aLayered?.meta?.annotations);
const bScoped = scopeOf(bLayered?.meta?.annotations);
const layers = {};
const collisions = [];
let onlyInA = 0, onlyInB = 0, inBoth = 0, aligned = 0, drifted = 0, outOfScope = 0, scaffold = 0, notObserved = 0;
for (const layerId of LAYER_ORDER) {
const aAll = layerArtefacts(aLayered, layerId);
const bAll = layerArtefacts(bLayered, layerId);
// Placeholders never pair (see the header): park them before matching
// so a declared artefact cannot align with, or drift against, a
// schema-forced fallback on the other side.
// Metrics pair as families (see the header), folded with what both
// sides know about which series belong together.
const folded = foldMetricFamilies(
aAll.filter((x) => !isScaffoldArtefact(x)),
bAll.filter((x) => !isScaffoldArtefact(x)),
);
const aItems = folded.a;
const bItems = folded.b;
const parked = [
...aAll.filter(isScaffoldArtefact).map((artefact) => ({ side: 'a', artefact })),
...bAll.filter(isScaffoldArtefact).map((artefact) => ({ side: 'b', artefact })),
];
const aByKey = groupByKey(aItems, pairKey, 'a');
const bByKey = groupByKey(bItems, pairKey, 'b');
collectCollisions(collisions, layerId, aByKey, bByKey);
// Kinds (artefact families) the declared side (A) actually participates in
// for this layer. The behavioural key is `${kind}::${identity}`, so the
// prefix is the family. When A contributes ZERO artefacts of a family, B's
// artefacts of that family are out of the declared pack's SCOPE, not
// actionable drift: comparing one service's declaration against a
// whole-platform live inventory would otherwise flood "live, not declared"
// with the entire fleet. Declare even one artefact of a family and the rest
// of that family's live members become in-scope (genuine shadow signal).
const aKinds = new Set();
for (const k of aByKey.keys()) aKinds.add(k.slice(0, k.indexOf('::')));
const bucket = { onlyInA: [], onlyInB: [], inBoth: [], outOfScope: [], scaffold: [], notObserved: [] };
for (const [k, aGroup] of aByKey) {
const kind = k.slice(0, k.indexOf('::'));
if (bByKey.has(k)) {
matchGroups(k, aGroup, bByKey.get(k), bucket);
} else {
// B never looked at this family, or read it only within a scope this
// artefact is not in: A's artefact is unchecked, not absent.
const unchecked = bBlind.has(kind) ? bBlind.get(kind) : outsideScope(kind, aGroup, bScoped, 'a');
if (unchecked) pushNotObserved(bucket.notObserved, k, aGroup, 'a', unchecked);
else pushUnmatched(bucket.onlyInA, k, aGroup);
}
}
for (const [k, bGroup] of bByKey) {
if (aByKey.has(k)) continue;
const kind = k.slice(0, k.indexOf('::'));
const unchecked = aBlind.has(kind) ? aBlind.get(kind) : outsideScope(kind, bGroup, aScoped, 'b');
if (unchecked) {
pushNotObserved(bucket.notObserved, k, bGroup, 'b', unchecked);
} else if (scopeMode === 'service' && isOutsideServiceScope(bGroup, serviceScope)) {
pushUnmatched(bucket.outOfScope, k, bGroup);
} else if (aKinds.has(kind) || scopeMode === 'all') {
pushUnmatched(bucket.onlyInB, k, bGroup);
} else {
pushUnmatched(bucket.outOfScope, k, bGroup);
}
}
// Stable order — alphabetical by key — so the UI doesn't reshuffle on
// every load.
bucket.onlyInA.sort((x, y) => x.key.localeCompare(y.key, 'en'));
bucket.onlyInB.sort((x, y) => x.key.localeCompare(y.key, 'en'));
bucket.inBoth.sort ((x, y) => x.key.localeCompare(y.key, 'en'));
bucket.outOfScope.sort((x, y) => x.key.localeCompare(y.key, 'en'));
bucket.notObserved.sort((x, y) => `${x.side}:${x.key}`.localeCompare(`${y.side}:${y.key}`, 'en'));
// Parked placeholders keep their behavioural key (with the side, so a
// placeholder present on both sides stays two entries) for display.
const parkedKey = ({ side, artefact }) => pairKey(artefact, { side });
parked
.sort((x, y) => `${x.side}:${parkedKey(x)}`.localeCompare(`${y.side}:${parkedKey(y)}`, 'en'))
.forEach((entry, i) => {
const { side, artefact } = entry;
bucket.scaffold.push({ key: `${parkedKey(entry)}@${side}#${String(i + 1).padStart(2, '0')}`, side, artefact });
});
// Per-layer aligned/drifted split of the matched pairs.
bucket.aligned = bucket.inBoth.filter((e) => e.match === 'aligned').length;
bucket.drifted = bucket.inBoth.filter((e) => e.match === 'drifted').length;
layers[layerId] = bucket;
onlyInA += bucket.onlyInA.length;
onlyInB += bucket.onlyInB.length;
inBoth += bucket.inBoth.length;
aligned += bucket.aligned;
drifted += bucket.drifted;
outOfScope += bucket.outOfScope.length;
scaffold += bucket.scaffold.length;
notObserved += bucket.notObserved.length;
}
return {
a: packMeta(aLayered),
b: packMeta(bLayered),
scope: diffScopeMeta(scopeMode, serviceScope),
// Only off the default path: which key paired A with B.
...(identity ? { identity: { mode: identity.mode } } : {}),
// Identity keys held by more than one artefact on either side. The bucket
// entries already preserve every instance via `#NN` occurrence suffixes;
// this is the explicit fail-loud surface so callers (and downstream
// vendors bucketing by identityKeyOf) never have to infer collisions from
// key strings. Top-level on purpose — several consumers enumerate
// `layers` as a map of layer buckets.
collisions,
summary: {
onlyInA,
onlyInB,
inBoth,
aligned,
drifted,
// Live artefacts whose whole family the declared pack never mentions —
// surfaced separately so the headline drift count isn't dominated by the
// rest of the platform's inventory.
outOfScope,
// Placeholders parked on either side (never paired, never counted).
scaffold,
// Artefacts one side holds in a family the other side could not
// observe: unchecked, not missing. Never paired, never counted.
notObserved,
union: onlyInA + onlyInB + inBoth,
aTotal: onlyInA + inBoth,
bTotal: onlyInB + inBoth,
jaccard: (onlyInA + onlyInB + inBoth) === 0
? 1
: Math.round((inBoth / (onlyInA + onlyInB + inBoth)) * 100) / 100,
// True alignment ratio: only structurally-equal matches count, over
// the full union. Identity-only matches that drifted are excluded.
alignment: (onlyInA + onlyInB + inBoth) === 0
? 1
: Math.round((aligned / (onlyInA + onlyInB + inBoth)) * 100) / 100,
},
layers,
};
}
function normalizeScopeMode(value) {
const raw = String(value || 'service').trim().toLowerCase();
if (raw === 'family' || raw === 'legacy' || raw === 'off') return 'family';
if (raw === 'all' || raw === 'none' || raw === 'strict') return 'all';
return 'service';
}
function diffScopeMeta(mode, scope) {
return {
mode,
service: scope.service || null,
serviceTokens: [...(scope.explicit || [])].sort(),
declaredMetricCount: scope.declaredMetrics?.size || 0,
metricPrefixes: [...(scope.metricPrefixes || [])].sort(),
};
}
const GENERIC_SERVICE_TOKENS = new Set([
'alert', 'alerts', 'api', 'app', 'apps', 'availability', 'backend',
'client', 'clients', 'core', 'dashboard', 'dashboards', 'demo', 'draft',
'error', 'errors', 'exporter', 'frontend', 'health', 'latency', 'live',
'local', 'log', 'logs', 'mcp', 'metric', 'metrics', 'monitoring',
'observability', 'pack', 'platform', 'prod', 'production', 'processor',
'repo', 'request', 'requests', 'runtime', 'scanned', 'server', 'service',
'services', 'signal', 'signals', 'system', 'target', 'telemetry', 'test',
'total', 'trace', 'traces', 'worker',
'alertmanager', 'alloy', 'cilium', 'collector', 'grafana', 'jaeger',
'kong', 'kube', 'kubernetes', 'kubestatemetrics', 'kubelet', 'loki',
'node', 'nodeexporter', 'otel', 'postgres', 'postgresql', 'prometheus',
'promtail', 'rabbitmq', 'redis', 'tempo', 'vector', 'victoria',
'victoriametrics',
]);
const GENERIC_METRIC_PREFIXES = new Set([
'alert', 'alertmanager', 'alerts', 'container', 'go', 'grafana', 'grpc',
'http', 'jaeger', 'jvm', 'kube', 'kubelet', 'loki', 'node', 'nodejs',
'otel', 'otelcol', 'pg', 'pod', 'process', 'prometheus', 'promtail',
'pushgateway', 'rabbitmq', 'redis', 'tempo', 'vm',
]);
function buildServiceScope(layered, serviceOverride) {
const explicit = new Set();
const addExplicit = (value) => addNameTokens(explicit, value, { allowShort: false });
addExplicit(serviceOverride);
addExplicit(layered?.meta?.service);
addExplicit(layered?.meta?.name);
addExplicit(layered?.name);
addExplicit(layered?.id);
const declaredMetrics = new Set();
const metricPrefixes = new Set();
for (const layerId of LAYER_ORDER) {
for (const artefact of layerArtefacts(layered, layerId)) {
const kind = classify(artefact);
const spec = artefact?.spec || {};
if (kind === 'metric') {
const metric = String(spec.name || artefact.title || '').trim();
if (!metric) continue;
declaredMetrics.add(metric.toLowerCase());
const prefix = metricPrefix(metric);
if (isUsefulMetricPrefix(prefix)) metricPrefixes.add(prefix);
}
if (spec.origin_service) addNameTokens(explicit, spec.origin_service, { allowShort: false });
if (spec.service) addNameTokens(explicit, spec.service, { allowShort: false });
}
}
return { service: serviceOverride || layered?.meta?.service || layered?.meta?.name || layered?.name || layered?.id || '', explicit, declaredMetrics, metricPrefixes };
}
function isOutsideServiceScope(group, scope) {
if (!scopeHasSignal(scope)) return false;
return group.every((artefact) => !artefactInServiceScope(artefact, scope));
}
function scopeHasSignal(scope) {
return (scope?.explicit?.size || 0)
|| (scope?.declaredMetrics?.size || 0)
|| (scope?.metricPrefixes?.size || 0);
}
function artefactInServiceScope(artefact, scope) {
if (!artefact) return false;
const kind = classify(artefact);
const spec = artefact.spec || {};
const text = artefactScopeText(artefact);
const compactText = compact(text);
for (const token of scope.explicit || []) {
if (token && compactText.includes(token)) return true;
}
if (kind !== 'metric') {
for (const metric of scope.declaredMetrics || []) {
if (metric && mentionsPromMetric(text, metric)) return true;
}
}
if (kind === 'metric') {
const name = String(spec.name || artefact.title || '').toLowerCase();
if ((scope.declaredMetrics || new Set()).has(name)) return true;
const prefix = metricPrefix(name);
return isUsefulMetricPrefix(prefix) && (scope.metricPrefixes || new Set()).has(prefix);
}
if (kind === 'scrape_job') {
const job = compact(spec.job || artefact.title || '');
for (const token of scope.explicit || []) {
if (job.includes(token) || token.includes(job)) return true;
}
}
return false;
}
function artefactScopeText(artefact) {
const spec = artefact?.spec || {};
const chunks = [
artefact?.id,
artefact?.title,
artefact?.desc,
artefact?.defines,
artefact?.parent,
...(artefact?.tags || []),
...(artefact?.refs || []),
];
appendSpecScopeText(spec, chunks);
return chunks.filter(Boolean).join(' ');
}
function appendSpecScopeText(value, chunks, depth = 0) {
if (value == null || depth > 4) return;
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
chunks.push(String(value));
return;
}
if (Array.isArray(value)) {
value.slice(0, 48).forEach((item) => appendSpecScopeText(item, chunks, depth + 1));
return;
}
if (typeof value === 'object') {
for (const key of [
'id', 'name', 'job', 'service', 'source_name', 'origin_service',
'origin_file', 'file', 'query', 'expr', 'promql', 'expression',
'binds_to', 'slo', 'sli', 'record', 'folder', 'title',
]) {
if (key in value) appendSpecScopeText(value[key], chunks, depth + 1);
}
for (const key of ['used_by', 'references', 'labels', 'annotations', 'targets']) {
if (key in value) appendSpecScopeText(value[key], chunks, depth + 1);
}
}
}
function addNameTokens(target, value, { allowShort = false } = {}) {
if (typeof value !== 'string') return;
for (const token of serviceTokens(value, allowShort)) target.add(token);
}
function serviceTokens(value, allowShort = false) {
const raw = String(value || '').toLowerCase();
const out = new Set();
const whole = compact(raw);
if (isUsefulServiceToken(whole, allowShort)) out.add(whole);
for (const part of raw.split(/[^a-z0-9]+/i)) {
const token = compact(part);
if (isUsefulServiceToken(token, allowShort)) out.add(token);
}
return [...out];
}
function isUsefulServiceToken(token, allowShort = false) {
if (!token) return false;
if (GENERIC_SERVICE_TOKENS.has(token)) return false;
if (token.length >= 3) return true;
return allowShort && token.length >= 2;
}
function metricPrefix(name) {
const raw = String(name || '').toLowerCase();
const sep = raw.includes(':') ? ':' : '_';
return raw.split(sep)[0] || '';
}
function mentionsPromMetric(text, metric) {
const haystack = String(text || '').toLowerCase();
const needle = String(metric || '').toLowerCase();
if (!needle) return false;
let index = haystack.indexOf(needle);
while (index !== -1) {
const before = index === 0 ? '' : haystack[index - 1];
const after = haystack[index + needle.length] || '';
if (!isPromMetricChar(before) && !isPromMetricChar(after)) return true;
index = haystack.indexOf(needle, index + 1);
}
return false;
}
function isPromMetricChar(ch) {
return !!ch && /[a-z0-9_:]/i.test(ch);
}
function isUsefulMetricPrefix(prefix) {
return !!prefix
&& prefix.length >= 2
&& !GENERIC_METRIC_PREFIXES.has(prefix)
&& !GENERIC_SERVICE_TOKENS.has(prefix);
}
function compact(value) {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
}
// The artefact families a pack's producer could not observe, as
// kind → reason, read from its `observogram.unobserved.<kind>` annotations
// (the adapter carries a pack's annotations on meta.annotations).
const UNOBSERVED_PREFIX = 'observogram.unobserved.';
function unobservedKinds(layered) {
const out = new Map();
const annotations = layered?.meta?.annotations;
if (!annotations || typeof annotations !== 'object') return out;
for (const [key, value] of Object.entries(annotations)) {
if (key.startsWith(UNOBSERVED_PREFIX) && key.length > UNOBSERVED_PREFIX.length) {
out.set(key.slice(UNOBSERVED_PREFIX.length), typeof value === 'string' && value ? value : 'not observed');
}
}
return out;
}
// The notObserved reason when the other pack read `kind` within a scope that
// does not cover this group, else null. One artefact of the group inside the
// scope makes the group's absence real (it is compared as usual); otherwise
// "outside" when any is known to be outside, else the cannot-tell reason.
function outsideScope(kind, group, otherScoped, holder) {
const entry = otherScoped.get(kind);
if (!entry) return null;
const verdicts = group.map((artefact) => inScope(kind, artefact, entry));
if (verdicts.includes(true)) return null;
return scopeReason(kind, verdicts.includes(false) ? false : null, entry, { holder });
}
function pushNotObserved(target, baseKey, group, side, reason) {
const suffix = group.length > 1;
group.forEach((artefact, i) => {
target.push({ key: occurrenceKey(baseKey, i, suffix), side, artefact, reason });
});
}
// A schema-forced placeholder projected by the adapter from a
// crawler.scaffold.* / mcp.scaffold.* marker. Not a declaration, not live.
function isScaffoldArtefact(artefact) {
return artefact?.source === 'Scaffold';
}
function groupByKey(items, pairKey, side) {
const out = new Map();
for (const item of items) {
const k = pairKey(item, { side });
if (!out.has(k)) out.set(k, []);
out.get(k).push(item);
}
return out;
}
function collectCollisions(target, layerId, aByKey, bByKey) {
const keys = new Set([...aByKey.keys(), ...bByKey.keys()]);
for (const k of [...keys].sort()) {
const aCount = aByKey.get(k)?.length || 0;
const bCount = bByKey.get(k)?.length || 0;
if (aCount > 1 || bCount > 1) {
target.push({ layer: layerId, kind: k.slice(0, k.indexOf('::')), key: k, aCount, bCount });
}
}
}
function matchGroups(baseKey, aGroup, bGroup, bucket) {
const suffix = Math.max(aGroup.length, bGroup.length) > 1;
let seq = 0;
const unusedB = bGroup.map((b, i) => ({ b, i }));
const usedA = new Set();
// Pair exact behavioural matches first so a self-diff remains fully aligned
// even when one pack contains duplicate identity keys.
for (let ai = 0; ai < aGroup.length; ai++) {
const bi = unusedB.findIndex(({ b }) => deltasOf(aGroup[ai], b).length === 0);
if (bi === -1) continue;
const [{ b }] = unusedB.splice(bi, 1);
bucket.inBoth.push({
key: occurrenceKey(baseKey, seq++, suffix),
a: aGroup[ai],
b,
match: 'aligned',
deltas: [],
});
usedA.add(ai);
}
const unusedA = aGroup
.map((a, i) => ({ a, i }))
.filter(({ i }) => !usedA.has(i));
// Remaining items share identity but not the same contract. Pair each A with
// the closest remaining B, then leave surplus controls visible as onlyInA/B.
while (unusedA.length && unusedB.length) {
const { a } = unusedA.shift();
let best = 0;
let bestDeltas = deltasOf(a, unusedB[0].b);
for (let i = 1; i < unusedB.length; i++) {
const d = deltasOf(a, unusedB[i].b);
if (d.length < bestDeltas.length) {
best = i;
bestDeltas = d;
}
}
const [{ b }] = unusedB.splice(best, 1);
bucket.inBoth.push({
key: occurrenceKey(baseKey, seq++, suffix),
a,
b,
match: 'drifted',
deltas: bestDeltas,
});
}
for (const { a } of unusedA) {
bucket.onlyInA.push({ key: occurrenceKey(baseKey, seq++, suffix), artefact: a });
}
for (const { b } of unusedB) {
bucket.onlyInB.push({ key: occurrenceKey(baseKey, seq++, suffix), artefact: b });
}
}
function pushUnmatched(target, baseKey, group) {
const suffix = group.length > 1;
group.forEach((artefact, i) => {
target.push({ key: occurrenceKey(baseKey, i, suffix), artefact });
});
}
function occurrenceKey(baseKey, index, suffix) {
return suffix ? `${baseKey}#${String(index + 1).padStart(2, '0')}` : baseKey;
}