mikser-io-layouts 2.10.0 → 2.11.1
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/lib/mcp.js +114 -20
- package/package.json +1 -1
- package/test/check-entity.test.js +91 -5
package/lib/mcp.js
CHANGED
|
@@ -77,7 +77,7 @@ export function registerMcpTools({ runtime, useLogger, findEntity, findEntities,
|
|
|
77
77
|
const ENGINE_KEYS = new Set([
|
|
78
78
|
'layout', 'layouts', 'task', 'destination', 'format', 'template', 'href',
|
|
79
79
|
'lang', 'url', 'pages', 'parent', 'plugins', 'presets', 'refs', 'date',
|
|
80
|
-
'draft', 'stamp', 'postprocessor',
|
|
80
|
+
'draft', 'stamp', 'postprocessor', 'schema',
|
|
81
81
|
])
|
|
82
82
|
|
|
83
83
|
// A document's meta in the same vocabulary a layout contract uses: dotted
|
|
@@ -188,6 +188,9 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
188
188
|
}
|
|
189
189
|
return
|
|
190
190
|
case 'array':
|
|
191
|
+
// The element path is NOT added as a key of its own. Requiring
|
|
192
|
+
// `tags[]` would claim the array must be non-empty, which no
|
|
193
|
+
// schema said — an empty list is a valid `z.array()`.
|
|
191
194
|
schemaKeys(def.element, `${prefix}[]`, required, optional, depth + 1)
|
|
192
195
|
return
|
|
193
196
|
default:
|
|
@@ -202,6 +205,55 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
202
205
|
return [...req, ...opt]
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
// How this record compares with its own kind.
|
|
209
|
+
//
|
|
210
|
+
// A layout contract answers "what does this template read". It cannot answer
|
|
211
|
+
// "is this record complete AS A RECORD" — a catalog entry missing a field
|
|
212
|
+
// its siblings all carry renders perfectly, reads nothing that is absent,
|
|
213
|
+
// and passes every other check here. That is among the commonest content
|
|
214
|
+
// defects and nothing else in this tool can see it.
|
|
215
|
+
//
|
|
216
|
+
// Deliberately weak evidence, and reported as such: records differ for good
|
|
217
|
+
// reasons, and a document is not wrong for lacking what its neighbours have.
|
|
218
|
+
// It is a prompt to look.
|
|
219
|
+
//
|
|
220
|
+
// Needs a real peer group. With one sibling "most of them" means nothing, so
|
|
221
|
+
// below MIN_PEERS the comparison is not made rather than made badly.
|
|
222
|
+
const MIN_PEERS = 3
|
|
223
|
+
const PEER_SHARE = 0.7
|
|
224
|
+
|
|
225
|
+
const declaredTypeOf = (entity) => {
|
|
226
|
+
const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
|
|
227
|
+
return key.split('.').reduce((o, k) => o?.[k], entity) ?? entity?.meta?.schema ?? null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const peerGapsFor = async (entity) => {
|
|
231
|
+
const type = declaredTypeOf(entity)
|
|
232
|
+
if (!type) return null
|
|
233
|
+
const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
|
|
234
|
+
const siblings = ((await findEntities({ [key]: type })) ?? [])
|
|
235
|
+
.filter(e => e?.id && e.id !== entity.id && e.collection === entity.collection)
|
|
236
|
+
if (siblings.length < MIN_PEERS) return null
|
|
237
|
+
|
|
238
|
+
const counts = new Map()
|
|
239
|
+
for (const peer of siblings) {
|
|
240
|
+
for (const k of flattenMeta(peer.meta ?? {}, '', new Set())) {
|
|
241
|
+
counts.set(k, (counts.get(k) ?? 0) + 1)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const mine = flattenMeta(entity.meta ?? {}, '', new Set())
|
|
245
|
+
const threshold = Math.ceil(siblings.length * PEER_SHARE)
|
|
246
|
+
const gaps = [...counts]
|
|
247
|
+
.filter(([k, c]) => c >= threshold && !mine.has(k))
|
|
248
|
+
.filter(([k]) => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
|
|
249
|
+
// A key whose PARENT this document also lacks is one finding, not
|
|
250
|
+
// several: report `card`, not `card.order` and `card.wide` under it.
|
|
251
|
+
.filter(([k]) => { const parent = k.replace(/\.[^.]+$|\[\]$/, ''); return parent === k || mine.has(parent) })
|
|
252
|
+
.map(([key, count]) => ({ key, siblings: count, of: siblings.length }))
|
|
253
|
+
.sort((a, b) => b.siblings - a.siblings || a.key.localeCompare(b.key))
|
|
254
|
+
return { type, peers: siblings.length, gaps }
|
|
255
|
+
}
|
|
256
|
+
|
|
205
257
|
// The schema declared for this entity, if the schemas plugin is loaded and
|
|
206
258
|
// the entity names one. `schemaKey` is the plugin's own config — commonly
|
|
207
259
|
// `meta.layout`, so a layout and its schema share a name.
|
|
@@ -394,38 +446,78 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
394
446
|
// A real contract, from the other side: the keys renders
|
|
395
447
|
// actually took off this entity, held to the same standard
|
|
396
448
|
// as a page — same comparison, same lists.
|
|
397
|
-
|
|
449
|
+
// A declared schema outranks consumer reads here for the
|
|
450
|
+
// same reason it does on a page: it says what the record
|
|
451
|
+
// must contain, where consumer reads only say what someone
|
|
452
|
+
// happened to look at. Without this a data document was the
|
|
453
|
+
// one kind that could not benefit from its own schema.
|
|
454
|
+
// Two questions, two comparisons — as on a page.
|
|
455
|
+
//
|
|
456
|
+
// `unused` and `untraceable` ask "does anything READ this",
|
|
457
|
+
// which only consumption answers. Running them against the
|
|
458
|
+
// schema instead reported keys as unread that the recorded
|
|
459
|
+
// reads plainly contain: a schema does not enumerate array
|
|
460
|
+
// elements, so every `x[]` in the document looked untouched.
|
|
461
|
+
const byReads = compare(entity, new Set(read.keys), new Set())
|
|
462
|
+
const declared = schemaFor(entity)
|
|
463
|
+
let cmp = byReads
|
|
464
|
+
if (declared) {
|
|
465
|
+
const req = new Set(); const opt = new Set()
|
|
466
|
+
schemaKeys(declared.schema, '', req, opt)
|
|
467
|
+
const bySchema = compare(entity, new Set([...req, ...opt]), opt)
|
|
468
|
+
// `missing` from the declaration, the rest from reality.
|
|
469
|
+
cmp = { ...byReads, missing: bySchema.missing,
|
|
470
|
+
missingOptional: bySchema.missingOptional }
|
|
471
|
+
}
|
|
472
|
+
// Matters most here: a data document is otherwise only
|
|
473
|
+
// checked against whatever its consumers happen to read, so
|
|
474
|
+
// a field every sibling carries and nobody reads yet is
|
|
475
|
+
// invisible.
|
|
476
|
+
const peers = await peerGapsFor(entity)
|
|
398
477
|
return ok({
|
|
399
478
|
entity: id, kind: 'data',
|
|
400
479
|
verdict: 'not a page — other entities consume it',
|
|
401
480
|
consumedBy: consumers,
|
|
402
481
|
checked: read.keys.length > 0,
|
|
403
482
|
reliable: read.keys.length > 0,
|
|
483
|
+
missingFrom: declared ? 'schema' : 'consumers',
|
|
404
484
|
missing: cmp.missing,
|
|
485
|
+
missingOptional: cmp.missingOptional,
|
|
405
486
|
likelyTypos: cmp.likelyTypos,
|
|
406
487
|
unused: cmp.unused,
|
|
407
488
|
untraceable: cmp.untraceable,
|
|
489
|
+
...(peers?.gaps.length
|
|
490
|
+
? { peerGaps: peers.gaps, peerGroup: { type: peers.type, peers: peers.peers } }
|
|
491
|
+
: {}),
|
|
408
492
|
consumedKeys: read.keys,
|
|
409
493
|
counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
|
|
410
494
|
missing: cmp.missing.length, unused: cmp.unused.length },
|
|
411
495
|
notes: [
|
|
412
|
-
`
|
|
413
|
-
read.
|
|
414
|
-
|
|
415
|
-
: 'No key-level reads are recorded yet, so only the consumer list is known. Build once with a current mikser and the fields each render takes off this entity get recorded.',
|
|
416
|
-
'untraceable means an ancestor WAS read but the members could not be followed: the value was handed to a template and provenance does not survive that hop. Treat them as consumed.',
|
|
417
|
-
'unused here means no render took the key. It may still be served to an API client or another process, which this tool cannot see — mikser is not always a static site generator.',
|
|
496
|
+
`Never renders — ${consumers.length} render(s) query it. Building will not make it appear, and a key it lacks shows up as a hole in those pages.`,
|
|
497
|
+
'missing: check missingFrom — "schema" is declared, "consumers" is only what someone happened to read off it.',
|
|
498
|
+
'Look, do not panic: consumedKeys (what renders actually took), peerGaps (most records of this type carry it), unused (may still be served to an API client), untraceable (an ancestor was read but its members could not be followed). None of these is a defect on its own.',
|
|
418
499
|
],
|
|
419
500
|
})
|
|
420
501
|
}
|
|
502
|
+
// Nothing reads it, so nothing can say what it needs — except
|
|
503
|
+
// its own kind. This is the one check available here, and the
|
|
504
|
+
// only place in the tool where peer comparison is not merely
|
|
505
|
+
// corroboration.
|
|
506
|
+
const orphanPeers = await peerGapsFor(entity)
|
|
421
507
|
return ok({
|
|
422
508
|
entity: id, kind: 'unreferenced',
|
|
423
509
|
verdict: 'no contract could be derived',
|
|
424
510
|
checked: false,
|
|
511
|
+
...(orphanPeers?.gaps.length
|
|
512
|
+
? { peerGaps: orphanPeers.gaps,
|
|
513
|
+
peerGroup: { type: orphanPeers.type, peers: orphanPeers.peers } }
|
|
514
|
+
: {}),
|
|
425
515
|
notes: [
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
516
|
+
'Never renders, declares no layout, and no render queried it. Not an error: the catalog is readable over the API, so a consumer outside this project may depend on it.',
|
|
517
|
+
...(orphanPeers?.gaps.length
|
|
518
|
+
? ['peerGaps is the only check possible here — nothing reads this entity, so only the other records of its type say anything about it.']
|
|
519
|
+
: []),
|
|
520
|
+
'If it was meant to be a page it matched no layout: check the layouts match patterns, or set meta.layout.',
|
|
429
521
|
],
|
|
430
522
|
})
|
|
431
523
|
}
|
|
@@ -455,6 +547,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
455
547
|
// required-versus-optional outright, needs no template parsing, and
|
|
456
548
|
// is engine-agnostic by construction — where one exists, nothing
|
|
457
549
|
// read out of a template should be able to call a document broken.
|
|
550
|
+
const peerCheck = await peerGapsFor(entity)
|
|
458
551
|
const declaredSchema = schemaFor(entity)
|
|
459
552
|
let missing = inferred.missing
|
|
460
553
|
let missingOptional = inferred.missingOptional
|
|
@@ -509,22 +602,23 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
509
602
|
...(drift ? { drift } : {}),
|
|
510
603
|
unused,
|
|
511
604
|
untraceable,
|
|
605
|
+
...(peerCheck?.gaps.length
|
|
606
|
+
? { peerGaps: peerCheck.gaps, peerGroup: { type: peerCheck.type, peers: peerCheck.peers } }
|
|
607
|
+
: {}),
|
|
512
608
|
unresolvedSections,
|
|
513
609
|
counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
|
|
514
610
|
missingOptional: missingOptional.length, unused: unused.length },
|
|
515
611
|
contract: { complete: contract.complete, incomplete: contract.incomplete ?? [],
|
|
516
612
|
scopedToPartials: usedPartials?.length ?? null },
|
|
517
613
|
observedReads: observed.length,
|
|
614
|
+
// One line per field, saying what to DO with it. These are
|
|
615
|
+
// read on every call, so anything that is not actionable is
|
|
616
|
+
// cost without value.
|
|
518
617
|
notes: [
|
|
519
|
-
'missing
|
|
520
|
-
'
|
|
521
|
-
'
|
|
522
|
-
'
|
|
523
|
-
'unresolvedSections named a section that resolved to no template under THIS layout. Weak evidence: the section may render under a different layout, its template may not be written yet, or the data may be consumed outside the SSG entirely — the catalog is readable over the API. It never fails a check.',
|
|
524
|
-
'untraceable is NOT a list of problems. An ancestor of each key WAS read, but the value was handed on — a sidecar returning it to a template — and provenance does not survive that hop, so the members could not be followed. Treat them as consumed unless you have another reason to doubt it.',
|
|
525
|
-
'unused is weak evidence too, and weakest of all where mikser is not a static site generator. A key may be consumed by a DIFFERENT layout, or by an API client reading the catalog over HTTP, or by another process entirely — none of which this tool can see. Engine-owned keys (href, lang, layout, task, ...) are excluded outright.',
|
|
526
|
-
'The contract is scoped to the partials this entity ACTUALLY rendered with, not to every branch the layout could take. Unscoped, a page that uses two of twenty sections is checked against all twenty.',
|
|
527
|
-
'reliable is false unless the contract was fully read AND this entity has rendered. An absence is only evidence when it is true.',
|
|
618
|
+
'missing: the layout needs it and this document has no value for it. Check missingFrom — "schema" is declared and authoritative, "inferred" is read from templates and is strong evidence rather than proof.',
|
|
619
|
+
'likelyTypos: a key you wrote paired with one the layout wanted, within two edits. A typo produces both, so the pair is worth more than either list.',
|
|
620
|
+
'Look, do not panic: missingOptional (guarded by the layout, safe to omit), unused (may be read by another layout or an API client), untraceable (an ancestor was read but its members could not be followed), unresolvedSections (matched no template here), peerGaps (most records of this type carry it). None of these is a defect on its own.',
|
|
621
|
+
'reliable: false means the contract had gaps or this entity has not rendered. An absence is only evidence when it is true.',
|
|
528
622
|
],
|
|
529
623
|
})
|
|
530
624
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-layouts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "SSG-flavor task-production policy for mikser-io. Multi-match layout assignment, per-layout destinations, postprocess chain attachment, layout inspection.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -198,7 +198,8 @@ describe('mikser_check_entity: a page', () => {
|
|
|
198
198
|
const r = await check('/documents/extra.md')
|
|
199
199
|
assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
|
|
200
200
|
assert.deepEqual(r.missing, [], 'an unused key is not a missing one')
|
|
201
|
-
assert.ok(r.notes.some(n => /
|
|
201
|
+
assert.ok(r.notes.some(n => /another layout or an API client/.test(n)),
|
|
202
|
+
'the caveat has to travel with the finding')
|
|
202
203
|
})
|
|
203
204
|
|
|
204
205
|
it('never reports engine-owned keys as unused', async () => {
|
|
@@ -214,7 +215,8 @@ describe('mikser_check_entity: a page', () => {
|
|
|
214
215
|
const r = await check('/documents/ghost.md')
|
|
215
216
|
assert.deepEqual(r.unresolvedSections, ['nosuchsection'])
|
|
216
217
|
assert.deepEqual(r.missing, [], 'an unresolved section is not a missing key')
|
|
217
|
-
assert.ok(r.notes.some(n => /unresolvedSections/.test(n) && /
|
|
218
|
+
assert.ok(r.notes.some(n => /unresolvedSections/.test(n) && /defect/.test(n)),
|
|
219
|
+
'it must be named as something to look at, not a failure')
|
|
218
220
|
})
|
|
219
221
|
|
|
220
222
|
it('uses the layout the entity actually rendered with', async () => {
|
|
@@ -233,7 +235,7 @@ describe('mikser_check_entity: not a page', () => {
|
|
|
233
235
|
assert.equal(r.kind, 'data')
|
|
234
236
|
assert.deepEqual(r.consumedBy, ['/documents/good.md'])
|
|
235
237
|
assert.ok(!JSON.stringify(r.notes).includes('build once'))
|
|
236
|
-
assert.ok(r.notes.some(n => /hole in
|
|
238
|
+
assert.ok(r.notes.some(n => /hole in those pages/.test(n)),
|
|
237
239
|
'it has to say where a mistake would actually show up')
|
|
238
240
|
})
|
|
239
241
|
|
|
@@ -260,7 +262,8 @@ describe('mikser_check_entity: not a page', () => {
|
|
|
260
262
|
const group = r.untraceable.find(g => g.under === 'enquiry')
|
|
261
263
|
assert.ok(group, `expected them collapsed under enquiry, got ${JSON.stringify(r.untraceable)}`)
|
|
262
264
|
assert.ok(group.keys.includes('enquiry.fields[].label'))
|
|
263
|
-
assert.ok(r.notes.some(n => /
|
|
265
|
+
assert.ok(r.notes.some(n => /untraceable/.test(n) && /could not be followed/.test(n)),
|
|
266
|
+
'the reason it cannot be judged has to travel with it')
|
|
264
267
|
})
|
|
265
268
|
|
|
266
269
|
it('still reports a key nothing read at all', async () => {
|
|
@@ -384,7 +387,90 @@ describe('mikser_check_entity: with a declared schema', () => {
|
|
|
384
387
|
it('labels an inferred contract as inferred when there is no schema', async () => {
|
|
385
388
|
const r = await check('/documents/good.md')
|
|
386
389
|
assert.equal(r.missingFrom, 'inferred')
|
|
387
|
-
assert.ok(r.notes.some(n => /
|
|
390
|
+
assert.ok(r.notes.some(n => /strong evidence rather than proof/.test(n)),
|
|
388
391
|
'the weaker source has to say that it is weaker')
|
|
389
392
|
})
|
|
390
393
|
})
|
|
394
|
+
|
|
395
|
+
// Is this record complete AS A RECORD?
|
|
396
|
+
//
|
|
397
|
+
// A layout contract cannot answer that. A catalog entry missing a field its
|
|
398
|
+
// siblings all carry renders perfectly, reads nothing that is absent, and passes
|
|
399
|
+
// every other check here — and a derived schema will not catch it either, since
|
|
400
|
+
// a key one sibling lacks becomes `.optional()`. Comparing a record with its own
|
|
401
|
+
// kind is the only thing that sees it.
|
|
402
|
+
//
|
|
403
|
+
// Weak evidence on purpose: records differ for good reasons.
|
|
404
|
+
describe('mikser_check_entity: peer comparison', () => {
|
|
405
|
+
let peerCheck
|
|
406
|
+
|
|
407
|
+
const entry = (n, extra = {}) => ({
|
|
408
|
+
id: `/documents/cat-${n}.md`, name: `cat-${n}`, collection: 'documents',
|
|
409
|
+
meta: { schema: 'catalog', title: `T${n}`, href: `/c/${n}`, summary: 'S', ...extra },
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
before(async () => {
|
|
413
|
+
// Nine complete siblings, one missing `summary`, and one carrying a key
|
|
414
|
+
// only it has.
|
|
415
|
+
const entities = [
|
|
416
|
+
...Array.from({ length: 9 }, (_, i) => entry(i)),
|
|
417
|
+
(() => { const e = entry('gap'); delete e.meta.summary; return e })(),
|
|
418
|
+
entry('extra', { oddball: true }),
|
|
419
|
+
]
|
|
420
|
+
const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
|
|
421
|
+
const findEntities = async (q) => entities.filter(e =>
|
|
422
|
+
Object.entries(q ?? {}).every(([k, v]) => k.split('.').reduce((o, x) => o?.[x], e) === v))
|
|
423
|
+
const runtime = { options: {}, renderers: new Map() }
|
|
424
|
+
runtime.options.layouts = { inspect: async () => ({ references: {} }) }
|
|
425
|
+
const registry = new Map()
|
|
426
|
+
runtime.options.mcp = { simpleTool: (n, _d, _s, h) => registry.set(n, h) }
|
|
427
|
+
registerCheckTool({
|
|
428
|
+
runtime, findEntity, findEntities,
|
|
429
|
+
// No snapshots: these never render, so they are data documents.
|
|
430
|
+
useDatabase: () => ({ handle: { prepare: () => ({ all: () => [], get: () => undefined }) } }),
|
|
431
|
+
collection: 'layouts', logger: { debug() {} },
|
|
432
|
+
})
|
|
433
|
+
peerCheck = async (id) =>
|
|
434
|
+
JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('reports a key most siblings carry and this record lacks', async () => {
|
|
438
|
+
const r = await peerCheck('/documents/cat-gap.md')
|
|
439
|
+
const gap = r.peerGaps?.find(g => g.key === 'summary')
|
|
440
|
+
assert.ok(gap, `no peer gap found: ${JSON.stringify(r.peerGaps)}`)
|
|
441
|
+
assert.equal(gap.siblings, 10, 'ten of the ten siblings have it')
|
|
442
|
+
assert.equal(gap.of, 10)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('names the group, so the count means something', async () => {
|
|
446
|
+
const r = await peerCheck('/documents/cat-gap.md')
|
|
447
|
+
assert.equal(r.peerGroup.type, 'catalog')
|
|
448
|
+
assert.equal(r.peerGroup.peers, 10)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('says nothing about a complete record', async () => {
|
|
452
|
+
// No false positives, or nobody reads the list.
|
|
453
|
+
const r = await peerCheck('/documents/cat-0.md')
|
|
454
|
+
assert.equal(r.peerGaps, undefined)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('does not report a key only ONE sibling has', async () => {
|
|
458
|
+
// `oddball` exists on exactly one record. One record is not a pattern.
|
|
459
|
+
const r = await peerCheck('/documents/cat-0.md')
|
|
460
|
+
assert.ok(!(r.peerGaps ?? []).some(g => g.key === 'oddball'))
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('never fails the check — it is a prompt, not a defect', async () => {
|
|
464
|
+
const r = await peerCheck('/documents/cat-gap.md')
|
|
465
|
+
assert.notEqual(r.kind, undefined)
|
|
466
|
+
assert.ok(r.notes.some(n => /peerGaps/.test(n) && /defect|only check possible/.test(n)),
|
|
467
|
+
'the weakness has to travel with the finding')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('excludes engine-owned keys from the comparison', async () => {
|
|
471
|
+
const r = await peerCheck('/documents/cat-gap.md')
|
|
472
|
+
for (const k of ['schema', 'href', 'lang']) {
|
|
473
|
+
assert.ok(!(r.peerGaps ?? []).some(g => g.key === k), `${k} leaked into peerGaps`)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
})
|