mikser-io-layouts 2.9.1 → 2.9.3
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 +17 -1
- package/lib/inspect.js +9 -1
- package/lib/mcp.js +52 -3
- package/package.json +1 -1
- package/test/check-entity.test.js +30 -4
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
|
-
|
|
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/inspect.js
CHANGED
|
@@ -270,7 +270,15 @@ async function buildContract({ layout, templateSource, runtime, findEntities, co
|
|
|
270
270
|
// it is resolved against what is known so far.
|
|
271
271
|
const subs = { ...bindings }
|
|
272
272
|
for (const a of parsed.assigns ?? []) {
|
|
273
|
-
|
|
273
|
+
// A DERIVED value is not an alias. `{% assign rows = c.specs |
|
|
274
|
+
// split: '|' %}` turns a string into a list, so a read of `rows[0]`
|
|
275
|
+
// says nothing about `specs` having members — binding it would
|
|
276
|
+
// report `specs[]` as a required key of a document whose `specs` is
|
|
277
|
+
// the string being split, and no document can satisfy that.
|
|
278
|
+
//
|
|
279
|
+
// The source path is still recorded by the parser as consumed,
|
|
280
|
+
// which is the true dependency.
|
|
281
|
+
if (a?.key && a.from && !a.derived) subs[a.key] = resolve(a.from, subs)
|
|
274
282
|
}
|
|
275
283
|
|
|
276
284
|
const resolved = []
|
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
|
|
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.
|
|
3
|
+
"version": "2.9.3",
|
|
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.
|
|
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 () => {
|