mikser-io-layouts 2.9.3 → 2.10.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 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 })` runs a layout against `samples` real entities and returns the resolved template source, render context, parsed refs, and any errors. Used by the `mikser-io-mcp` plugin's `mikser_layouts_inspect` MCP tool but available to any plugin that wants to surface layout introspection.
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
@@ -153,6 +153,69 @@ 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
+ // The schema declared for this entity, if the schemas plugin is loaded and
206
+ // the entity names one. `schemaKey` is the plugin's own config — commonly
207
+ // `meta.layout`, so a layout and its schema share a name.
208
+ const schemaFor = (entity) => {
209
+ const api = runtime.options.schemas
210
+ if (!api?.lookup) return null
211
+ const key = runtime.options.schemasKey ?? runtime.options.schemaKey ?? 'meta.schema'
212
+ const name = key.split('.').reduce((o, k) => o?.[k], entity)
213
+ ?? entity?.meta?.schema ?? entity?.meta?.layout
214
+ if (!name) return null
215
+ const schema = api.lookup(name)
216
+ return schema ? { name, schema } : null
217
+ }
218
+
156
219
  // What every render read OFF this entity, and who read it.
157
220
  //
158
221
  // The contract for a document that never renders. `metaReads` answers "what
@@ -383,8 +446,41 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
383
446
  // `missing` is the ONLY list that should read as "this is probably
384
447
  // wrong". A key the layout guards with `{% if %}` is one the layout
385
448
  // was written to work without, so it belongs in the softer list.
386
- const { provided, missing, missingOptional, unused, untraceable, likelyTypos } =
387
- compare(entity, consumed, optional)
449
+ // `unused` always comes from what is CONSUMED — a key nothing reads
450
+ // is the question there, and a schema does not answer it.
451
+ const inferred = compare(entity, consumed, optional)
452
+ const { provided, unused, untraceable, likelyTypos } = inferred
453
+
454
+ // `missing` prefers the DECLARED contract. A schema states
455
+ // required-versus-optional outright, needs no template parsing, and
456
+ // is engine-agnostic by construction — where one exists, nothing
457
+ // read out of a template should be able to call a document broken.
458
+ const declaredSchema = schemaFor(entity)
459
+ let missing = inferred.missing
460
+ let missingOptional = inferred.missingOptional
461
+ let drift = null
462
+ if (declaredSchema) {
463
+ const req = new Set(); const opt = new Set()
464
+ schemaKeys(declaredSchema.schema, '', req, opt)
465
+ // Both sets are candidates: an absent OPTIONAL key still has
466
+ // to be reported, in the softer list, or a schema's optionality
467
+ // would silently drop it from the report altogether.
468
+ const bySchema = compare(entity, new Set([...req, ...opt]), opt)
469
+ missing = bySchema.missing
470
+ missingOptional = bySchema.missingOptional
471
+ // Where the two disagree, which is the drift the schemas plugin
472
+ // exists to catch: a layout reading a key nobody declared, or a
473
+ // declared key no layout reads.
474
+ const knows = (k) => req.has(k) || opt.has(k)
475
+ || [...req, ...opt].some(d => d.startsWith(`${k}.`) || d.startsWith(`${k}[`))
476
+ const isRead = (k) => consumed.has(k)
477
+ || [...consumed].some(c => c.startsWith(`${k}.`) || c.startsWith(`${k}[`))
478
+ drift = {
479
+ schema: declaredSchema.name,
480
+ readButNotDeclared: [...consumed].filter(k => k && !knows(k)).sort(),
481
+ declaredButNotRead: [...req].filter(k => !isRead(k)).sort(),
482
+ }
483
+ }
388
484
 
389
485
  // A declared section that resolved to no template under this
390
486
  // layout. Weak evidence, reported apart from `missing` and never a
@@ -402,9 +498,15 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
402
498
  layout: layoutId,
403
499
  layoutFrom: mode,
404
500
  reliable,
501
+ // Where `missing` came from, because the two are not equally
502
+ // trustworthy and a caller acting on it deserves to know which
503
+ // it has: 'schema' is declared, 'inferred' is read out of
504
+ // templates and can be wrong.
505
+ missingFrom: declaredSchema ? 'schema' : 'inferred',
405
506
  missing,
406
507
  likelyTypos,
407
508
  missingOptional,
509
+ ...(drift ? { drift } : {}),
408
510
  unused,
409
511
  untraceable,
410
512
  unresolvedSections,
@@ -414,7 +516,8 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
414
516
  scopedToPartials: usedPartials?.length ?? null },
415
517
  observedReads: observed.length,
416
518
  notes: [
417
- 'missing is the strong signal: the layout consumes the key unconditionally and this document has no value for it.',
519
+ '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.',
520
+ '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
521
  '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
522
  '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.',
420
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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.9.3",
3
+ "version": "2.10.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,100 @@ 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
+ })