mikser-io-layouts 2.10.0 → 2.11.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/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
@@ -202,6 +202,55 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
202
202
  return [...req, ...opt]
203
203
  }
204
204
 
205
+ // How this record compares with its own kind.
206
+ //
207
+ // A layout contract answers "what does this template read". It cannot answer
208
+ // "is this record complete AS A RECORD" — a catalog entry missing a field
209
+ // its siblings all carry renders perfectly, reads nothing that is absent,
210
+ // and passes every other check here. That is among the commonest content
211
+ // defects and nothing else in this tool can see it.
212
+ //
213
+ // Deliberately weak evidence, and reported as such: records differ for good
214
+ // reasons, and a document is not wrong for lacking what its neighbours have.
215
+ // It is a prompt to look.
216
+ //
217
+ // Needs a real peer group. With one sibling "most of them" means nothing, so
218
+ // below MIN_PEERS the comparison is not made rather than made badly.
219
+ const MIN_PEERS = 3
220
+ const PEER_SHARE = 0.7
221
+
222
+ const declaredTypeOf = (entity) => {
223
+ const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
224
+ return key.split('.').reduce((o, k) => o?.[k], entity) ?? entity?.meta?.schema ?? null
225
+ }
226
+
227
+ const peerGapsFor = async (entity) => {
228
+ const type = declaredTypeOf(entity)
229
+ if (!type) return null
230
+ const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
231
+ const siblings = ((await findEntities({ [key]: type })) ?? [])
232
+ .filter(e => e?.id && e.id !== entity.id && e.collection === entity.collection)
233
+ if (siblings.length < MIN_PEERS) return null
234
+
235
+ const counts = new Map()
236
+ for (const peer of siblings) {
237
+ for (const k of flattenMeta(peer.meta ?? {}, '', new Set())) {
238
+ counts.set(k, (counts.get(k) ?? 0) + 1)
239
+ }
240
+ }
241
+ const mine = flattenMeta(entity.meta ?? {}, '', new Set())
242
+ const threshold = Math.ceil(siblings.length * PEER_SHARE)
243
+ const gaps = [...counts]
244
+ .filter(([k, c]) => c >= threshold && !mine.has(k))
245
+ .filter(([k]) => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
246
+ // A key whose PARENT this document also lacks is one finding, not
247
+ // several: report `card`, not `card.order` and `card.wide` under it.
248
+ .filter(([k]) => { const parent = k.replace(/\.[^.]+$|\[\]$/, ''); return parent === k || mine.has(parent) })
249
+ .map(([key, count]) => ({ key, siblings: count, of: siblings.length }))
250
+ .sort((a, b) => b.siblings - a.siblings || a.key.localeCompare(b.key))
251
+ return { type, peers: siblings.length, gaps }
252
+ }
253
+
205
254
  // The schema declared for this entity, if the schemas plugin is loaded and
206
255
  // the entity names one. `schemaKey` is the plugin's own config — commonly
207
256
  // `meta.layout`, so a layout and its schema share a name.
@@ -394,17 +443,40 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
394
443
  // A real contract, from the other side: the keys renders
395
444
  // actually took off this entity, held to the same standard
396
445
  // as a page — same comparison, same lists.
397
- const cmp = compare(entity, new Set(read.keys), new Set())
446
+ // A declared schema outranks consumer reads here for the
447
+ // same reason it does on a page: it says what the record
448
+ // must contain, where consumer reads only say what someone
449
+ // happened to look at. Without this a data document was the
450
+ // one kind that could not benefit from its own schema.
451
+ const declared = schemaFor(entity)
452
+ let cmp
453
+ if (declared) {
454
+ const req = new Set(); const opt = new Set()
455
+ schemaKeys(declared.schema, '', req, opt)
456
+ cmp = compare(entity, new Set([...req, ...opt]), opt)
457
+ } else {
458
+ cmp = compare(entity, new Set(read.keys), new Set())
459
+ }
460
+ // Matters most here: a data document is otherwise only
461
+ // checked against whatever its consumers happen to read, so
462
+ // a field every sibling carries and nobody reads yet is
463
+ // invisible.
464
+ const peers = await peerGapsFor(entity)
398
465
  return ok({
399
466
  entity: id, kind: 'data',
400
467
  verdict: 'not a page — other entities consume it',
401
468
  consumedBy: consumers,
402
469
  checked: read.keys.length > 0,
403
470
  reliable: read.keys.length > 0,
471
+ missingFrom: declared ? 'schema' : 'consumers',
404
472
  missing: cmp.missing,
473
+ missingOptional: cmp.missingOptional,
405
474
  likelyTypos: cmp.likelyTypos,
406
475
  unused: cmp.unused,
407
476
  untraceable: cmp.untraceable,
477
+ ...(peers?.gaps.length
478
+ ? { peerGaps: peers.gaps, peerGroup: { type: peers.type, peers: peers.peers } }
479
+ : {}),
408
480
  consumedKeys: read.keys,
409
481
  counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
410
482
  missing: cmp.missing.length, unused: cmp.unused.length },
@@ -414,15 +486,29 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
414
486
  ? 'consumedKeys is what those renders actually READ off this entity. That is its contract: a key here with no value is a hole in the pages listed above, not in this file.'
415
487
  : '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
488
  '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.',
489
+ 'missingFrom says where `missing` came from: "schema" is declared and authoritative; "consumers" means it is derived from what renders actually read off this entity, which only covers what someone happened to look at.',
490
+ 'peerGaps compares this record with the others declaring the same type — keys most of them carry that this one lacks. For a data document this is often the only check with teeth, since everything else here depends on what a consumer happened to read. Weak evidence, never a failure.',
417
491
  '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.',
418
492
  ],
419
493
  })
