mikser-io-layouts 2.8.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/lib/assembly.js +23 -4
- package/lib/inspect.js +41 -2
- package/lib/mcp.js +209 -99
- package/package.json +2 -2
- package/test/check-entity.test.js +157 -40
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')
|
|
@@ -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,123 @@ 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
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 [] }
|
|
159
224
|
}
|
|
160
225
|
|
|
161
226
|
mcp.simpleTool(
|
|
162
227
|
'mikser_check_entity',
|
|
163
228
|
'Check a document against the contract of the layout that renders it, BEFORE the mistake ships.\n\n'
|
|
164
229
|
+ '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
|
|
166
|
-
+ '
|
|
167
|
-
+ '
|
|
168
|
-
+ '
|
|
169
|
-
+ '
|
|
170
|
-
+ '
|
|
171
|
-
+ '
|
|
172
|
-
+ '
|
|
173
|
-
+ '
|
|
174
|
-
+ '
|
|
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.',
|
|
175
249
|
{
|
|
176
250
|
id: z.string().describe('Entity id, e.g. "/documents/en/index.md".'),
|
|
177
251
|
layout: z.string().optional()
|
|
@@ -185,86 +259,122 @@ export function registerCheckTool({ runtime, findEntity, findEntities, useDataba
|
|
|
185
259
|
`No entity ${JSON.stringify(id)}. Use mikser_query_entities to find its id.` }] }
|
|
186
260
|
}
|
|
187
261
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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)
|
|
193
273
|
if (!layoutId && entity.meta?.layout) {
|
|
194
274
|
const named = (await findEntities({ name: entity.meta.layout })) ?? []
|
|
195
275
|
layoutId = named.find(e => e.collection === collection)?.id ?? null
|
|
196
|
-
|
|
276
|
+
mode = layoutId ? 'declared' : null
|
|
197
277
|
}
|
|
278
|
+
|
|
198
279
|
if (!layoutId) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
})
|
|
202
321
|
}
|
|
203
322
|
|
|
204
|
-
|
|
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
|
+
})
|
|
205
332
|
const contract = inspected.references?.contract ?? { meta: [], complete: false, incomplete: [] }
|
|
333
|
+
const optional = new Set(contract.optionalMeta ?? [])
|
|
206
334
|
|
|
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
335
|
const consumed = new Set([...(contract.meta ?? []), ...observed])
|
|
214
|
-
|
|
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)
|
|
215
341
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|| [...provided].some(p => p.startsWith(`${key}.`) || p.startsWith(`${key}[`))
|
|
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))
|
|
225
350
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
layout
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
}
|
|
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
|
+
})
|
|
268
378
|
},
|
|
269
379
|
)
|
|
270
380
|
|
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": {
|
|
@@ -17,27 +17,63 @@ 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':
|
|
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': {
|
|
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': {
|
|
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': {
|
|
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 },
|
|
34
59
|
// Engine-owned keys must never be reported as unused.
|
|
35
|
-
'/documents/routing.md': {
|
|
36
|
-
hero: { title: 'T', tags: [{ label: 'A' }] } },
|
|
60
|
+
'/documents/routing.md': { title: 'T', sections: ['hero'], href: '/r', lang: 'bg', task: 'worker',
|
|
61
|
+
hero: { title: 'T', subtitle: 'S', tags: [{ label: 'A' }] } },
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
let dir, check
|
|
40
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
|
+
|
|
41
77
|
before(async () => {
|
|
42
78
|
dir = await mkdtemp(path.join(tmpdir(), 'mikser-check-'))
|
|
43
79
|
const entities = []
|
|
@@ -52,18 +88,27 @@ before(async () => {
|
|
|
52
88
|
entities.push({ id, name: id.slice(11), collection: 'documents', meta })
|
|
53
89
|
}
|
|
54
90
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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']]]
|
|
60
98
|
const useDatabase = () => ({
|
|
61
99
|
handle: {
|
|
62
100
|
prepare: (sql) => ({
|
|
63
|
-
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
112
|
: undefined),
|
|
68
113
|
}),
|
|
69
114
|
},
|
|
@@ -74,6 +119,12 @@ before(async () => {
|
|
|
74
119
|
const runtime = {
|
|
75
120
|
options: {},
|
|
76
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
|
+
},
|
|
77
128
|
}
|
|
78
129
|
runtime.options.layouts = {
|
|
79
130
|
inspect: createInspect({ runtime, findEntity, findEntities, useDatabase, collection: 'layouts' }),
|
|
@@ -88,63 +139,129 @@ before(async () => {
|
|
|
88
139
|
|
|
89
140
|
after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
|
90
141
|
|
|
91
|
-
describe('mikser_check_entity', () => {
|
|
92
|
-
it('says nothing is wrong with a document that satisfies
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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.
|
|
95
146
|
const r = await check('/documents/good.md')
|
|
147
|
+
assert.equal(r.kind, 'page')
|
|
96
148
|
assert.deepEqual(r.missing, [])
|
|
97
149
|
assert.deepEqual(r.unused, [])
|
|
98
150
|
assert.deepEqual(r.likelyTypos, [])
|
|
99
151
|
})
|
|
100
152
|
|
|
101
|
-
it('
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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)
|
|
105
161
|
})
|
|
106
162
|
|
|
107
|
-
it('
|
|
108
|
-
// The
|
|
109
|
-
//
|
|
110
|
-
// strong one.
|
|
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.
|
|
111
166
|
const r = await check('/documents/typo.md')
|
|
112
|
-
|
|
113
|
-
assert.ok(
|
|
114
|
-
|
|
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')
|
|
115
172
|
assert.equal(pair.distance, 1)
|
|
116
173
|
})
|
|
117
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
|
+
|
|
118
194
|
it('reports a key no layout reads, without calling it an error', async () => {
|
|
119
195
|
const r = await check('/documents/extra.md')
|
|
120
196
|
assert.ok(r.unused.includes('card'), `unused: ${r.unused.join(', ')}`)
|
|
121
197
|
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')
|
|
198
|
+
assert.ok(r.notes.some(n => /DIFFERENT layout/.test(n)))
|
|
124
199
|
})
|
|
125
200
|
|
|
126
201
|
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
202
|
const r = await check('/documents/routing.md')
|
|
130
203
|
for (const key of ['href', 'lang', 'task']) {
|
|
131
204
|
assert.ok(!r.unused.includes(key), `${key} leaked into unused: ${r.unused.join(', ')}`)
|
|
132
205
|
}
|
|
133
206
|
})
|
|
134
207
|
|
|
135
|
-
it('
|
|
136
|
-
|
|
137
|
-
|
|
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)))
|
|
138
215
|
})
|
|
139
216
|
|
|
140
217
|
it('uses the layout the entity actually rendered with', async () => {
|
|
141
218
|
const r = await check('/documents/good.md')
|
|
142
219
|
assert.equal(r.layout, '/layouts/page.liquid')
|
|
143
|
-
assert.equal(r.layoutFrom, '
|
|
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')
|
|
144
261
|
})
|
|
145
262
|
|
|
146
263
|
it('refuses an entity it cannot find rather than guessing', async () => {
|
|
147
264
|
const r = await check('/documents/nope.md').catch(() => null)
|
|
148
|
-
assert.equal(r, null
|
|
265
|
+
assert.equal(r, null)
|
|
149
266
|
})
|
|
150
267
|
})
|