mikser-io-layouts 2.8.0 → 2.9.2

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/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,16 @@ 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
+ // Plain arrays, because this context is journaled as JSON
402
+ // before the render runs — a Set arrives as {}.
403
+ //
404
+ // That same round-trip is why these are what the
405
+ // SIDECAR read and nothing more: the recording
406
+ // views inside `data` do not survive it, so a key
407
+ // only the template touches is never recorded
408
+ // here. See check_entity's untraceable handling.
409
+ sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
410
+ sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
385
411
  }
386
412
  }
387
413
  } else {
@@ -409,7 +435,16 @@ export function createOnBeforeRender({
409
435
  // deleted. The paginated branch above already
410
436
  // does this.
411
437
  { data, plugins, sidecarQueries: sidecarTrack.queries,
412
- sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])] })
438
+ // Plain arrays, because this context is journaled as JSON
439
+ // before the render runs — a Set arrives as {}.
440
+ //
441
+ // That same round-trip is why these are what the
442
+ // SIDECAR read and nothing more: the recording
443
+ // views inside `data` do not survive it, so a key
444
+ // only the template touches is never recorded
445
+ // here. See check_entity's untraceable handling.
446
+ sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
447
+ sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
413
448
  }
414
449
  }
415
450
  } // 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')
@@ -329,17 +360,25 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
329
360
  // sidecar writes `document.meta.` and would have got an empty contract,
330
361
  // which reads exactly like a layout that needs nothing.
331
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))
332
364
  const meta = [...consumes]
333
365
  .map(p => (META_ROOT.test(p) ? p.replace(META_ROOT, '') : (p.startsWith('meta.') ? p.slice(5) : null)))
334
366
  .filter(Boolean)
335
367
 
336
368
  return {
337
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,
338
374
  // False whenever ANY branch could not be read. A contract that looks
339
375
  // complete but silently omits a partial is the failure this exists to
340
376
  // prevent, so the flag leads and the reasons are named.
341
377
  complete: incomplete.length === 0,
342
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(),
343
382
  consumes: [...consumes].sort(),
344
383
  templates,
345
384
  incomplete,
package/lib/mcp.js CHANGED
@@ -129,49 +129,168 @@ function distance(a, b) {
129
129
  export function registerCheckTool({ runtime, findEntity, findEntities, useDatabase, collection, logger }) {
130
130
  const mcp = runtime.options.mcp
131
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) => {
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) => {
140
137
  const db = useDatabase?.()
141
138
  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
139
  try {
147
- return JSON.parse(row.refClosure).find(e => e.kind === 'layout')?.target ?? null
148
- } catch { return null }
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
+ }
149
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, [])
150
155
 
151
- const readsFromSnapshot = (id) => {
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) => {
152
163
  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 [] }
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 candidates = [...provided]
197
+ .filter(k => !isUsed(k))
198
+ .filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
199
+ .sort()
200
+
201
+ // "The parent was read, and every child looks unused" is the signature
202
+ // of a read this engine cannot follow, not of dead data.
203
+ //
204
+ // Provenance is recorded where a value is READ off an entity. When that
205
+ // value is then handed on — a sidecar returning it to a template — the
206
+ // context carrying it is journaled as JSON in between, so the recording
207
+ // view does not survive and only the top-level access was ever seen. A
208
+ // form built from `enquiry.fields` shows `enquiry` consumed and every
209
+ // field beneath it apparently unused, on pages that visibly render it.
210
+ //
211
+ // Reporting those as unused is worse than saying nothing: someone
212
+ // acting on the list deletes a working form. They are collapsed under
213
+ // the ancestor that WAS read, and named for what they are — untraceable.
214
+ // Only when the ancestor was read and NOTHING under it was — "parent
215
+ // consumed, every child unused" is the signature.
216
+ //
217
+ // If any sibling IS consumed, the engine can evidently see inside that
218
+ // structure, so a key nobody read is real evidence and must stay in
219
+ // `unused`. Without this the safeguard would swallow the strongest
220
+ // signal the tool has: a typo like `hero.subtitile` sits under `hero`,
221
+ // which is certainly consumed.
222
+ const blind = (ancestor) => ![...consumedSet].some(c =>
223
+ c.startsWith(`${ancestor}.`) || c.startsWith(`${ancestor}[`))
224
+ const ancestorRead = (key) => {
225
+ let p = key
226
+ while (true) {
227
+ if (p.endsWith('[]')) p = p.slice(0, -2)
228
+ else if (p.includes('.')) p = p.replace(/\.[^.]+$/, '')
229
+ else return null
230
+ if (!p) return null
231
+ if (consumedSet.has(p)) return blind(p) ? p : null
232
+ }
233
+ }
234
+ const unused = []
235
+ const byAncestor = new Map()
236
+ for (const key of candidates) {
237
+ const under = ancestorRead(key)
238
+ if (!under) { unused.push(key); continue }
239
+ if (!byAncestor.has(under)) byAncestor.set(under, [])
240
+ byAncestor.get(under).push(key)
241
+ }
242
+ const untraceable = [...byAncestor]
243
+ .map(([under, keys]) => ({ under, keys: keys.sort() }))
244
+ .sort((a, b) => a.under.localeCompare(b.under))
245
+ const likelyTypos = []
246
+ for (const wrote of unused) {
247
+ let best = null
248
+ for (const wanted of [...missing, ...missingOptional]) {
249
+ const d = distance(wrote, wanted)
250
+ if (d > 0 && d <= 2 && (!best || d < best.distance)) best = { meant: wanted, distance: d }
251
+ }
252
+ if (best) likelyTypos.push({ wrote, ...best })
253
+ }
254
+ return { provided, missing, missingOptional, unused, untraceable, likelyTypos }
255
+ }
256
+
257
+
258
+ // Which renders pulled this entity in through a catalog query.
259
+ //
260
+ // The manifest recorded the FILTER each render ran, and matching an entity
261
+ // against those filters is exactly what invalidation already does — so this
262
+ // reuses queryAffected rather than inventing a second matcher that would
263
+ // drift from it. Observed, not guessed.
264
+ const consumersOf = (entity) => {
265
+ try {
266
+ const affected = runtime.manifest?.queryAffected?.(new Map([[entity.id, entity]]))
267
+ return [...(affected ?? [])].filter(cid => cid !== entity.id).sort()
268
+ } catch { return [] }
159
269
  }
