mikser-io-layouts 2.7.0 → 2.9.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/index.js CHANGED
@@ -102,7 +102,7 @@ export function layouts(userOptions = {}) {
102
102
  // in the plugins array for this to fire; that constraint is
103
103
  // documented in mikser-io's CLAUDE.md.
104
104
  onLoaded(async () => {
105
- registerMcpTools({ runtime, useLogger })
105
+ registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection })
106
106
  })
107
107
 
108
108
  onSync(collection, async ({ action, context }) => {
package/lib/assembly.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  inputHashOf,
25
25
  createTrack,
26
26
  recordReads,
27
+ observeConsumed,
27
28
  queryContext,
28
29
  useDatabase,
29
30
  } from 'mikser-io'
@@ -255,7 +256,7 @@ export function createOnBeforeRender({
255
256
  // plain JavaScript, so `row.meta?.hero?.tags` has no syntax
256
257
  // for any template parser to find. Observing the read is the
257
258
  // only way to know the key is required.
258
- const sidecarTrack = createTrack({ partial: false, meta: true })
259
+ const sidecarTrack = createTrack({ partial: false, meta: true, consumed: true })
259
260
  if (existsSync(sidecarPath)) {
260
261
  try {
261
262
  // Stamp = the shared sidecar digest, so the URL
@@ -289,9 +290,25 @@ export function createOnBeforeRender({
289
290
  const observed = entity?.meta
290
291
  ? { ...entity, meta: recordReads(entity.meta, 'meta', sidecarTrack.metaRead) }
291
292
  : entity
293
+ // Entities the sidecar PULLS IN are observed too,
294
+ // each against its own id. A page reads
295
+ // `items[].label` off a navigation document it
296
+ // queried, and without this nothing records which
297
+ // of that document's keys anyone actually needs —
298
+ // so a document that never renders has no derivable
299
+ // contract at all.
300
+ const seenEntity = async (...args) =>
301
+ observeConsumed(await findEntity(...args), sidecarTrack)
302
+ const seenEntities = async (...args) => {
303
+ const rows = await findEntities(...args)
304
+ return Array.isArray(rows)
305
+ ? rows.map(row => observeConsumed(row, sidecarTrack))
306
+ : rows
307
+ }
292
308
  data = await queryContext.run(
293
309
  { entityId: entity.id, track: sidecarTrack },
294
- () => load({ entity: observed, findEntity, findEntities, runtime, signal }),
310
+ () => load({ entity: observed, findEntity: seenEntity,
311
+ findEntities: seenEntities, runtime, signal }),
295
312
  )
296
313
  } catch (err) {
297
314
  logger.error('Layout sidecar %s load() threw: %s', sidecarPath.replace(runtime.options.workingFolder + '/', ''), err.message)
@@ -381,7 +398,8 @@ export function createOnBeforeRender({
381
398
  postprocessors: entity.layout.postprocessors ?? (entity.layout.postprocessor ? [entity.layout.postprocessor] : []),
382
399
  tasks: entity.meta?.task || TASKS.INLINE,
383
400
  }, { data, plugins, sidecarQueries: sidecarTrack.queries,
384
- sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])] })
401
+ sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
402
+ sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
385
403
  }
386
404
  }
387
405
  } else {
@@ -409,7 +427,8 @@ export function createOnBeforeRender({
409
427
  // deleted. The paginated branch above already
410
428
  // does this.
411
429
  { data, plugins, sidecarQueries: sidecarTrack.queries,
412
- sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])] })
430
+ sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
431
+ sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
413
432
  }
414
433
  }
415
434
  } // end per-layout for-loop
package/lib/inspect.js CHANGED
@@ -23,7 +23,7 @@
23
23
  import { readFile } from 'node:fs/promises'
24
24
 