420
494
  }
495
+ // Nothing reads it, so nothing can say what it needs — except
496
+ // its own kind. This is the one check available here, and the
497
+ // only place in the tool where peer comparison is not merely
498
+ // corroboration.
499
+ const orphanPeers = await peerGapsFor(entity)
421
500
  return ok({
422
501
  entity: id, kind: 'unreferenced',
423
502
  verdict: 'no contract could be derived',
424
503
  checked: false,
504
+ ...(orphanPeers?.gaps.length
505
+ ? { peerGaps: orphanPeers.gaps,
506
+ peerGroup: { type: orphanPeers.type, peers: orphanPeers.peers } }
507
+ : {}),
425
508
  notes: [
509
+ ...(orphanPeers?.gaps.length
510
+ ? ['peerGaps is the only check that could be made here: nothing reads this entity, so nothing defines what it needs, but the other records declaring the same type do. Weak evidence, never a failure — records differ for good reasons.']
511
+ : []),
426
512
  'This entity has never rendered, declares no meta.layout, and no render in the manifest queried it. Nothing in this project reads it.',
427
513
  'That is not an error. The catalog is readable over the API, so another process or an external consumer may depend on it, and this tool cannot see them.',
428
514
  'If it was MEANT to be a page, it matched no layout — check the layouts match patterns or set meta.layout.',
@@ -455,6 +541,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
455
541
  // required-versus-optional outright, needs no template parsing, and
456
542
  // is engine-agnostic by construction — where one exists, nothing
457
543
  // read out of a template should be able to call a document broken.
544
+ const peerCheck = await peerGapsFor(entity)
458
545
  const declaredSchema = schemaFor(entity)
459
546
  let missing = inferred.missing
460
547
  let missingOptional = inferred.missingOptional
@@ -509,6 +596,9 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
509
596
  ...(drift ? { drift } : {}),
510
597
  unused,
511
598
  untraceable,
599
+ ...(peerCheck?.gaps.length
600
+ ? { peerGaps: peerCheck.gaps, peerGroup: { type: peerCheck.type, peers: peerCheck.peers } }
601
+ : {}),
512
602
  unresolvedSections,
513
603
  counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
514
604
  missingOptional: missingOptional.length, unused: unused.length },
@@ -520,6 +610,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
520
610
  'drift appears only with a schema, and is where the two sources disagree: readButNotDeclared is a key the layouts consume that nothing declares (a schema gap, or a typo in a template), declaredButNotRead is a declared key no layout reads (dead, or consumed over the API).',
521
611
  'likelyTypos pairs an unused key against a wanted one within two edits. A real typo produces both, which is why the pair is worth more than either list.',
522
612
  'missingOptional is NOT a defect. The layout guards these behind a condition, so it was written to work without them — they are listed only so a deliberate omission can be told from a forgotten one.',
613
+ 'peerGaps compares this record with the others declaring the same type: keys most of them carry that this one lacks, with the count as evidence. A layout contract cannot see this — a record missing a field its siblings all have renders perfectly and reads nothing that is absent. Weak evidence, and never a failure: records differ for good reasons.',
523
614
  '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
615
  '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
616
  '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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
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",
@@ -388,3 +388,86 @@ describe('mikser_check_entity: with a declared schema', () => {
388
388
  'the weaker source has to say that it is weaker')
389
389
  })
390
390
  })