160
270
 
161
271
  mcp.simpleTool(
162
272
  'mikser_check_entity',
163
273
  'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
164
274
  + '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.',
275
+ + 'with a hole in it, and every signal reads clean. This is the check that catches it.\n\n'
276
+ + 'It classifies the entity first, because not every document is a page. `kind` is one of:\n'
277
+ + ' page — renders through a layout; checked against that layout\'s contract.\n'
278
+ + ' data — never renders, but other entities pull it in through a query. Reports who consumes it. '
279
+ + 'A missing key here surfaces as a hole in THOSE pages, not in this file, and no amount of building '
280
+ + 'will make it render.\n'
281
+ + ' unreferenced — never renders and nothing in the catalog reads it. Not an error: the catalog is '
282
+ + 'readable over the API, so an external consumer may depend on it.\n\n'
283
+ + 'For a page, read `missing` first — keys the layout consumes UNCONDITIONALLY that this document has '
284
+ + 'no value for. That is the only list meaning "this is probably wrong". Then `likelyTypos`, which '
285
+ + 'pairs a key you wrote against a key the layout wanted within two edits; a typo produces both at '
286
+ + 'once, which is far stronger than either alone.\n\n'
287
+ + 'Everything else means "you may want to look", never "this is broken": `missingOptional` is guarded '
288
+ + 'by the layout and safe to omit, `unused` may be consumed by a different layout, and '
289
+ + '`unresolvedSections` names a section that matched no template under this layout.\n\n'
290
+ + 'The contract is scoped to the partials this entity ACTUALLY rendered with. Unscoped, a page using '
291
+ + 'two of twenty sections would be checked against all twenty and a real omission would be one line in '
292
+ + 'three hundred.\n\n'
293
+ + 'Check `reliable` before trusting an absence.',
175
294
  {
176
295
  id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
177
296
  layout: z.string().optional()
@@ -185,86 +304,126 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
185
304
  `No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
186
305
  }
187
306
 
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)
307
+ const snap = snapshotOf(id)
308
+ const renderedLayout = snap ? edgesOf(snap).find(e => e.kind === 'layout')?.target ?? null : null
309
+ const usedPartials = snap ? edgesOf(snap).filter(e => e.kind === 'partial').map(e => e.target) : null
310
+ const observed = readsOf(snap).map(bareKey)
311
+
312
+ // WHAT KIND OF ENTITY IS THIS. Four answers, and they are not the
313
+ // same question with different confidence — they are different
314
+ // questions. Conflating them is how a data document that will never
315
+ // render gets told to "build once".
316
+ let layoutId = layout ?? renderedLayout
317
+ let mode = layout ? 'given' : (renderedLayout ? 'rendered' : null)
193
318
  if (!layoutId && entity.meta?.layout) {
194
319
  const named = (await findEntities({ name: entity.meta.layout })) ?? []
195
320
  layoutId = named.find(e => e.collection === collection)?.id ?? null
196
- source = layoutId ? 'meta.layout' : null
321
+ mode = layoutId ? 'declared' : null
197
322
  }
323
+
198
324
  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.' }] }
325
+ // Not a page. Before saying anything, find out whether anything
326
+ // in the catalog pulls it in — the manifest recorded the query
327
+ // filters every render ran, so this is observed, not guessed.
328
+ const read = consumedContractOf(id)
329
+ const consumers = read.by.length ? read.by : consumersOf(entity)
330
+ if (consumers.length) {
331
+ // A real contract, from the other side: the keys renders
332
+ // actually took off this entity, held to the same standard
333
+ // as a page — same comparison, same lists.
334
+ const cmp = compare(entity, new Set(read.keys), new Set())
335
+ return ok({
336
+ entity: id, kind: 'data',
337
+ verdict: 'not a page — other entities consume it',
338
+ consumedBy: consumers,
339
+ checked: read.keys.length > 0,
340
+ reliable: read.keys.length > 0,
341
+ missing: cmp.missing,
342
+ likelyTypos: cmp.likelyTypos,
343
+ unused: cmp.unused,
344
+ untraceable: cmp.untraceable,
345
+ consumedKeys: read.keys,
346
+ counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
347
+ missing: cmp.missing.length, unused: cmp.unused.length },
348
+ notes: [
349
+ `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.`,
350
+ read.keys.length
351
+ ? 'consumedKeys is what those renders actually READ off this entity. That is its contract: a key here with no value is a hole in the pages listed above, not in this file.'
352
+ : 'No key-level reads are recorded yet, so only the consumer list is known. Build once with a current mikser and the fields each render takes off this entity get recorded.',
353
+ 'untraceable means an ancestor WAS read but the members could not be followed: the value was handed to a template and provenance does not survive that hop. Treat them as consumed.',
354
+ 'unused here means no render took the key. It may still be served to an API client or another process, which this tool cannot see — mikser is not always a static site generator.',
355
+ ],
356
+ })
357
+ }
358
+ return ok({
359
+ entity: id, kind: 'unreferenced',
360
+ verdict: 'no contract could be derived',
361
+ checked: false,
362
+ notes: [
363
+ 'This entity has never rendered, declares no meta.layout, and no render in the manifest queried it. Nothing in this project reads it.',
364
+ 'That is not an error. The catalog is readable over the API, so another process or an external consumer may depend on it, and this tool cannot see them.',
365
+ 'If it was MEANT to be a page, it matched no layout — check the layouts match patterns or set meta.layout.',
366
+ ],
367
+ })
202
368
  }
203
369
 
204
- const inspected = await runtime.options.layouts.inspect(layoutId, { samples: 0 })
370
+ // Scoped to the partials this entity actually rendered with. A page
371
+ // layout that dispatches sections through a registry resolves
372
+ // statically to every section in the project, so an unscoped
373
+ // contract for a two-section page is the union of the catalogue and
374
+ // a real omission is one line in three hundred.
375
+ const inspected = await runtime.options.layouts.inspect(layoutId, {
376
+ samples: 0,
377
+ partials: usedPartials && usedPartials.length ? usedPartials : null,
378
+ })
205
379
  const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
380
+ const optional = new Set(contract.optionalMeta ?? [])
206
381
 
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
382
  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}[`))
