mikser-io-layouts 2.9.1 → 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
@@ -398,6 +398,14 @@ export function createOnBeforeRender({
398
398
  postprocessors: entity.layout.postprocessors ?? (entity.layout.postprocessor ? [entity.layout.postprocessor] : []),
399
399
  tasks: entity.meta?.task || TASKS.INLINE,
400
400
  }, { data, plugins, sidecarQueries: sidecarTrack.queries,
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.
401
409
  sidecarMetaReads: [...(sidecarTrack.metaReads ?? [])],
402
410
  sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
403
411
  }
@@ -427,7 +435,15 @@ export function createOnBeforeRender({
427
435
  // deleted. The paginated branch above already
428
436
  // does this.
429
437
  { data, plugins, sidecarQueries: sidecarTrack.queries,
430
- 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 ?? [])],
431
447
  sidecarConsumedReads: [...(sidecarTrack.consumedReads ?? [])].map(([k, v]) => [k, [...v]]) })
432
448
  }
433
449
  }
package/lib/mcp.js CHANGED
@@ -193,10 +193,55 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
193
193
  const absent = [...consumedSet].filter(k => k && !isProvided(k))
194
194
  const missing = absent.filter(k => !isOptional(k)).sort()
195
195
  const missingOptional = absent.filter(isOptional).sort()
196
- const unused = [...provided]
196
+ const candidates = [...provided]
197
197
  .filter(k => !isUsed(k))
198
198
  .filter(k => !ENGINE_KEYS.has(k.split('.')[0].replace(/\[\]$/, '')))
199
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))
200
245
  const likelyTypos = []
201
246
  for (const wrote of unused) {
202
247
  let best = null
@@ -206,7 +251,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
206
251
  }
207
252
  if (best) likelyTypos.push({ wrote, ...best })
208
253
  }
209
- return { provided, missing, missingOptional, unused, likelyTypos }
254
+ return { provided, missing, missingOptional, unused, untraceable, likelyTypos }
210
255
  }
211
256
 
212
257
 
@@ -296,6 +341,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
296
341
  missing: cmp.missing,
297
342
  likelyTypos: cmp.likelyTypos,
298
343
  unused: cmp.unused,
344
+ untraceable: cmp.untraceable,
299
345
  consumedKeys: read.keys,
300
346
  counts: { consumedKeys: read.keys.length, provided: cmp.provided.size,
301
347
  missing: cmp.missing.length, unused: cmp.unused.length },
@@ -304,6 +350,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
304
350
  read.keys.length
305
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.'
306
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.',
307
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.',
308
355
  ],
309
356
  })
@@ -336,7 +383,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
336
383
  // `missing` is the ONLY list that should read as "this is probably
337
384
  // wrong". A key the layout guards with `{% if %}` is one the layout
338
385
  // was written to work without, so it belongs in the softer list.
339
- const { provided, missing, missingOptional, unused, likelyTypos } =
386
+ const { provided, missing, missingOptional, unused, untraceable, likelyTypos } =
340
387
  compare(entity, consumed, optional)
341
388
 
342
389
  // A declared section that resolved to no template under this
@@ -359,6 +406,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
359
406
  likelyTypos,
360
407
  missingOptional,
361
408
  unused,
409
+ untraceable,
362
410
  unresolvedSections,
363
411
  counts: { consumed: consumed.size, provided: provided.size, missing: missing.length,
364
412
  missingOptional: missingOptional.length, unused: unused.length },
@@ -370,6 +418,7 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
370
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.',
371
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.',
372
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.',
373
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.',
374
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.',
375
424
  'reliable is false unless the contract was fully read AND this entity has rendered. An absence is only evidence when it is true.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-layouts",
3
- "version": "2.9.1",
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",
@@ -53,7 +53,10 @@ const DOCS = {
53
53
  '/documents/ghost.md': { title: 'T', sections: ['hero', 'nosuchsection'],
54
54
  hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
55
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' },
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' }] } },
57
60
  // Never renders and nothing reads it.
58
61
  '/documents/orphan.yml': { href: '/system/orphan', whatever: 1 },
59
62
  // Engine-owned keys must never be reported as unused.
@@ -94,7 +97,7 @@ before(async () => {
94
97
  // What renders took OFF other entities. good.md read two fields of nav.yml
95
98
  // and never touched menuLabel — which is the whole point: the contract for a
96
99
  // document that never renders is what its consumers actually read.
97
- const CONSUMED = [['/documents/nav.yml', ['items', 'items[]', 'items[].label', 'items[].href']]]
100
+ const CONSUMED = [['/documents/nav.yml', ['items', 'items[]', 'items[].label', 'items[].href', 'enquiry']]]
98
101
  const useDatabase = () => ({
99
102
  handle: {
100
103
  prepare: (sql) => ({
@@ -244,12 +247,35 @@ describe('mikser_check_entity: not a page', () => {
244
247
  assert.deepEqual(r.missing, [], 'this document provides everything its consumers read')
245
248
  })
246
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
+
247
274
  it('holds a data document to the same standard as a page', async () => {
248
275
  // menuLabel is provided and no render reads it. Weak evidence, exactly
249
276
  // as on a page — an API client may well be the consumer.
250
277
  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)))
278
+ assert.ok(r.consumedKeys.includes('items[].label'))
253
279
  })
254
280
 
255
281
  it('says plainly when no contract can be derived', async () => {