mikser-io-layouts 2.9.3 → 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/README.md +22 -1
- package/lib/mcp.js +199 -5
- package/package.json +1 -1
- package/test/check-entity.test.js +180 -0
package/README.md
CHANGED
|
@@ -176,7 +176,28 @@ See the [mikser-io rendering docs](https://github.com/almero-digital-marketing/m
|
|
|
176
176
|
|
|
177
177
|
## `inspect()` primitive
|
|
178
178
|
|
|
179
|
-
`runtime.options.layouts.inspect(layoutId, { samples })`
|
|
179
|
+
`runtime.options.layouts.inspect(layoutId, { samples, partials })` returns a layout's template source, sample entities, and three views of what it depends on:
|
|
180
|
+
|
|
181
|
+
- **`references.contract`** — the whole layout tree walked, with partial arguments and renamings resolved, so `meta` lists document keys in the form a document writes them. Pass `partials` (the ids a render actually used, from the manifest) to scope it to one page: a layout that dispatches sections through a registry otherwise resolves statically to *every* section in the project.
|
|
182
|
+
- **`references.runtime`** — what recent renders actually touched, including `metaReads`, which sees a layout sidecar that no parser can.
|
|
183
|
+
- **`references.static`** — the single-file parse, in that file's own vocabulary.
|
|
184
|
+
|
|
185
|
+
`contract.complete` is false when a branch could not be read, and `incomplete` names each with a reason. An incomplete contract is still useful, but absence is not proof.
|
|
186
|
+
|
|
187
|
+
## Checking a document — `mikser_check_entity`
|
|
188
|
+
|
|
189
|
+
Answers "does this document have what its layout needs", before the mistake ships. A mistyped key does not fail a build: the section it named simply does not render.
|
|
190
|
+
|
|
191
|
+
It classifies the entity first, because not every document is a page — `page`, `data` (never renders, but other entities query it; reports who, and what they read off it) or `unreferenced` (nothing in the catalog reads it, which is not an error — the catalog is readable over the API).
|
|
192
|
+
|
|
193
|
+
Read `missing` first, but check **`missingFrom`**:
|
|
194
|
+
|
|
195
|
+
- `schema` — a [zod schema](https://www.npmjs.com/package/mikser-io-schemas) declares the key required. Authoritative, and it needed no template parsing.
|
|
196
|
+
- `inferred` — read out of the templates. Strong evidence, not proof: it models one engine's semantics and a guard it cannot see makes a fine document look broken.
|
|
197
|
+
|
|
198
|
+
With a schema you also get `drift`: `readButNotDeclared` (layouts consume a key nothing declares — a schema gap, or a template typo) and `declaredButNotRead` (declared, unread — dead, or served over the API).
|
|
199
|
+
|
|
200
|
+
Everything else means "you may want to look", never "this is broken": `missingOptional` is guarded and safe to omit, `unused` may be consumed by another layout or an API client, `untraceable` means an ancestor was read but its members could not be followed, and `unresolvedSections` names a section that matched no template.
|
|
180
201
|
|
|
181
202
|
## Entity properties
|
|
182
203
|
|
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
|
|
@@ -153,6 +153,118 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
153
153
|
const edgesOf = (snap) => parse(snap?.refClosure, [])
|
|
154
154
|
const readsOf = (snap) => parse(snap?.metaReads, [])
|
|
155
155
|
|
|
156
|
+
// A DECLARED contract, walked out of a zod schema.
|
|
157
|
+
//
|
|
158
|
+
// This is the only source here that is engine-agnostic by construction. It
|
|
159
|
+
// says nothing about liquid or handlebars, it needs no filter table, and it
|
|
160
|
+
// states required-versus-optional outright instead of inferring it from
|
|
161
|
+
// `{% if %}` guards and `| default:` filters — which is the same distinction
|
|
162
|
+
// reconstructed by reading templates, and got wrong twice.
|
|
163
|
+
//
|
|
164
|
+
// Where a schema exists it therefore OUTRANKS anything read out of a
|
|
165
|
+
// template, and the inferred contract is demoted to reporting drift.
|
|
166
|
+
const zdef = (t) => t?._zod?.def ?? t?._def ?? {}
|
|
167
|
+
function schemaKeys(type, prefix, required, optional, depth = 0) {
|
|
168
|
+
const def = zdef(type)
|
|
169
|
+
if (!def.type || depth > 12) return
|
|
170
|
+
switch (def.type) {
|
|
171
|
+
// A wrapper says the value beneath it may be absent; everything
|
|
172
|
+
// under it inherits that, since a document omitting the parent
|
|
173
|
+
// cannot be faulted for omitting its members.
|
|
174
|
+
case 'optional':
|
|
175
|
+
case 'nullable':
|
|
176
|
+
case 'default':
|
|
177
|
+
if (prefix) { required.delete(prefix); optional.add(prefix) }
|
|
178
|
+
schemaKeys(def.innerType, prefix, required, new Set(), depth + 1)
|
|
179
|
+
for (const k of collectAll(def.innerType, prefix, depth + 1)) optional.add(k)
|
|
180
|
+
return
|
|
181
|
+
case 'object':
|
|
182
|
+
for (const [key, child] of Object.entries(def.shape ?? {})) {
|
|
183
|
+
const path = prefix ? `${prefix}.${key}` : key
|
|
184
|
+
const childDef = zdef(child)
|
|
185
|
+
const soft = ['optional', 'nullable', 'default'].includes(childDef.type)
|
|
186
|
+
;(soft ? optional : required).add(path)
|
|
187
|
+
schemaKeys(child, path, required, optional, depth + 1)
|
|
188
|
+
}
|
|
189
|
+
return
|
|
190
|
+
case 'array':
|
|
191
|
+
schemaKeys(def.element, `${prefix}[]`, required, optional, depth + 1)
|
|
192
|
+
return
|
|
193
|
+
default:
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Every path under a type, regardless of requiredness — used to mark a whole
|
|
198
|
+
// optional subtree optional.
|
|
199
|
+
function collectAll(type, prefix, depth = 0) {
|
|
200
|
+
const req = new Set(); const opt = new Set()
|
|
201
|
+
schemaKeys(type, prefix, req, opt, depth)
|
|
202
|
+
return [...req, ...opt]
|
|
203
|
+
}
|
|
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
|
+
|
|
254
|
+
// The schema declared for this entity, if the schemas plugin is loaded and
|
|
255
|
+
// the entity names one. `schemaKey` is the plugin's own config — commonly
|
|
256
|
+
// `meta.layout`, so a layout and its schema share a name.
|
|
257
|
+
const schemaFor = (entity) => {
|
|
258
|
+
const api = runtime.options.schemas
|
|
259
|
+
if (!api?.lookup) return null
|
|
260
|
+
const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
|
|
261
|
+
const name = key.split('.').reduce((o, k) => o?.[k], entity)
|
|
262
|
+
?? entity?.meta?.schema ?? entity?.meta?.layout
|
|
263
|
+
if (!name) return null
|
|
264
|
+
const schema = api.lookup(name)
|
|
265
|
+
return schema ? { name, schema } : null
|
|
266
|
+
}
|
|
267
|
+
|
|
156
268
|
// What every render read OFF this entity, and who read it.
|
|
157
269
|
//
|
|
158
270
|
// The contract for a document that never renders. `metaReads` answers "what
|
|
@@ -331,17 +443,40 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
331
443
|
// A real contract, from the other side: the keys renders
|
|
332
444
|
// actually took off this entity, held to the same standard
|
|
333
445
|
// as a page — same comparison, same lists.
|
|
334
|
-
|
|
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)
|
|
335
465
|
return ok({
|
|
336
466
|
entity: id, kind: 'data',
|
|
337
467
|
verdict: 'not a page — other entities consume it',
|
|
338
468
|
consumedBy: consumers,
|
|
339
469
|
checked: read.keys.length > 0,
|
|
340
470
|
reliable: read.keys.length > 0,
|
|
471
|
+
missingFrom: declared ? 'schema' : 'consumers',
|
|
341
472
|
missing: cmp.missing,
|
|
473
|
+
missingOptional: cmp.missingOptional,
|
|
342
474
|
likelyTypos: cmp.likelyTypos,
|
|
343
475
|
unused: cmp.unused,
|
|
344
476
|
untraceable: cmp.untraceable,
|
|
477
|
+
...(peers?.gaps.length
|
|
478
|
+
? { peerGaps: peers.gaps, peerGroup: { type: peers.type, peers: peers.peers } }
|
|
479
|
+
: {}),
|
|
345
480
|
consumedKeys: read.keys,
|
|
346
481
|
counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
|
|
347
482
|
missing: cmp.missing.length, unused: cmp.unused.length },
|
|
@@ -351,15 +486,29 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
351
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.'
|
|
352
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.',
|
|
353
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.',
|
|
354
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.',
|
|
355
492
|
],
|
|
356
493
|
})
|
|
357
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)
|
|
358
500
|
return ok({
|
|
359
501
|
entity: id, kind: 'unreferenced',
|
|
360
502
|
verdict: 'no contract could be derived',
|
|
361
503
|
checked: false,
|
|
504
|
+
...(orphanPeers?.gaps.length
|
|
505
|
+
? { peerGaps: orphanPeers.gaps,
|
|
506
|
+
peerGroup: { type: orphanPeers.type, peers: orphanPeers.peers } }
|
|
507
|
+
: {}),
|
|
362
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
|
+
: []),
|
|
363
512
|
'This entity has never rendered, declares no meta.layout, and no render in the manifest queried it. Nothing in this project reads it.',
|
|
364
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.',
|
|
365
514
|
'If it was MEANT to be a page, it matched no layout — check the layouts match patterns or set meta.layout.',
|
|
@@ -383,8 +532,42 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
383
532
|
// `missing` is the ONLY list that should read as "this is probably
|
|
384
533
|
// wrong". A key the layout guards with `{% if %}` is one the layout
|
|
385
534
|
// was written to work without, so it belongs in the softer list.
|
|
386
|
-
|
|
387
|
-
|
|
535
|
+
// `unused` always comes from what is CONSUMED — a key nothing reads
|
|
536
|
+
// is the question there, and a schema does not answer it.
|
|
537
|
+
const inferred = compare(entity, consumed, optional)
|
|
538
|
+
const { provided, unused, untraceable, likelyTypos } = inferred
|
|
539
|
+
|
|
540
|
+
// `missing` prefers the DECLARED contract. A schema states
|
|
541
|
+
// required-versus-optional outright, needs no template parsing, and
|
|
542
|
+
// is engine-agnostic by construction — where one exists, nothing
|
|
543
|
+
// read out of a template should be able to call a document broken.
|
|
544
|
+
const peerCheck = await peerGapsFor(entity)
|
|
545
|
+
const declaredSchema = schemaFor(entity)
|
|
546
|
+
let missing = inferred.missing
|
|
547
|
+
let missingOptional = inferred.missingOptional
|
|
548
|
+
let drift = null
|
|
549
|
+
if (declaredSchema) {
|
|
550
|
+
const req = new Set(); const opt = new Set()
|
|
551
|
+
schemaKeys(declaredSchema.schema, '', req, opt)
|
|
552
|
+
// Both sets are candidates: an absent OPTIONAL key still has
|
|
553
|
+
// to be reported, in the softer list, or a schema's optionality
|
|
554
|
+
// would silently drop it from the report altogether.
|
|
555
|
+
const bySchema = compare(entity, new Set([...req, ...opt]), opt)
|
|
556
|
+
missing = bySchema.missing
|
|
557
|
+
missingOptional = bySchema.missingOptional
|
|
558
|
+
// Where the two disagree, which is the drift the schemas plugin
|
|
559
|
+
// exists to catch: a layout reading a key nobody declared, or a
|
|
560
|
+
// declared key no layout reads.
|
|
561
|
+
const knows = (k) => req.has(k) || opt.has(k)
|
|
562
|
+
|| [...req, ...opt].some(d => d.startsWith(`${k}.`) || d.startsWith(`${k}[`))
|
|
563
|
+
const isRead = (k) => consumed.has(k)
|
|
564
|
+
|| [...consumed].some(c => c.startsWith(`${k}.`) || c.startsWith(`${k}[`))
|
|
565
|
+
drift = {
|
|
566
|
+
schema: declaredSchema.name,
|
|
567
|
+
readButNotDeclared: [...consumed].filter(k => k && !knows(k)).sort(),
|
|
568
|
+
declaredButNotRead: [...req].filter(k => !isRead(k)).sort(),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
388
571
|
|
|
389
572
|
// A declared section that resolved to no template under this
|
|
390
573
|
// layout. Weak evidence, reported apart from `missing` and never a
|
|
@@ -402,11 +585,20 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
402
585
|
layout: layoutId,
|
|
403
586
|
layoutFrom: mode,
|
|
404
587
|
reliable,
|
|
588
|
+
// Where `missing` came from, because the two are not equally
|
|
589
|
+
// trustworthy and a caller acting on it deserves to know which
|
|
590
|
+
// it has: 'schema' is declared, 'inferred' is read out of
|
|
591
|
+
// templates and can be wrong.
|
|
592
|
+
missingFrom: declaredSchema ? 'schema' : 'inferred',
|
|
405
593
|
missing,
|
|
406
594
|
likelyTypos,
|
|
407
595
|
missingOptional,
|
|
596
|
+
...(drift ? { drift } : {}),
|
|
408
597
|
unused,
|
|
409
598
|
untraceable,
|
|
599
|
+
...(peerCheck?.gaps.length
|
|
600
|
+
? { peerGaps: peerCheck.gaps, peerGroup: { type: peerCheck.type, peers: peerCheck.peers } }
|
|
601
|
+
: {}),
|
|
410
602
|
unresolvedSections,
|
|
411
603
|
counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
|
|
412
604
|
missingOptional: missingOptional.length, unused: unused.length },
|
|
@@ -414,9 +606,11 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
414
606
|
scopedToPartials: usedPartials?.length ?? null },
|
|
415
607
|
observedReads: observed.length,
|
|
416
608
|
notes: [
|
|
417
|
-
'missing is the strong signal
|
|
609
|
+
'missing is the strong signal, but read missingFrom first. "schema" means a zod schema DECLARES the key required — authoritative, and it needed no template parsing. "inferred" means it was read out of the templates, which models one engine\'s semantics and has been wrong: a guard it cannot see makes a fine document look broken. Treat an inferred missing as strong evidence, not proof.',
|
|
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).',
|
|
418
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.',
|
|
419
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.',
|
|
420
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.',
|
|
421
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.',
|
|
422
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.
|
|
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",
|
|
@@ -291,3 +291,183 @@ describe('mikser_check_entity: not a page', () => {
|
|
|
291
291
|
assert.equal(r, null)
|
|
292
292
|
})
|
|
293
293
|
})
|
|
294
|
+
|
|
295
|
+
// A DECLARED contract outranks one read out of templates.
|
|
296
|
+
//
|
|
297
|
+
// A zod schema says nothing about liquid. It needs no filter table, states
|
|
298
|
+
// required-versus-optional outright rather than inferring it from `{% if %}`
|
|
299
|
+
// guards and `| default:`, and is engine-agnostic by construction. Where one
|
|
300
|
+
// exists, nothing parsed out of a template should be able to call a document
|
|
301
|
+
// broken — that inference has been wrong, and `missing` is the one list that
|
|
302
|
+
// must only ever mean "probably wrong".
|
|
303
|
+
describe('mikser_check_entity: with a declared schema', () => {
|
|
304
|
+
let checkWithSchema, dir2
|
|
305
|
+
|
|
306
|
+
before(async () => {
|
|
307
|
+
const { z } = await import('zod')
|
|
308
|
+
dir2 = await mkdtemp(path.join(tmpdir(), 'mikser-check-schema-'))
|
|
309
|
+
const entities = []
|
|
310
|
+
for (const [rel, content] of Object.entries(LAYOUTS)) {
|
|
311
|
+
const uri = path.join(dir2, rel)
|
|
312
|
+
await mkdir(path.dirname(uri), { recursive: true })
|
|
313
|
+
await writeFile(uri, content)
|
|
314
|
+
entities.push({ id: `/layouts/${rel}`, name: rel.replace(/\.liquid$/, ''), uri,
|
|
315
|
+
collection: 'layouts', template: 'liquid' })
|
|
316
|
+
}
|
|
317
|
+
// Declares a key the layouts do NOT read, and omits one they do.
|
|
318
|
+
const schema = z.object({
|
|
319
|
+
title: z.string(),
|
|
320
|
+
byline: z.string(), // declared, no layout reads it
|
|
321
|
+
hero: z.object({
|
|
322
|
+
title: z.string(),
|
|
323
|
+
subtitle: z.string().optional(), // declared optional
|
|
324
|
+
}),
|
|
325
|
+
})
|
|
326
|
+
const doc = { id: '/documents/schemed.md', name: 'schemed', collection: 'documents',
|
|
327
|
+
meta: { schema: 'article', title: 'T', sections: ['hero'],
|
|
328
|
+
hero: { title: 'T', tags: [{ label: 'A' }] } } }
|
|
329
|
+
entities.push(doc)
|
|
330
|
+
|
|
331
|
+
const useDatabase = () => ({ handle: { prepare: (sql) => ({
|
|
332
|
+
all: () => [],
|
|
333
|
+
get: () => (sql.includes('refClosure')
|
|
334
|
+
? { refClosure: JSON.stringify([
|
|
335
|
+
{ kind: 'layout', target: '/layouts/page.liquid' },
|
|
336
|
+
...PAGE_PARTIALS.map(t => ({ kind: 'partial', target: t })),
|
|
337
|
+
]) }
|
|
338
|
+
: { metaReads: JSON.stringify(['data.meta.title', 'data.meta.hero.title']) }),
|
|
339
|
+
}) } })
|
|
340
|
+
const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
|
|
341
|
+
const findEntities = async (q) => entities.filter(e => q.name == null || e.name === q.name)
|
|
342
|
+
const runtime = {
|
|
343
|
+
options: { schemas: { lookup: (n) => (n === 'article' ? schema : undefined), names: () => ['article'] } },
|
|
344
|
+
renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
|
|
345
|
+
}
|
|
346
|
+
runtime.options.layouts = {
|
|
347
|
+
inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
|
|
348
|
+
}
|
|
349
|
+
const registry = new Map()
|
|
350
|
+
runtime.options.mcp = { simpleTool: (n, _d, _s, h) => registry.set(n, h) }
|
|
351
|
+
registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
|
|
352
|
+
collection: 'layouts', logger: { debug() {} } })
|
|
353
|
+
checkWithSchema = async (id) =>
|
|
354
|
+
JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
after(async () => { if (dir2) await rm(dir2, { recursive: true, force: true }) })
|
|
358
|
+
|
|
359
|
+
it('takes `missing` from the schema and says so', async () => {
|
|
360
|
+
const r = await checkWithSchema('/documents/schemed.md')
|
|
361
|
+
assert.equal(r.missingFrom, 'schema')
|
|
362
|
+
assert.ok(r.missing.includes('byline'), `missing: ${r.missing.join(', ')}`)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('honours zod optionality instead of inferring it from the template', async () => {
|
|
366
|
+
// `.optional()` states it. No guard analysis, no filter table.
|
|
367
|
+
const r = await checkWithSchema('/documents/schemed.md')
|
|
368
|
+
assert.ok(!r.missing.includes('hero.subtitle'))
|
|
369
|
+
assert.ok(r.missingOptional.includes('hero.subtitle'),
|
|
370
|
+
`missingOptional: ${r.missingOptional.join(', ')}`)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('reports drift in both directions', async () => {
|
|
374
|
+
const r = await checkWithSchema('/documents/schemed.md')
|
|
375
|
+
assert.equal(r.drift.schema, 'article')
|
|
376
|
+
// The layouts read hero.tags[].label; nothing declares it.
|
|
377
|
+
assert.ok(r.drift.readButNotDeclared.some(k => k.startsWith('hero.tags')),
|
|
378
|
+
`readButNotDeclared: ${r.drift.readButNotDeclared.join(', ')}`)
|
|
379
|
+
// byline is declared and no layout reads it.
|
|
380
|
+
assert.ok(r.drift.declaredButNotRead.includes('byline'),
|
|
381
|
+
`declaredButNotRead: ${r.drift.declaredButNotRead.join(', ')}`)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('labels an inferred contract as inferred when there is no schema', async () => {
|
|
385
|
+
const r = await check('/documents/good.md')
|
|
386
|
+
assert.equal(r.missingFrom, 'inferred')
|
|
387
|
+
assert.ok(r.notes.some(n => /has been wrong/.test(n)),
|
|
388
|
+
'the weaker source has to say that it is weaker')
|
|
389
|
+
})
|
|
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
|
+
})
|