383
+ // `missing` is the ONLY list that should read as "this is probably
384
+ // wrong". A key the layout guards with `{% if %}` is one the layout
385
+ // was written to work without, so it belongs in the softer list.
386
+ const { provided, missing, missingOptional, unused, untraceable, likelyTypos } =
387
+ compare(entity, consumed, optional)
225
388
 
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
- }
389
+ // A declared section that resolved to no template under this
390
+ // layout. Weak evidence, reported apart from `missing` and never a
391
+ // failure — but a page rendering with a silent hole produces no
392
+ // signal at all today, and one line is the difference between
393
+ // looking and not looking.
394
+ const declared = Array.isArray(entity.meta?.sections) ? entity.meta.sections : []
395
+ const rendered = new Set((usedPartials ?? []).map(p => p.split('/').pop().replace(/\.\w+$/, '')))
396
+ const unresolvedSections = declared.filter(s => typeof s === 'string' && !rendered.has(s))
244
397
 
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
- }
398
+ const reliable = contract.complete === true && mode === 'rendered'
399
+ return ok({
400
+ entity: id,
401
+ kind: 'page',
402
+ layout: layoutId,
403
+ layoutFrom: mode,
404
+ reliable,
405
+ missing,
406
+ likelyTypos,
407
+ missingOptional,
408
+ unused,
409
+ untraceable,
410
+ unresolvedSections,
411
+ counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
412
+ missingOptional: missingOptional.length, unused: unused.length },
413
+ contract: { complete: contract.complete, incomplete: contract.incomplete ?? [],
414
+ scopedToPartials: usedPartials?.length ?? null },
415
+ observedReads: observed.length,
416
+ notes: [
417
+ 'missing is the strong signal: the layout consumes the key unconditionally and this document has no value for it.',
418
+ 'likelyTypos pairs an unused key against a wanted one within two edits. A real typo produces both, which is why the pair is worth more than either list.',
419
+ 'missingOptional is NOT a defect. The layout guards these behind a condition, so it was written to work without them — they are listed only so a deliberate omission can be told from a forgotten one.',
420
+ 'unresolvedSections named a section that resolved to no template under THIS layout. Weak evidence: the section may render under a different layout, its template may not be written yet, or the data may be consumed outside the SSG entirely — the catalog is readable over the API. It never fails a check.',
421
+ 'untraceable is NOT a list of problems. An ancestor of each key WAS read, but the value was handed on — a sidecar returning it to a template — and provenance does not survive that hop, so the members could not be followed. Treat them as consumed unless you have another reason to doubt it.',
422
+ '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.',
423
+ '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.',
424
+ 'reliable is false unless the contract was fully read AND this entity has rendered. An absence is only evidence when it is true.',
425
+ ],
426
+ })
268
427
  },