391
+
392
+ // Is this record complete AS A RECORD?
393
+ //
394
+ // A layout contract cannot answer that. A catalog entry missing a field its
395
+ // siblings all carry renders perfectly, reads nothing that is absent, and passes
396
+ // every other check here — and a derived schema will not catch it either, since
397
+ // a key one sibling lacks becomes `.optional()`. Comparing a record with its own
398
+ // kind is the only thing that sees it.
399
+ //
400
+ // Weak evidence on purpose: records differ for good reasons.
401
+ describe('mikser_check_entity: peer comparison', () => {
402
+ let peerCheck
403
+
404
+ const entry = (n, extra = {}) => ({
405
+ id: `/documents/cat-${n}.md`, name: `cat-${n}`, collection: 'documents',
406
+ meta: { schema: 'catalog', title: `T${n}`, href: `/c/${n}`, summary: 'S', ...extra },
407
+ })
408
+
409
+ before(async () => {
410
+ // Nine complete siblings, one missing `summary`, and one carrying a key
411
+ // only it has.
412
+ const entities = [
413
+ ...Array.from({ length: 9 }, (_, i) => entry(i)),
414
+ (() => { const e = entry('gap'); delete e.meta.summary; return e })(),
415
+ entry('extra', { oddball: true }),
416
+ ]
417
+ const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
418
+ const findEntities = async (q) => entities.filter(e =>
419
+ Object.entries(q ?? {}).every(([k, v]) => k.split('.').reduce((o, x) => o?.[x], e) === v))
420
+ const runtime = { options: {}, renderers: new Map() }
421
+ runtime.options.layouts = { inspect: async () => ({ references: {} }) }
422
+ const registry = new Map()
423
+ runtime.options.mcp = { simpleTool: (n, _d, _s, h) => registry.set(n, h) }
424
+ registerCheckTool({
425
+ runtime, findEntity, findEntities,
426
+ // No snapshots: these never render, so they are data documents.
427
+ useDatabase: () => ({ handle: { prepare: () => ({ all: () => [], get: () => undefined }) } }),
428
+ collection: 'layouts', logger: { debug() {} },
429
+ })
430
+ peerCheck = async (id) =>
431
+ JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
432
+ })
433
+
434
+ it('reports a key most siblings carry and this record lacks', async () => {
435
+ const r = await peerCheck('/documents/cat-gap.md')
436
+ const gap = r.peerGaps?.find(g => g.key === 'summary')
437
+ assert.ok(gap, `no peer gap found: ${JSON.stringify(r.peerGaps)}`)
438
+ assert.equal(gap.siblings, 10, 'ten of the ten siblings have it')
439
+ assert.equal(gap.of, 10)
440
+ })
441
+
442
+ it('names the group, so the count means something', async () => {
443
+ const r = await peerCheck('/documents/cat-gap.md')
444
+ assert.equal(r.peerGroup.type, 'catalog')
445
+ assert.equal(r.peerGroup.peers, 10)
446
+ })
447
+
448
+ it('says nothing about a complete record', async () => {
449
+ // No false positives, or nobody reads the list.
450
+ const r = await peerCheck('/documents/cat-0.md')
451
+ assert.equal(r.peerGaps, undefined)
452
+ })
453
+
454
+ it('does not report a key only ONE sibling has', async () => {
455
+ // `oddball` exists on exactly one record. One record is not a pattern.
456
+ const r = await peerCheck('/documents/cat-0.md')
457
+ assert.ok(!(r.peerGaps ?? []).some(g => g.key === 'oddball'))
458
+ })
459
+
460
+ it('never fails the check — it is a prompt, not a defect', async () => {
461
+ const r = await peerCheck('/documents/cat-gap.md')
462
+ assert.notEqual(r.kind, undefined)
463
+ assert.ok(r.notes.some(n => /Weak evidence, never a failure|never a failure/.test(n)),
464
+ 'the weakness has to travel with the finding')
465
+ })
466
+
467
+ it('excludes engine-owned keys from the comparison', async () => {
468
+ const r = await peerCheck('/documents/cat-gap.md')
469
+ for (const k of ['schema', 'href', 'lang']) {
470
+ assert.ok(!(r.peerGaps ?? []).some(g => g.key === k), `${k} leaked into peerGaps`)
471
+ }
472
+ })
473
+ })