mikser-io-layouts 2.7.0 → 2.8.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/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/inspect.js CHANGED
@@ -320,9 +320,17 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
320
320
  // The subset an author actually writes. Everything else in `consumes` is
321
321
  // render-time furniture — helpers, loop variables, plugin surfaces — and
322
322
  // is kept, but it is not what a document's front matter is checked against.
323
+ // Entity meta, whichever root binding reached it.
324
+ //
325
+ // A template can arrive at the same keys by more than one name: `data` is
326
+ // whatever a layout sidecar returned, and the entity is ALSO bound under
327
+ // its own type, so `document.meta.title` and `data.meta.title` are the same
328
+ // document key. Matching only `data.` was lmed-shaped — a project with no
329
+ // sidecar writes `document.meta.` and would have got an empty contract,
330
+ // which reads exactly like a layout that needs nothing.
331
+ const META_ROOT = /^[A-Za-z_$][\w$]*\.meta\./
323
332
  const meta = [...consumes]
324
- .filter(p => p.startsWith('data.meta.'))
325
- .map(p => p.slice('data.meta.'.length))
333
+ .map(p => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null)))
326
334
  .filter(Boolean)
327
335
 
328
336
  return {
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,207 @@ 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
+ // Which layout this entity ACTUALLY rendered with, taken from its most
133
+ // recent snapshot rather than predicted.
134
+ //
135
+ // Layout selection has three mechanisms — declared meta.layout, a match
136
+ // pattern, and the auto-layout peel ladder — and re-deriving them here
137
+ // would be a second implementation to keep in step with the first. The
138
+ // manifest already recorded the answer.
139
+ const layoutFromSnapshot = (id) => {
140
+ const db = useDatabase?.()
141
+ if (!db?.handle) return null
142
+ const row = db.handle.prepare(
143
+ 'SELECT refClosure FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
144
+ ).get(id)
145
+ if (!row?.refClosure) return null
146
+ try {
147
+ return JSON.parse(row.refClosure).find(e => e.kind === 'layout')?.target ?? null
148
+ } catch { return null }
149
+ }
150
+
151
+ const readsFromSnapshot = (id) => {
152
+ const db = useDatabase?.()
153
+ if (!db?.handle) return []
154
+ const row = db.handle.prepare(
155
+ 'SELECT metaReads FROM mikser_snapshots WHERE id = ? ORDER BY renderedAt DESC LIMIT 1',
156
+ ).get(id)
157
+ if (!row?.metaReads) return []
158
+ try { return JSON.parse(row.metaReads) } catch { return [] }
159
+ }
160
+
161
+ mcp.simpleTool(
162
+ 'mikser_check_entity',
163
+ 'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
164
+ + 'A mistyped key does not fail a build. The section it named simply does not render, the page goes out '
165
+ + 'with a hole in it, and every signal reads clean. This is the check that catches it: what the layout '
166
+ + 'tree needs, against what the document actually provides.\n\n'
167
+ + 'Read `missing` first — keys the layout consumes that this document has no value for. Then '
168
+ + '`likelyTypos`, which pairs a key you wrote against a key the layout wanted that is one or two edits '
169
+ + 'away; a typo usually shows up as both at once, which is a far stronger signal than either alone.\n\n'
170
+ + '`unused` is weaker evidence and says so: another layout may consume the key, and keys the engine '
171
+ + 'itself owns are excluded. Treat it as a prompt to look, not a defect.\n\n'
172
+ + 'Check `reliable` before trusting an absence. When false, some branch of the layout could not be read '
173
+ + 'or the document has never rendered, and a key may be missing from the analysis rather than from the '
174
+ + 'document.',
175
+ {
176
+ id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
177
+ layout: z.string().optional()
178
+ .describe('Layout id to check against. Omit to use the layout this entity last rendered with, '
179
+ + 'which is what the manifest recorded rather than a prediction.'),
180
+ },
181
+ async ({ id, layout }) => {
182
+ const entity = await findEntity({ id })
183
+ if (!entity) {
184
+ return { isError: true, content: [{ type: 'text', text:
185
+ `No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
186
+ }
187
+
188
+ // Resolve the layout: explicit, then what it rendered with, then
189
+ // what it asked for. Each fallback is weaker, and which one was
190
+ // used is reported so the caller can judge.
191
+ let layoutId = layout ?? layoutFromSnapshot(id)
192
+ let source = layout ? 'given' : (layoutId ? 'last render' : null)
193
+ if (!layoutId && entity.meta?.layout) {
194
+ const named = (await findEntities({ name: entity.meta.layout })) ?? []
195
+ layoutId = named.find(e => e.collection === collection)?.id ?? null
196
+ source = layoutId ? 'meta.layout' : null
197
+ }
198
+ if (!layoutId) {
199
+ return { isError: true, content: [{ type: 'text', text:
200
+ `Cannot tell which layout renders ${id}: it has never rendered and declares no meta.layout. `
201
+ + 'Pass `layout` explicitly, or build once so the manifest records it.' }] }
202
+ }
203
+
204
+ const inspected = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
205
+ const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
206
+
207
+ // Consumed is the UNION of both halves, and it has to be: the
208
+ // contract is blind to a sidecar (plain JavaScript, nothing to
209
+ // parse) and the observed reads are blind to a branch this document
210
+ // never took. Using either alone reports keys as unused that are
211
+ // demonstrably read.
212
+ const observed = readsFromSnapshot(id).map(bareKey)
213
+ const consumed = new Set([...(contract.meta ?? []), ...observed])
214
+ const provided = flattenMeta(entity.meta ?? {}, '', new Set())
215
+
216
+ const isUsed = (key) => consumed.has(key)
217
+ // A parent is used when anything under it is: a document
218
+ // providing `hero` for a layout that reads `hero.title` has not
219
+ // provided a spare key.
220
+ || [...consumed].some(c => c.startsWith(`${key}.`) || c.startsWith(`${key}[`))
221
+ const isProvided = (key) => provided.has(key)
222
+ // And the reverse: a layout reading `hero.tags[]` is satisfied
223
+ // by a document that provided the array.
224
+ || [...provided].some(p => p.startsWith(`${key}.`) || p.startsWith(`${key}[`))
225
+
226
+ const missing = [...consumed].filter(k => k && !isProvided(k)).sort()
227
+ const unused = [...provided]
228
+ .filter(k => !isUsed(k))
229
+ .filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
230
+ .sort()
231
+
232
+ // The pair. A mistyped key leaves a hole where the real key was
233
+ // wanted AND a stray key nothing reads, so matching one list
234
+ // against the other turns two weak signals into one strong one.
235
+ const likelyTypos = []
236
+ for (const wrote of unused) {
237
+ let best = null
238
+ for (const wanted of missing) {
239
+ const d = distance(wrote, wanted)
240
+ if (d > 0 && d <= 2 && (!best || d < best.distance)) best = { meant: wanted, distance: d }
241
+ }
242
+ if (best) likelyTypos.push({ wrote, ...best })
243
+ }
244
+
245
+ const reliable = contract.complete === true && observed.length > 0
246
+ return {
247
+ content: [{ type: 'text', text: JSON.stringify({
248
+ entity: id,
249
+ layout: layoutId,
250
+ layoutFrom: source,
251
+ reliable,
252
+ missing,
253
+ likelyTypos,
254
+ unused,
255
+ counts: { consumed: consumed.size, provided: provided.size,
256
+ missing: missing.length, unused: unused.length },
257
+ contract: { complete: contract.complete, incomplete: contract.incomplete ?? [] },
258
+ observedReads: observed.length,
259
+ notes: [
260
+ 'missing is the strong signal: the layout consumes the key and this document has no value for it.',
261
+ 'likelyTypos pairs an unused key against a missing one within two edits. A real typo usually produces both, which is why the pair is worth more than either list.',
262
+ 'unused is weak evidence. A key may be consumed by a DIFFERENT layout — a card rendered on a listing page is the common case — and keys the engine owns (href, lang, layout, task, ...) are excluded outright.',
263
+ 'reliable is false when the contract could not be fully read, or when this entity has never rendered so nothing was observed. An absence is only evidence when reliable is true.',
264
+ 'Coverage is the union of what the layout templates read (the contract) and what renders actually read (observed, which includes sidecars). Neither sees everything alone.',
265
+ ],
266
+ }, null, 2) }],
267
+ }
268
+ },
269
+ )
270
+
271
+ logger.debug('MCP tool registered: mikser_check_entity (layouts plugin)')
272
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.7.0",
3
+ "version": "2.8.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",
@@ -0,0 +1,150 @@
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
+ const LAYOUTS = {
21
+ 'page.liquid': '<html>{% include "sections/hero" %}<p>{{ document.meta.summary }}</p></html>',
22
+ 'sections/hero.liquid':
23
+ '{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1>'
24
+ + '{% for t in hero.tags %}<i>{{ t.label }}</i>{% endfor %}',
25
+ }
26
+
27
+ const DOCS = {
28
+ '/documents/good.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] } },
29
+ // `heroo` instead of `hero`: nothing under it is read, and everything the
30
+ // layout wanted is absent. One mistake, visible from both sides.
31
+ '/documents/typo.md': { summary: 'S', heroo: { title: 'T' } },
32
+ // Provides a key no layout here consumes. Weak evidence on purpose.
33
+ '/documents/extra.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] }, card: { order: 2 } },
34
+ // Engine-owned keys must never be reported as unused.
35
+ '/documents/routing.md': { summary: 'S', href: '/r', lang: 'bg', task: 'worker',
36
+ hero: { title: 'T', tags: [{ label: 'A' }] } },
37
+ }
38
+
39
+ let dir, check
40
+
41
+ before(async () => {
42
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
43
+ const entities = []
44
+ for (const [rel, content] of Object.entries(LAYOUTS)) {
45
+ const uri = path.join(dir, rel)
46
+ await mkdir(path.dirname(uri), { recursive: true })
47
+ await writeFile(uri, content)
48
+ entities.push({ id: `/layouts/${rel}`, name: rel.replace(/\.liquid$/, ''), uri,
49
+ collection: 'layouts', template: 'liquid' })
50
+ }
51
+ for (const [id, meta] of Object.entries(DOCS)) {
52
+ entities.push({ id, name: id.slice(11), collection: 'documents', meta })
53
+ }
54
+
55
+ // A stand-in manifest. Every document rendered with page.liquid, and read
56
+ // what a correct one would — so `unused` and `missing` are exercised
57
+ // without needing a real build in a unit test.
58
+ const observed = ['data.meta.summary', 'data.meta.hero', 'data.meta.hero.title',
59
+ 'data.meta.hero.tags', 'data.meta.hero.tags[]', 'data.meta.hero.tags[].label']
60
+ const useDatabase = () => ({
61
+ handle: {
62
+ prepare: (sql) => ({
63
+ get: (id) => (DOCS[id]
64
+ ? (sql.includes('refClosure')
65
+ ? { refClosure: JSON.stringify([{ kind: 'layout', target: '/layouts/page.liquid' }]) }
66
+ : { metaReads: JSON.stringify(observed) })
67
+ : undefined),
68
+ }),
69
+ },
70
+ })
71
+
72
+ const findEntity = async ({ id }) => entities.find(e => e.id === id) ?? null
73
+ const findEntities = async (q) => entities.filter(e => q.name == null || e.name === q.name)
74
+ const runtime = {
75
+ options: {},
76
+ renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
77
+ }
78
+ runtime.options.layouts = {
79
+ inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
80
+ }
81
+ const registry = new Map()
82
+ runtime.options.mcp = { simpleTool: (name, _d, _s, handler) => registry.set(name, handler) }
83
+ registerCheckTool({ runtime, findEntity, findEntities, useDatabase,
84
+ collection: 'layouts', logger: { debug() {} } })
85
+
86
+ check = async (id) => JSON.parse((await registry.get('mikser_check_entity')({ id })).content[0].text)
87
+ })
88
+
89
+ after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
90
+
91
+ describe('mikser_check_entity', () => {
92
+ it('says nothing is wrong with a document that satisfies the layout', async () => {
93
+ // The assertion that matters most: no false positives. A check that
94
+ // cries wolf on a correct document is one nobody reads.
95
+ const r = await check('/documents/good.md')
96
+ assert.deepEqual(r.missing, [])
97
+ assert.deepEqual(r.unused, [])
98
+ assert.deepEqual(r.likelyTypos, [])
99
+ })
100
+
101
+ it('names the keys the layout wants and the document lacks', async () => {
102
+ const r = await check('/documents/typo.md')
103
+ assert.ok(r.missing.includes('hero.title'), `missing: ${r.missing.join(', ')}`)
104
+ assert.ok(r.missing.includes('hero.tags[].label'))
105
+ })
106
+
107
+ it('pairs the key written against the key meant', async () => {
108
+ // The point of the tool. A typo shows up as a hole AND a stray key, and
109
+ // matching one list against the other turns two weak signals into one
110
+ // strong one.
111
+ const r = await check('/documents/typo.md')
112
+ const pair = r.likelyTypos.find(t => t.wrote === 'heroo')
113
+ assert.ok(pair, `no pairing found in ${JSON.stringify(r.likelyTypos)}`)
114
+ assert.equal(pair.meant, 'hero')
115
+ assert.equal(pair.distance, 1)
116
+ })
117
+
118
+ it('reports a key no layout reads, without calling it an error', async () => {
119
+ const r = await check('/documents/extra.md')
120
+ assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
121
+ assert.deepEqual(r.missing, [], 'an unused key is not a missing one')
122
+ assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)),
123
+ 'the weakness of this signal has to travel with it')
124
+ })
125
+
126
+ it('never reports engine-owned keys as unused', async () => {
127
+ // href, lang and task are consumed by mikser, not by any layout. Listing
128
+ // them on every document would teach a reader to skip the list.
129
+ const r = await check('/documents/routing.md')
130
+ for (const key of ['href', 'lang', 'task']) {
131
+ assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
132
+ }
133
+ })
134
+
135
+ it('counts a parent as provided when the layout reads through it', async () => {
136
+ const r = await check('/documents/good.md')
137
+ assert.ok(!r.missing.includes('hero'), 'hero is provided by providing its contents')
138
+ })
139
+
140
+ it('uses the layout the entity actually rendered with', async () => {
141
+ const r = await check('/documents/good.md')
142
+ assert.equal(r.layout, '/layouts/page.liquid')
143
+ assert.equal(r.layoutFrom, 'last render')
144
+ })
145
+
146
+ it('refuses an entity it cannot find rather than guessing', async () => {
147
+ const r = await check('/documents/nope.md').catch(() => null)
148
+ assert.equal(r, null, 'a missing entity must not produce a report')
149
+ })
150
+ })