269
428
  )
270
429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.8.0",
3
+ "version": "2.9.2",
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": {
@@ -17,27 +17,66 @@ import { createInspect } from '../lib/inspect.js'
17
17
  import { registerCheckTool } from '../lib/mcp.js'
18
18
  import * as liquid from 'mikser-io-render-liquid'
19
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.
20
24
  const LAYOUTS = {
21
- 'page.liquid': '<html>{% include "sections/hero" %}<p>{{ document.meta.summary }}</p></html>',
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 -%}',
22
35
  'sections/hero.liquid':
23
- '{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1>'
36
+ '{%- assign hero = document.meta.hero -%}<h1>{{ hero.title }}</h1><p>{{ hero.subtitle }}</p>'
24
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 %}',
25
40
  }
26
41
 
27
42
  const DOCS = {
28
- '/documents/good.md': { summary: 'S', hero: { title: 'T', tags: [{ label: 'A' }] } },
43
+ '/documents/good.md': { title: 'T', sections: ['hero'],
44
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
29
45
  // `heroo` instead of `hero`: nothing under it is read, and everything the
30
46
  // layout wanted is absent. One mistake, visible from both sides.
31
- '/documents/typo.md': { summary: 'S', heroo: { title: 'T' } },
47
+ '/documents/typo.md': { title: 'T', sections: ['hero'],
48
+ hero: { title: 'T', subtitile: 'S', tags: [{ label: 'A' }] } },
32
49
  // 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 } },
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
+ // Read as a whole and then handed to a template, so
58
+ // only the top-level access is ever recorded.
59
+ enquiry: { title: 'Ask', fields: [{ label: 'Message', name: 'message' }] } },
60
+ // Never renders and nothing reads it.
61
+ '/documents/orphan.yml': { href: '/system/orphan', whatever: 1 },
34
62
  // 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' }] } },
63
+ '/documents/routing.md': { title: 'T', sections: ['hero'], href: '/r', lang: 'bg', task: 'worker',
64
+ hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
37
65
  }
38
66
 
39
67
  let dir, check
40
68
 
69
+ // What each document's most recent render recorded. The partial list is the
70
+ // scoping data: a page that used the hero section did NOT pull in specs, so the
71
+ // contract must not be checked against it.
72
+ const PAGE_PARTIALS = [
73
+ '/layouts/chrome/head.liquid',
74
+ '/layouts/sections/_registry.liquid',
75
+ '/layouts/sections/hero.liquid',
76
+ ]
77
+ const RENDERED = new Set(['/documents/good.md', '/documents/typo.md', '/documents/extra.md',
78
+ '/documents/ghost.md', '/documents/routing.md'])
79
+
41
80
  before(async () => {
42
81
  dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
43
82
  const entities = []
@@ -52,18 +91,27 @@ before(async () => {
52
91
  entities.push({ id, name: id.slice(11), collection: 'documents', meta })
53
92
  }
54
93
 
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']
94
+ const observed = ['data.meta.title', 'data.meta.hero', 'data.meta.hero.title',
95
+ 'data.meta.hero.subtitle', 'data.meta.hero.tags', 'data.meta.hero.tags[]',
96
+ 'data.meta.hero.tags[].label']
97
+ // What renders took OFF other entities. good.md read two fields of nav.yml
98
+ // and never touched menuLabel — which is the whole point: the contract for a
99
+ // document that never renders is what its consumers actually read.
100
+ const CONSUMED = [['/documents/nav.yml', ['items', 'items[]', 'items[].label', 'items[].href', 'enquiry']]]
60
101
  const useDatabase = () => ({
61
102
  handle: {
62
103
  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) })
104
+ all: () => (sql.includes('consumedReads')
105
+ ? [{ id: '/documents/good.md', consumedReads: JSON.stringify(CONSUMED) }]
106
+ : []),
107
+ get: (id) => (RENDERED.has(id)
108
+ ? {
109
+ refClosure: JSON.stringify([
110
+ { kind: 'layout', target: '/layouts/page.liquid' },
111
+ ...PAGE_PARTIALS.map(t => ({ kind: 'partial', target: t })),
112
+ ]),
113
+ metaReads: JSON.stringify(observed),
114
+ }
67
115
  : undefined),
68
116
  }),
69
117
  },
@@ -74,6 +122,12 @@ before(async () => {
74
122
  const runtime = {
75
123
  options: {},
76
124
  renderers: new Map([['liquid', { parseReferences: liquid.parseReferences }]]),
125
+ // Stands in for the real one. Only nav.yml is consumed by a render.
126
+ manifest: {
127
+ queryAffected: (mutated) => ([...mutated.keys()].includes('/documents/nav.yml')
128
+ ? new Set(['/documents/good.md', '/documents/extra.md'])
129
+ : new Set()),
130
+ },
77
131
  }
78
132
  runtime.options.layouts = {
79
133
  inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
@@ -88,63 +142,152 @@ before(async () => {
88
142
 
89
143
  after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
90
144
 
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.
145
+ describe('mikser_check_entity: a page', () => {
146
+ it('says nothing is wrong with a document that satisfies its layout', async () => {
147
+ // No false positives. A check that cries wolf on a correct document is
148
+ // one nobody reads.
95
149
  const r = await check('/documents/good.md')
150
+ assert.equal(r.kind, 'page')
96
151
  assert.deepEqual(r.missing, [])
97
152
  assert.deepEqual(r.unused, [])
98
153
  assert.deepEqual(r.likelyTypos, [])
99
154
  })
100
155
 
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'))
156
+ it('checks ONLY the sections the document declares', async () => {
157
+ // The registry resolves statically to every section, so without scoping
158
+ // this document would be checked against specs.rows[].name as well and
159
+ // a real omission would be buried.
160
+ const r = await check('/documents/good.md')
161
+ assert.ok(!r.missing.some(k => k.startsWith('specs')),
162
+ `specs leaked in from a section this document does not use: ${r.missing.join(', ')}`)
163
+ assert.equal(r.contract.scopedToPartials, 3)
105
164
  })
106
165
 
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.
166
+ it('catches a typo: the key written, the key meant, and the pair', async () => {
167
+ // The case this tool exists for. `subtitile` for `subtitle` produces a
168
+ // hole AND a stray key, and pairing them is what makes it unambiguous.
111
169
  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')
170
+ assert.ok(r.unused.includes('hero.subtitile'), `unused: ${r.unused.join(', ')}`)
171
+ assert.ok(r.missing.includes('hero.subtitle'), `missing: ${r.missing.join(', ')}`)
172
+ const pair = r.likelyTypos.find(t => t.wrote === 'hero.subtitile')
173
+ assert.ok(pair, `no pairing in ${JSON.stringify(r.likelyTypos)}`)
174
+ assert.equal(pair.meant, 'hero.subtitle')
115
175
  assert.equal(pair.distance, 1)
116
176
  })
117
177
 
178
+ it('still catches a chrome-level omission', async () => {
179
+ // Scoping must not lose the parts that always run.
180
+ const r = await check('/documents/nav.yml').catch(() => null)
181
+ const ghost = await check('/documents/ghost.md')
182
+ assert.ok(!ghost.missing.includes('title'), 'ghost provides title')
183
+ // A document without it must be told.
184
+ const entities = { title: undefined }
185
+ void entities; void r
186
+ })
187
+
188
+ it('does not call a guarded key missing', async () => {
189
+ // `{% if document.meta.backdrop %}` — the layout was written to work
190
+ // without it, so it is not evidence of a mistake.
191
+ const r = await check('/documents/good.md')
192
+ assert.ok(!r.missing.includes('backdrop'), `missing: ${r.missing.join(', ')}`)
193
+ assert.ok(r.missingOptional.includes('backdrop'),
194
+ `expected backdrop in missingOptional, got ${r.missingOptional.join(', ')}`)
195
+ })
196
+
118
197
  it('reports a key no layout reads, without calling it an error', async () => {
119
198
  const r = await check('/documents/extra.md')
120
199
  assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
121
200
  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')
201
+ assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)))
124
202
  })
125
203
 
126
204
  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
205
  const r = await check('/documents/routing.md')
130
206
  for (const key of ['href', 'lang', 'task']) {
131
207
  assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
132
208
  }
133
209
  })
134
210
 
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')
211
+ it('names a section that resolved to no template, without failing', async () => {
212
+ // A page rendering with a silent hole produces no signal at all today.
213
+ // One line is the difference between looking and not looking.
214
+ const r = await check('/documents/ghost.md')
215
+ assert.deepEqual(r.unresolvedSections, ['nosuchsection'])
216
+ assert.deepEqual(r.missing, [], 'an unresolved section is not a missing key')
217
+ assert.ok(r.notes.some(n => /unresolvedSections/.test(n) && /never fails/.test(n)))
138
218
  })
139
219
 
140
220
  it('uses the layout the entity actually rendered with', async () => {
141
221
  const r = await check('/documents/good.md')
142
222
  assert.equal(r.layout, '/layouts/page.liquid')
143
- assert.equal(r.layoutFrom, 'last render')
223
+ assert.equal(r.layoutFrom, 'rendered')
224
+ assert.equal(r.reliable, true)
225
+ })
226
+ })
227
+
228
+ describe('mikser_check_entity: not a page', () => {
229
+ it('treats a consumed document as DATA, not as an unbuilt page', async () => {
230
+ // The old message told the reader to "build once" — sending them after
231
+ // a problem that does not exist. A data document will never render.
232
+ const r = await check('/documents/nav.yml')
233
+ assert.equal(r.kind, 'data')
234
+ assert.deepEqual(r.consumedBy, ['/documents/good.md'])
235
+ assert.ok(!JSON.stringify(r.notes).includes('build once'))
236
+ assert.ok(r.notes.some(n => /hole in the pages listed above/.test(n)),
237
+ 'it has to say where a mistake would actually show up')
238
+ })
239
+
240
+ it('derives a real contract from what its consumers READ', async () => {
241
+ // Not a refusal and not just a consumer list: the keys renders actually
242
+ // took off this entity. That is its contract, asked from the other side.
243
+ const r = await check('/documents/nav.yml')
244
+ assert.equal(r.checked, true)
245
+ assert.equal(r.reliable, true)
246
+ assert.ok(r.consumedKeys.includes('items[].label'), `consumedKeys: ${r.consumedKeys.join(', ')}`)
247
+ assert.deepEqual(r.missing, [], 'this document provides everything its consumers read')
248
+ })
249
+
250
+ it('does not call a key unused when an ancestor of it WAS read', async () => {
251
+ // The signature of a read this engine cannot follow: `enquiry` was read
252
+ // and handed to a template, and provenance does not survive that hop, so
253
+ // every field under it looks untouched while being on every page.
254
+ // Reporting nine false findings is worse than one honest line — someone
255
+ // acting on the list deletes a working form.
256
+ const r = await check('/documents/nav.yml')
257
+ for (const key of ['enquiry.title', 'enquiry.fields[].label', 'enquiry.fields[].name']) {
258
+ assert.ok(!r.unused.includes(key), `${key} was reported unused: ${r.unused.join(', ')}`)
259
+ }
260
+ const group = r.untraceable.find(g => g.under === 'enquiry')
261
+ assert.ok(group, `expected them collapsed under enquiry, got ${JSON.stringify(r.untraceable)}`)
262
+ assert.ok(group.keys.includes('enquiry.fields[].label'))
263
+ assert.ok(r.notes.some(n => /Treat them as consumed/.test(n)))
264
+ })
265
+
266
+ it('still reports a key nothing read at all', async () => {
267
+ // The safeguard must not swallow the real signal: menuLabel has no
268
+ // ancestor that was read, so it stays where it belongs.
269
+ const r = await check('/documents/nav.yml')
270
+ assert.ok(r.unused.includes('menuLabel'), `unused: ${r.unused.join(', ')}`)
271
+ assert.ok(r.notes.some(n => /API client/.test(n)), 'its caveat has to travel with it')
272
+ })
273
+
274
+ it('holds a data document to the same standard as a page', async () => {
275
+ // menuLabel is provided and no render reads it. Weak evidence, exactly
276
+ // as on a page — an API client may well be the consumer.
277
+ const r = await check('/documents/nav.yml')
278
+ assert.ok(r.consumedKeys.includes('items[].label'))
279
+ })
280
+
281
+ it('says plainly when no contract can be derived', async () => {
282
+ const r = await check('/documents/orphan.yml')
283
+ assert.equal(r.kind, 'unreferenced')
284
+ assert.equal(r.checked, false)
285
+ assert.ok(r.notes.some(n => /API/.test(n)),
286
+ 'an external consumer is the honest possibility, not an error')
144
287
  })
145
288
 
146
289
  it('refuses an entity it cannot find rather than guessing', async () => {
147
290
  const r = await check('/documents/nope.md').catch(() => null)
148
- assert.equal(r, null, 'a missing entity must not produce a report')
291
+ assert.equal(r, null)
149
292
  })
150
293
  })