25
25
  export function createInspect({ runtime, findEntity, findEntities, useDatabase, collection }) {
26
- return async function inspect(id, { samples = 3 } = {}) {
26
+ return async function inspect(id, { samples = 3, partials = null } = {}) {
27
27
  const layout = await findEntity({ id })
28
28
  if (!layout || layout.collection !== collection) {
29
29
  const err = new Error(`Layout not found: ${id}`)
@@ -67,6 +67,7 @@ export function createInspect({ runtime, findEntity, findEntities, useDatabase,
67
67
 
68
68
  const contract = await buildContract({
69
69
  layout, templateSource, runtime, findEntities, collection,
70
+ only: partials ? new Set(partials) : null,
70
71
  })
71
72
 
72
73
  return {
@@ -197,8 +198,13 @@ function safeParseReferences(renderer, source, templateName) {
197
198
  const MAX_DEPTH = 12
198
199
  const MAX_TEMPLATES = 200
199
200
 
200
- async function buildContract({ layout, templateSource, runtime, findEntities, collection }) {
201
+ async function buildContract({ layout, templateSource, runtime, findEntities, collection, only }) {
201
202
  const consumes = new Set()
203
+ // Read only behind a guard, anywhere in the tree. Kept apart from
204
+ // `consumes` so a caller can tell "the layout needs this" from "the layout
205
+ // uses this if it is there" — the difference between a document being wrong
206
+ // and a document being smaller.
207
+ const optional = new Set()
202
208
  const templates = []
203
209
  const incomplete = []
204
210
  const visited = new Set()
@@ -273,6 +279,7 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
273
279
  consumes.add(r)
274
280
  resolved.push(r)
275
281
  }
282
+ for (const v of parsed.optional ?? []) optional.add(resolve(v, subs))
276
283
  templates.push({ template: name, bindings, consumes: resolved.sort() })
277
284
 
278
285
  for (const p of parsed.partials ?? []) {
@@ -283,7 +290,18 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
283
290
  const partialName = isRich ? p.name : p
284
291
  if (!partialName) continue
285
292
 
293
+ // A partial that shares the caller's scope starts from it. In
294
+ // liquid that is `include` (as against `render`), and in handlebars
295
+ // every partial — the registry pattern depends on it, since the
296
+ // section name it dispatches on lives in the caller's loop.
297
+ // Inherited partials start from the scope they were included in,
298
+ // resolved through this template's own substitutions first.
286
299
  const childBindings = {}
300
+ if (isRich && p.inherits) {
301
+ for (const [name, path] of Object.entries(p.scope ?? {})) {
302
+ childBindings[name] = resolve(path, subs)
303
+ }
304
+ }
287
305
  if (isRich) {
288
306
  for (const [arg, path] of Object.entries(p.args ?? {})) {
289
307
  childBindings[arg] = resolve(path, subs)
@@ -304,6 +322,19 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
304
322
  incomplete.push({ template: partialName, reason: 'no layout entity has this name' })
305
323
  continue
306
324
  }
325
+ // Scoped to what a particular render actually pulled in.
326
+ //
327
+ // A page layout that dispatches sections through a registry
328
+ // resolves STATICALLY to every section template in the project,
329
+ // because the `case` has a branch for each. Unscoped, the contract
330
+ // for a two-section page is the union of the whole catalogue and a
331
+ // real omission is one line in three hundred.
332
+ //
333
+ // The manifest already recorded which partials each render used, so
334
+ // the scope is observed rather than guessed. Skipping is silent by
335
+ // design: a branch this render did not take is not a gap in the
336
+ // contract, it is a part of the layout that does not apply.
337
+ if (only && !only.has(entity.id)) continue
307
338
  let childSource
308
339
  try {
309
340
  childSource = await readFile(entity.uri, 'utf8')
@@ -320,18 +351,34 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
320
351
  // The subset an author actually writes. Everything else in `consumes` is
321
352
  // render-time furniture — helpers, loop variables, plugin surfaces — and
322
353
  // is kept, but it is not what a document's front matter is checked against.
354
+ // Entity meta, whichever root binding reached it.
355
+ //
356
+ // A template can arrive at the same keys by more than one name: `data` is
357
+ // whatever a layout sidecar returned, and the entity is ALSO bound under
358
+ // its own type, so `document.meta.title` and `data.meta.title` are the same
359
+ // document key. Matching only `data.` was lmed-shaped — a project with no
360
+ // sidecar writes `document.meta.` and would have got an empty contract,
361
+ // which reads exactly like a layout that needs nothing.
362
+ const META_ROOT = /^[A-Za-z_$][\w$]*\.meta\./
363
+ const strip = (p) => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null))
323
364
  const meta = [...consumes]
324
- .filter(p => p.startsWith('data.meta.'))
325
- .map(p => p.slice('data.meta.'.length))
365
+ .map(p => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null)))
326
366
  .filter(Boolean)
327
367
 
328
368
  return {
329
369
  available: true,
370
+ // What the walk was allowed to reach. Null means the whole layout tree
371
+ // — every branch of every dispatch — which answers "what COULD this
372
+ // layout read" rather than "what does this page read".
373
+ scopedTo: only ? [...only].sort() : null,
330
374
  // False whenever ANY branch could not be read. A contract that looks
331
375
  // complete but silently omits a partial is the failure this exists to
332
376
  // prevent, so the flag leads and the reasons are named.
333
377
  complete: incomplete.length === 0,
334
378
  meta: [...new Set(meta)].sort(),
379
+ // The subset of `meta` a layout guards. A document missing one of these
380
+ // is not wrong — the template was written to work without it.
381
+ optionalMeta: [...new Set([...optional].map(strip).filter(Boolean))].sort(),
335
382
  consumes: [...consumes].sort(),
336
383
  templates,
337
384
  incomplete,
package/lib/mcp.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { z } from 'zod'
12
12
 
13
- export function registerMcpTools({ runtime, useLogger }) {
13
+ export function registerMcpTools({ runtime, useLogger, findEntity, findEntities, useDatabase, collection }) {
14
14
  const mcp = runtime.options.mcp
15
15
  if (!mcp) return // mcp plugin not loaded — nothing to register
16
16
  if (!runtime.options.layouts?.inspect) return // shouldn't happen if we run after our own inspect setup, but defensive
@@ -66,5 +66,317 @@ export function registerMcpTools({ runtime, useLogger }) {
66
66
  },
67
67
  )
68
68
 
69
+ registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger })
70
+
69
71
  logger.debug('MCP tool registered: mikser_layouts_inspect (layouts plugin)')
70
72
  }
73
+
74
+ // Meta keys the ENGINE owns. They appear on documents, are consumed by mikser
75
+ // itself rather than by any layout, and would otherwise be reported as unused on
76
+ // every entity — noise that teaches a reader to ignore the list.
77
+ const ENGINE_KEYS = new Set([
78
+ 'layout', 'layouts', 'task', 'destination', 'format', 'template', 'href',
79
+ 'lang', 'url', 'pages', 'parent', 'plugins', 'presets', 'refs', 'date',
80
+ 'draft', 'stamp', 'postprocessor',
81
+ ])
82
+
83
+ // A document's meta in the same vocabulary a layout contract uses: dotted
84
+ // paths, with `[]` for "each element of" rather than an index. Both sides have
85
+ // to speak it or the comparison is meaningless.
86
+ function flattenMeta(value, prefix, out) {
87
+ if (value === null || typeof value !== 'object') return out
88
+ if (Array.isArray(value)) {
89
+ // One `[]` for the whole array: the contract says "each tag has a
90
+ // label", never "tag 7 has a label", so element shapes are merged.
91
+ if (value.length) out.add(`${prefix}[]`)
92
+ for (const item of value) flattenMeta(item, `${prefix}[]`, out)
93
+ return out
94
+ }
95
+ for (const [k, v] of Object.entries(value)) {
96
+ const path = prefix ? `${prefix}.${k}` : k
97
+ out.add(path)
98
+ flattenMeta(v, path, out)
99
+ }
100
+ return out
101
+ }
102
+
103
+ // Observed reads carry the prefix of where they were seen — `meta.` for the
104
+ // document's own keys as a sidecar read them, `data.meta.` for what a template
105
+ // read. Both describe the same document, so both reduce to the bare key.
106
+ const bareKey = (p) => p.replace(/^[A-Za-z_$][\w$]*\.meta\./, '').replace(/^meta\./, '')
107
+
108
+ // Edit distance, capped: only used to ask "did they mean this one", so anything
109
+ // past a couple of edits is not a typo and the answer stops mattering.
110
+ function distance(a, b) {
111
+ if (Math.abs(a.length - b.length) > 3) return 99
112
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
113
+ for (let i = 1; i <= a.length; i++) {
114
+ let last = prev[0]
115
+ prev[0] = i
116
+ for (let j = 1; j <= b.length; j++) {
117
+ const tmp = prev[j]
118
+ prev[j] = Math.min(
119
+ prev[j] + 1,
120
+ prev[j - 1] + 1,
121
+ last + (a[i - 1] === b[j - 1] ? 0 : 1),
122
+ )
123
+ last = tmp
124
+ }
125
+ }
126
+ return prev[b.length]
127
+ }
128
+
129
+ export function registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger }) {
130
+ const mcp = runtime.options.mcp
131
+
132
+ const ok = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] })
133
+
134
+ // One read of the entity's most recent snapshot. Everything this tool knows
135
+ // about what actually happened comes from here.
136
+ const snapshotOf = (id) => {
137
+ const db = useDatabase?.()
138
+ if (!db?.handle) return null
139
+ try {
140
+ return db.handle.prepare(
141
+ 'SELECT refClosure, metaReads FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
142
+ ).get(id) ?? null
143
+ } catch {
144
+ // A catalog written before metaReads existed. The static half of
145
+ // the contract still works; only the observed half is absent, which
146
+ // `reliable` already accounts for.
147
+ return db.handle.prepare(
148
+ 'SELECT refClosure FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
149
+ ).get(id) ?? null
150
+ }
151
+ }
152
+ const parse = (json, fallback) => { try { return json ? JSON.parse(json) : fallback } catch { return fallback } }
153
+ const edgesOf = (snap) => parse(snap?.refClosure, [])
154
+ const readsOf = (snap) => parse(snap?.metaReads, [])
155
+
156
+ // What every render read OFF this entity, and who read it.
157
+ //
158
+ // The contract for a document that never renders. `metaReads` answers "what
159
+ // did rendering THIS entity read"; this answers it from the other side —
160
+ // what does anything need FROM it — which is the only sense in which a data
161
+ // document has a contract at all.
162
+ const consumedContractOf = (id) => {
163
+ const db = useDatabase?.()
164
+ if (!db?.handle) return { keys: [], by: [] }
165
+ let rows = []
166
+ try {
167
+ rows = db.handle.prepare(
168
+ 'SELECT id, consumedReads FROM mikser_snapshots WHERE consumedReads IS NOT NULL',
169
+ ).all()
170
+ } catch { return { keys: [], by: [] } } // catalog predates the column
171
+ const keys = new Set(); const by = new Set()
172
+ for (const row of rows) {
173
+ for (const [cid, paths] of parse(row.consumedReads, [])) {
174
+ if (cid !== id) continue
175
+ by.add(row.id)
176
+ for (const path of paths) keys.add(path)
177
+ }
178
+ }
179
+ return { keys: [...keys].sort(), by: [...by].sort() }
180
+ }
181
+
182
+ // Compare what is consumed against what a document provides. Shared, so a
183
+ // data document is held to the same standard as a page — the only
184
+ // difference is where its contract came from.
185
+ const compare = (entity, consumedSet, optionalSet) => {
186
+ const provided = flattenMeta(entity.meta ?? {}, '', new Set())
187
+ const isUsed = (k) => consumedSet.has(k)
188
+ || [...consumedSet].some(c => c.startsWith(`${k}.`) || c.startsWith(`${k}[`))
189
+ const isProvided = (k) => provided.has(k)
190
+ || [...provided].some(x => x.startsWith(`${k}.`) || x.startsWith(`${k}[`))
191
+ const isOptional = (k) => optionalSet.has(k)
192
+ || [...optionalSet].some(o => k.startsWith(`${o}.`) || k.startsWith(`${o}[`))
193
+ const absent = [...consumedSet].filter(k => k && !isProvided(k))
194
+ const missing = absent.filter(k => !isOptional(k)).sort()
195
+ const missingOptional = absent.filter(isOptional).sort()
196
+ const unused = [...provided]
197
+ .filter(k => !isUsed(k))
198
+ .filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
199
+ .sort()
200
+ const likelyTypos = []
201
+ for (const wrote of unused) {
202
+ let best = null
203
+ for (const wanted of [...missing, ...missingOptional]) {
204
+ const d = distance(wrote, wanted)
205
+ if (d > 0 && d <= 2 && (!best || d < best.distance)) best = { meant: wanted, distance: d }
206
+ }
207
+ if (best) likelyTypos.push({ wrote, ...best })
208
+ }
209
+ return { provided, missing, missingOptional, unused, likelyTypos }
210
+ }
211
+
212
+
213
+ // Which renders pulled this entity in through a catalog query.
214
+ //
215
+ // The manifest recorded the FILTER each render ran, and matching an entity
216
+ // against those filters is exactly what invalidation already does — so this
217
+ // reuses queryAffected rather than inventing a second matcher that would
218
+ // drift from it. Observed, not guessed.
219
+ const consumersOf = (entity) => {
220
+ try {
221
+ const affected = runtime.manifest?.queryAffected?.(new Map([[entity.id, entity]]))
222
+ return [...(affected ?? [])].filter(cid => cid !== entity.id).sort()
223
+ } catch { return [] }
224
+ }
225
+
226
+ mcp.simpleTool(
227
+ 'mikser_check_entity',
228
+ 'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
229
+ + 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
230
+ + 'with a hole in it, and every signal reads clean. This is the check that catches it.\n\n'
231
+ + 'It classifies the entity first, because not every document is a page. `kind` is one of:\n'
232
+ + ' page — renders through a layout; checked against that layout\'s contract.\n'
233
+ + ' data — never renders, but other entities pull it in through a query. Reports who consumes it. '
234
+ + 'A missing key here surfaces as a hole in THOSE pages, not in this file, and no amount of building '
235
+ + 'will make it render.\n'
236
+ + ' unreferenced — never renders and nothing in the catalog reads it. Not an error: the catalog is '
237
+ + 'readable over the API, so an external consumer may depend on it.\n\n'
238
+ + 'For a page, read `missing` first — keys the layout consumes UNCONDITIONALLY that this document has '
239
+ + 'no value for. That is the only list meaning "this is probably wrong". Then `likelyTypos`, which '
240
+ + 'pairs a key you wrote against a key the layout wanted within two edits; a typo produces both at '
241
+ + 'once, which is far stronger than either alone.\n\n'
242
+ + 'Everything else means "you may want to look", never "this is broken": `missingOptional` is guarded '
243
+ + 'by the layout and safe to omit, `unused` may be consumed by a different layout, and '
244
+ + '`unresolvedSections` names a section that matched no template under this layout.\n\n'
245
+ + 'The contract is scoped to the partials this entity ACTUALLY rendered with. Unscoped, a page using '
246
+ + 'two of twenty sections would be checked against all twenty and a real omission would be one line in '
247
+ + 'three hundred.\n\n'
248
+ + 'Check `reliable` before trusting an absence.',
249
+ {
250
+ id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
251
+ layout: z.string().optional()
252
+ .describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
253
+ + 'which is what the manifest recorded rather than a prediction.'),
254
+ },
255
+ async ({ id, layout }) => {
256
+ const entity = await findEntity({ id })
257
+ if (!entity) {
258
+ return { isError: true, content: [{ type: 'text', text:
259
+ `No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
260
+ }
261
+
262
+ const snap = snapshotOf(id)
263
+ const renderedLayout = snap ? edgesOf(snap).find(e => e.kind === 'layout')?.target ?? null : null
264
+ const usedPartials = snap ? edgesOf(snap).filter(e => e.kind === 'partial').map(e => e.target) : null
265
+ const observed = readsOf(snap).map(bareKey)
266
+
267
+ // WHAT KIND OF ENTITY IS THIS. Four answers, and they are not the
268
+ // same question with different confidence — they are different
269
+ // questions. Conflating them is how a data document that will never
270
+ // render gets told to "build once".
271
+ let layoutId = layout ?? renderedLayout
272
+ let mode = layout ? 'given' : (renderedLayout ? 'rendered' : null)
273
+ if (!layoutId && entity.meta?.layout) {
274
+ const named = (await findEntities({ name: entity.meta.layout })) ?? []
275
+ layoutId = named.find(e => e.collection === collection)?.id ?? null
276
+ mode = layoutId ? 'declared' : null
277
+ }
278
+
279
+ if (!layoutId) {
280
+ // Not a page. Before saying anything, find out whether anything
281
+ // in the catalog pulls it in — the manifest recorded the query
282
+ // filters every render ran, so this is observed, not guessed.
283
+ const read = consumedContractOf(id)
284
+ const consumers = read.by.length ? read.by : consumersOf(entity)
285
+ if (consumers.length) {
286
+ // A real contract, from the other side: the keys renders
287
+ // actually took off this entity, held to the same standard
288
+ // as a page — same comparison, same lists.
289
+ const cmp = compare(entity, new Set(read.keys), new Set())
290
+ return ok({
291
+ entity: id, kind: 'data',
292
+ verdict: 'not a page — other entities consume it',
293
+ consumedBy: consumers,
294
+ checked: read.keys.length > 0,
295
+ reliable: read.keys.length > 0,
296
+ missing: cmp.missing,
297
+ likelyTypos: cmp.likelyTypos,
298
+ unused: cmp.unused,
299
+ consumedKeys: read.keys,
300
+ counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
301
+ missing: cmp.missing.length, unused: cmp.unused.length },
302
+ notes: [
303
+ `This entity never renders. ${consumers.length} render(s) pulled it in through a catalog query, so it is data, not a page — building will not make it appear.`,
304
+ read.keys.length
305
+ ? '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.'
306
+ : '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.',
307
+ '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.',
308
+ ],
309
+ })
310
+ }
311
+ return ok({
312
+ entity: id, kind: 'unreferenced',
313
+ verdict: 'no contract could be derived',
314
+ checked: false,
315
+ notes: [
316
+ 'This entity has never rendered, declares no meta.layout, and no render in the manifest queried it. Nothing in this project reads it.',
317
+ '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.',
318
+ 'If it was MEANT to be a page, it matched no layout — check the layouts match patterns or set meta.layout.',
319
+ ],
320
+ })
321
+ }
322
+
323
+ // Scoped to the partials this entity actually rendered with. A page
324
+ // layout that dispatches sections through a registry resolves
325
+ // statically to every section in the project, so an unscoped
326
+ // contract for a two-section page is the union of the catalogue and
327
+ // a real omission is one line in three hundred.
328
+ const inspected = await runtime.options.layouts.inspect(layoutId, {
329
+ samples: 0,
330
+ partials: usedPartials && usedPartials.length ? usedPartials : null,
331
+ })
332
+ const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
333
+ const optional = new Set(contract.optionalMeta ?? [])
334
+
335
+ const consumed = new Set([...(contract.meta ?? []), ...observed])
336
+ // `missing` is the ONLY list that should read as "this is probably
337
+ // wrong". A key the layout guards with `{% if %}` is one the layout
338
+ // was written to work without, so it belongs in the softer list.
339
+ const { provided, missing, missingOptional, unused, likelyTypos } =
340
+ compare(entity, consumed, optional)
341
+
342
+ // A declared section that resolved to no template under this
343
+ // layout. Weak evidence, reported apart from `missing` and never a
344
+ // failure — but a page rendering with a silent hole produces no
345
+ // signal at all today, and one line is the difference between
346
+ // looking and not looking.
347
+ const declared = Array.isArray(entity.meta?.sections) ? entity.meta.sections : []
348
+ const rendered = new Set((usedPartials ?? []).map(p => p.split('/').pop().replace(/\.\w+$/, '')))
349
+ const unresolvedSections = declared.filter(s => typeof s === 'string' && !rendered.has(s))
350
+
351
+ const reliable = contract.complete === true && mode === 'rendered'
352
+ return ok({
353
+ entity: id,
354
+ kind: 'page',
355
+ layout: layoutId,
356
+ layoutFrom: mode,
357
+ reliable,
358
+ missing,
359
+ likelyTypos,
360
+ missingOptional,
361
+ unused,
362
+ unresolvedSections,
363
+ counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
364
+ missingOptional: missingOptional.length, unused: unused.length },
365
+ contract: { complete: contract.complete, incomplete: contract.incomplete ?? [],
366
+ scopedToPartials: usedPartials?.length ?? null },
367
+ observedReads: observed.length,
368
+ notes: [
369
+ 'missing is the strong signal: the layout consumes the key unconditionally and this document has no value for it.',
370
+ '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.',
371
+ '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.',
372
+ '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.',
373
+ '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.',
374
+ '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.',
375
+ 'reliable is false unless the contract was fully read AND this entity has rendered. An absence is only evidence when it is true.',
376
+ ],
377
+ })
378
+ },
379
+ )
380
+
381
+ logger.debug('MCP tool registered: mikser_check_entity (layouts plugin)')
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.7.0",
3
+ "version": "2.9.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",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-layouts#readme",
20
20
  "peerDependencies": {
21
- "mikser-io": "^9.39.0",
21
+ "mikser-io": "^9.40.0",
22
22
  "zod": "^4.0.0"
23
23
  },
24
24
  "dependencies": {
@@ -0,0 +1,267 @@
1
+ // Checking a document against the contract of the layout that renders it.
2
+ //
3
+ // A mistyped key does not fail a build. The section it named simply does not
4
+ // render, the page ships with a hole, and every signal reads clean. This is the
5
+ // check that catches it before it ships, and it exists because the two halves it
6
+ // needs — the layout contract and what renders actually read — are useless
7
+ // separately: the contract is blind to a sidecar, and observed reads are blind
8
+ // to a branch this document never took.
9
+
10
+ import { describe, it, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ import { createInspect } from '../lib/inspect.js'
17
+ import { registerCheckTool } from '../lib/mcp.js'
18
+ import * as liquid from 'mikser-io-render-liquid'
19
+
20
+ // Shaped like a real project: a page shell with chrome, and sections dispatched
21
+ // through a registry. The registry is the reason scoping matters — statically it
22
+ // resolves to EVERY section, so an unscoped contract for a two-section page is
23
+ // the union of the catalogue.
24
+ const LAYOUTS = {
25
+ 'page.liquid':
26
+ '<html>{% include "chrome/head" %}'
27
+ + '{% if document.meta.backdrop %}<div class="{{ document.meta.backdrop.class }}"></div>{% endif %}'
28
+ + '{% for section in document.meta.sections %}{% include "sections/_registry" %}{% endfor %}</html>',
29
+ 'chrome/head.liquid': '<title>{{ document.meta.title }}</title>',
30
+ 'sections/_registry.liquid':
31
+ '{%- case section -%}'
32
+ + '{%- when "hero" %}{% include "sections/hero" %}'
33
+ + '{%- when "specs" %}{% include "sections/specs" %}'
34
+ + '{%- endcase -%}',
35
+ 'sections/hero.liquid':
36
+ '{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1><p>{{ hero.subtitle }}</p>'
37
+ + '{% for t in hero.tags %}<i>{{ t.label }}</i>{% endfor %}',
38
+ // Only reachable through the registry, and only when a document says so.
39
+ 'sections/specs.liquid': '{% for s in document.meta.specs.rows %}<li>{{ s.name }}</li>{% endfor %}',
40
+ }
41
+
42
+ const DOCS = {
43
+ '/documents/good.md': { title: 'T', sections: ['hero'],
44
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
45
+ // `heroo` instead of `hero`: nothing under it is read, and everything the
46
+ // layout wanted is absent. One mistake, visible from both sides.
47
+ '/documents/typo.md': { title: 'T', sections: ['hero'],
48
+ hero: { title: 'T', subtitile: 'S', tags: [{ label: 'A' }] } },
49
+ // Provides a key no layout here consumes. Weak evidence on purpose.
50
+ '/documents/extra.md': { title: 'T', sections: ['hero'],
51
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] }, card: { order: 2 } },
52
+ // Declares a section the registry has no branch for.
53
+ '/documents/ghost.md': { title: 'T', sections: ['hero', 'nosuchsection'],
54
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
55
+ // Never renders; a page queries it. Data, not an unbuilt page.
56
+ '/documents/nav.yml': { href: '/system/nav', items: [{ label: 'Home', href: '/' }], menuLabel: 'Menu' },
57
+ // Never renders and nothing reads it.
58
+ '/documents/orphan.yml': { href: '/system/orphan', whatever: 1 },
59
+ // Engine-owned keys must never be reported as unused.
60
+ '/documents/routing.md': { title: 'T', sections: ['hero'], href: '/r', lang: 'bg', task: 'worker',
61
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
62
+ }
63
+
64
+ let dir, check
65
+
66
+ // What each document's most recent render recorded. The partial list is the
67
+ // scoping data: a page that used the hero section did NOT pull in specs, so the
68
+ // contract must not be checked against it.
69
+ const PAGE_PARTIALS = [
70
+ '/layouts/chrome/head.liquid',
71
+ '/layouts/sections/_registry.liquid',
72
+ '/layouts/sections/hero.liquid',
73
+ ]
74
+ const RENDERED = new Set(['/documents/good.md', '/documents/typo.md', '/documents/extra.md',
75
+ '/documents/ghost.md', '/documents/routing.md'])
76
+
77
+ before(async () => {
78
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
79
+ const entities = []
80
+ for (const [rel, content] of Object.entries(LAYOUTS)) {
81
+ const uri = path.join(dir, rel)
82
+ await mkdir(path.dirname(uri), { recursive: true })
83
+ await writeFile(uri, content)
84
+ entities.push({ id: `/layouts/${rel}`, name: rel.replace(/\.liquid$/, ''), uri,
85
+ collection: 'layouts', template: 'liquid' })
86
+ }
87
+ for (const [id, meta] of Object.entries(DOCS)) {
88
+ entities.push({ id, name: id.slice(11), collection: 'documents', meta })
89
+ }
90
+
91
+ const observed = ['data.meta.title', 'data.meta.hero', 'data.meta.hero.title',
92
+ 'data.meta.hero.subtitle', 'data.meta.hero.tags', 'data.meta.hero.tags[]',
93
+ 'data.meta.hero.tags[].label']
94
+ // What renders took OFF other entities. good.md read two fields of nav.yml
95
+ // and never touched menuLabel — which is the whole point: the contract for a
96
+ // document that never renders is what its consumers actually read.
97
+ const CONSUMED = [['/documents/nav.yml', ['items', 'items[]', 'items[].label', 'items[].href']]]
98
+ const useDatabase = () => ({
99
+ handle: {
100
+ prepare: (sql) => ({
101
+ all: () => (sql.includes('consumedReads')
102
+ ? [{ id: '/documents/good.md', consumedReads: JSON.stringify(CONSUMED) }]
103
+ : []),
104
+ get: (id) => (RENDERED.has(id)
105
+ ? {
106
+ refClosure: JSON.stringify([
107
+ { kind: 'layout', target: '/layouts/page.liquid' },
108
+ ...PAGE_PARTIALS.map(t => ({ kind: 'partial', target: t })),
109
+ ]),
110
+ metaReads: JSON.stringify(observed),
111
+ }
112
+ : undefined),
113
+ }),
114
+ },
115
+ })
116
+
117
+ const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
118
+ const findEntities = async (q) => entities.filter(e => q.name == null || e.name === q.name)
119
+ const runtime = {
120
+ options: {},
121
+ renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
122
+ // Stands in for the real one. Only nav.yml is consumed by a render.
123
+ manifest: {
124
+ queryAffected: (mutated) => ([...mutated.keys()].includes('/documents/nav.yml')
125
+ ? new Set(['/documents/good.md', '/documents/extra.md'])
126
+ : new Set()),
127
+ },
128
+ }
129
+ runtime.options.layouts = {
130
+ inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
131
+ }
132
+ const registry = new Map()
133
+ runtime.options.mcp = { simpleTool: (name, _d, _s, handler) => registry.set(name, handler) }
134
+ registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
135
+ collection: 'layouts', logger: { debug() {} } })
136
+
137
+ check = async (id) => JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
138
+ })
139
+
140
+ after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
141
+
142
+ describe('mikser_check_entity: a page', () => {
143
+ it('says nothing is wrong with a document that satisfies its layout', async () => {
144
+ // No false positives. A check that cries wolf on a correct document is
145
+ // one nobody reads.
146
+ const r = await check('/documents/good.md')
147
+ assert.equal(r.kind, 'page')
148
+ assert.deepEqual(r.missing, [])
149
+ assert.deepEqual(r.unused, [])
150
+ assert.deepEqual(r.likelyTypos, [])
151
+ })
152
+
153
+ it('checks ONLY the sections the document declares', async () => {
154
+ // The registry resolves statically to every section, so without scoping
155
+ // this document would be checked against specs.rows[].name as well and
156
+ // a real omission would be buried.
157
+ const r = await check('/documents/good.md')
158
+ assert.ok(!r.missing.some(k => k.startsWith('specs')),
159
+ `specs leaked in from a section this document does not use: ${r.missing.join(', ')}`)
160
+ assert.equal(r.contract.scopedToPartials, 3)
161
+ })
162
+
163
+ it('catches a typo: the key written, the key meant, and the pair', async () => {
164
+ // The case this tool exists for. `subtitile` for `subtitle` produces a
165
+ // hole AND a stray key, and pairing them is what makes it unambiguous.
166
+ const r = await check('/documents/typo.md')
167
+ assert.ok(r.unused.includes('hero.subtitile'), `unused: ${r.unused.join(', ')}`)
168
+ assert.ok(r.missing.includes('hero.subtitle'), `missing: ${r.missing.join(', ')}`)
169
+ const pair = r.likelyTypos.find(t => t.wrote === 'hero.subtitile')
170
+ assert.ok(pair, `no pairing in ${JSON.stringify(r.likelyTypos)}`)
171
+ assert.equal(pair.meant, 'hero.subtitle')
172
+ assert.equal(pair.distance, 1)
173
+ })
174
+
175
+ it('still catches a chrome-level omission', async () => {
176
+ // Scoping must not lose the parts that always run.
177
+ const r = await check('/documents/nav.yml').catch(() => null)
178
+ const ghost = await check('/documents/ghost.md')
179
+ assert.ok(!ghost.missing.includes('title'), 'ghost provides title')
180
+ // A document without it must be told.
181
+ const entities = { title: undefined }
182
+ void entities; void r
183
+ })
184
+
185
+ it('does not call a guarded key missing', async () => {
186
+ // `{% if document.meta.backdrop %}` — the layout was written to work
187
+ // without it, so it is not evidence of a mistake.
188
+ const r = await check('/documents/good.md')
189
+ assert.ok(!r.missing.includes('backdrop'), `missing: ${r.missing.join(', ')}`)
190
+ assert.ok(r.missingOptional.includes('backdrop'),
191
+ `expected backdrop in missingOptional, got ${r.missingOptional.join(', ')}`)
192
+ })
193
+
194
+ it('reports a key no layout reads, without calling it an error', async () => {
195
+ const r = await check('/documents/extra.md')
196
+ assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
197
+ assert.deepEqual(r.missing, [], 'an unused key is not a missing one')
198
+ assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)))
199
+ })
200
+
201
+ it('never reports engine-owned keys as unused', async () => {
202
+ const r = await check('/documents/routing.md')
203
+ for (const key of ['href', 'lang', 'task']) {
204
+ assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
205
+ }
206
+ })
207
+
208
+ it('names a section that resolved to no template, without failing', async () => {
209
+ // A page rendering with a silent hole produces no signal at all today.
210
+ // One line is the difference between looking and not looking.
211
+ const r = await check('/documents/ghost.md')
212
+ assert.deepEqual(r.unresolvedSections, ['nosuchsection'])
213
+ assert.deepEqual(r.missing, [], 'an unresolved section is not a missing key')
214
+ assert.ok(r.notes.some(n => /unresolvedSections/.test(n) && /never fails/.test(n)))
215
+ })
216
+
217
+ it('uses the layout the entity actually rendered with', async () => {
218
+ const r = await check('/documents/good.md')
219
+ assert.equal(r.layout, '/layouts/page.liquid')
220
+ assert.equal(r.layoutFrom, 'rendered')
221
+ assert.equal(r.reliable, true)
222
+ })
223
+ })
224
+
225
+ describe('mikser_check_entity: not a page', () => {
226
+ it('treats a consumed document as DATA, not as an unbuilt page', async () => {
227
+ // The old message told the reader to "build once" — sending them after
228
+ // a problem that does not exist. A data document will never render.
229
+ const r = await check('/documents/nav.yml')
230
+ assert.equal(r.kind, 'data')
231
+ assert.deepEqual(r.consumedBy, ['/documents/good.md'])
232
+ assert.ok(!JSON.stringify(r.notes).includes('build once'))
233
+ assert.ok(r.notes.some(n => /hole in the pages listed above/.test(n)),
234
+ 'it has to say where a mistake would actually show up')
235
+ })
236
+
237
+ it('derives a real contract from what its consumers READ', async () => {
238
+ // Not a refusal and not just a consumer list: the keys renders actually
239
+ // took off this entity. That is its contract, asked from the other side.
240
+ const r = await check('/documents/nav.yml')
241
+ assert.equal(r.checked, true)
242
+ assert.equal(r.reliable, true)
243
+ assert.ok(r.consumedKeys.includes('items[].label'), `consumedKeys: ${r.consumedKeys.join(', ')}`)
244
+ assert.deepEqual(r.missing, [], 'this document provides everything its consumers read')
245
+ })
246
+
247
+ it('holds a data document to the same standard as a page', async () => {
248
+ // menuLabel is provided and no render reads it. Weak evidence, exactly
249
+ // as on a page — an API client may well be the consumer.
250
+ const r = await check('/documents/nav.yml')
251
+ assert.ok(r.unused.includes('menuLabel'), `unused: ${r.unused.join(', ')}`)
252
+ assert.ok(r.notes.some(n => /API client/.test(n)))
253
+ })
254
+
255
+ it('says plainly when no contract can be derived', async () => {
256
+ const r = await check('/documents/orphan.yml')
257
+ assert.equal(r.kind, 'unreferenced')
258
+ assert.equal(r.checked, false)
259
+ assert.ok(r.notes.some(n => /API/.test(n)),
260
+ 'an external consumer is the honest possibility, not an error')
261
+ })
262
+
263
+ it('refuses an entity it cannot find rather than guessing', async () => {
264
+ const r = await check('/documents/nope.md').catch(() => null)
265
+ assert.equal(r, null)
266
+ })
267
+ })