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 +1 -1
- package/lib/assembly.js +23 -4
- package/lib/inspect.js +51 -4
- package/lib/mcp.js +313 -1
- package/package.json +2 -2
- package/test/check-entity.test.js +267 -0
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
|
|
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
|
-
.
|
|
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.
|
|
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.
|
|
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
|
+
})
|