mikser-io 9.20.0 → 9.21.0
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/docs/diagnostics.md +44 -4
- package/package.json +1 -1
- package/src/engine.js +1 -1
- package/src/explain.js +33 -5
- package/src/manifest.js +38 -7
- package/src/report.js +9 -2
- package/src/utils.js +68 -4
package/docs/diagnostics.md
CHANGED
|
@@ -35,10 +35,29 @@ npx mikser --explain /documents/en/posts/hello.md
|
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
It prints the entity's layout and **why that layout matched**, its
|
|
38
|
-
destination, its `inputHash
|
|
39
|
-
the
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
destination, its `inputHash` and the components that went into it,
|
|
39
|
+
whether the file on disk still agrees with the catalog, every recorded
|
|
40
|
+
render with its `refClosure`, and a verdict in plain words.
|
|
41
|
+
|
|
42
|
+
When an entity's inputs have moved since a render, the verdict names what
|
|
43
|
+
moved rather than only that something did:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
would re-render — meta.title changed since it was last rendered
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
and the render line carries the same detail per snapshot:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved since]
|
|
53
|
+
moved content, meta.weight (added)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A snapshot written before per-input recording says so rather than
|
|
57
|
+
guessing. Note that `--explain` compares the CATALOG's entity against the
|
|
58
|
+
snapshot: if you have edited a file and not yet built, the verdict is
|
|
59
|
+
`source differs from the catalog` — the edit has not been imported yet, so
|
|
60
|
+
there is nothing to attribute. Build, then ask.
|
|
42
61
|
|
|
43
62
|
Each `refClosure` edge shows the name that was asked for and the entity
|
|
44
63
|
it bound to, and flags the ones that bound to nothing:
|
|
@@ -77,6 +96,27 @@ Four buckets, and the distinction between them is the point:
|
|
|
77
96
|
`never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
|
|
78
97
|
`cache-disabled`, `postprocessor`, `force`, `no-manifest`.
|
|
79
98
|
|
|
99
|
+
`inputs-changed` carries a `changed` array naming **which** input moved,
|
|
100
|
+
so you do not have to go to the database to find out:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "id": "/files/hero.jpg", "reason": "inputs-changed", "changed": ["checksum"] }
|
|
104
|
+
{ "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["meta.title"] }
|
|
105
|
+
{ "id": "/documents/page.md", "reason": "inputs-changed", "changed": ["content"] }
|
|
106
|
+
{ "id": "/layouts/post.hbs", "reason": "inputs-changed", "changed": ["inputs.shared"] }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`checksum` means the bytes on disk moved; `content` means the body did;
|
|
110
|
+
`meta.<field>` names the front-matter field; `inputs.<key>` is a declared
|
|
111
|
+
input such as a layout's sidecar digest. A field that appeared or vanished
|
|
112
|
+
reads as `meta.weight (added)` / `(removed)`, which is the answer when a
|
|
113
|
+
document gains or loses front-matter.
|
|
114
|
+
|
|
115
|
+
The array is absent on a first render — there is no prior snapshot to
|
|
116
|
+
compare against — and on `ref-changed`, because that is a dependency
|
|
117
|
+
moving rather than the entity's own inputs. Conflating the two would make
|
|
118
|
+
the attribution misleading.
|
|
119
|
+
|
|
80
120
|
`unchanged` is the interesting one. It means invalidation was coarser
|
|
81
121
|
than it needed to be — the render was scheduled, ran, and produced
|
|
82
122
|
nothing new. A high count is not a bug, but it tells you where the
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -374,7 +374,7 @@ export async function setup(options) {
|
|
|
374
374
|
logger.debug('Manifest skip: %s → %s', entity.name || entity.id, entity.destination)
|
|
375
375
|
return
|
|
376
376
|
}
|
|
377
|
-
reportRendered(entity, decision.reason)
|
|
377
|
+
reportRendered(entity, decision.reason, decision.changed)
|
|
378
378
|
// Project reference-marker keys (`$author`, `$hero`, …)
|
|
379
379
|
// into their normalized form (`author`, `hero`) before
|
|
380
380
|
// the entity crosses into the renderer — applies whether
|
package/src/explain.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// needs knowledge a user of the tool should not need.
|
|
9
9
|
//
|
|
10
10
|
// Follows --verify's shape: report and exit, no build phases run.
|
|
11
|
-
import { inputHashOf, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
11
|
+
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
|
|
12
12
|
import { findEntity } from './catalog.js'
|
|
13
13
|
import runtime from './runtime.js'
|
|
14
14
|
|
|
@@ -28,6 +28,22 @@ async function resolve(reference) {
|
|
|
28
28
|
return like ?? null
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The verdict line names what moved when it can. That line is the one
|
|
32
|
+
// people read, so "the input hash differs" there is the answer stopping one
|
|
33
|
+
// step short of useful.
|
|
34
|
+
function renderVerdict(snapshots, currentHash, currentParts) {
|
|
35
|
+
const moved = new Set()
|
|
36
|
+
for (const snap of snapshots) {
|
|
37
|
+
if (snap.inputHash === currentHash || !snap.inputParts) continue
|
|
38
|
+
const d = diffInputParts(snap.inputParts, currentParts)
|
|
39
|
+
for (const key of [...d.changed, ...d.added, ...d.removed]) moved.add(key)
|
|
40
|
+
}
|
|
41
|
+
if (!moved.size) {
|
|
42
|
+
return 'would re-render — the entity\'s input hash differs from what it was last rendered at'
|
|
43
|
+
}
|
|
44
|
+
return `would re-render — ${[...moved].join(', ')} changed since it was last rendered`
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
export async function explain(reference) {
|
|
32
48
|
const entity = await resolve(reference)
|
|
33
49
|
if (!entity) {
|
|
@@ -43,6 +59,7 @@ export async function explain(reference) {
|
|
|
43
59
|
|
|
44
60
|
const snapshots = runtime.manifest?.snapshotsFor(entity.id) ?? []
|
|
45
61
|
const currentHash = inputHashOf(entity)
|
|
62
|
+
const currentParts = inputPartsOf(entity)
|
|
46
63
|
|
|
47
64
|
// The catalog is as of the LAST BUILD. If the file has been edited since,
|
|
48
65
|
// nothing here knows it yet — the hashes would all agree and the verdict
|
|
@@ -96,9 +113,11 @@ export async function explain(reference) {
|
|
|
96
113
|
// current hash; each snapshot carries the hash it was rendered at, so
|
|
97
114
|
// the two disagreeing IS the answer to "why did this change".
|
|
98
115
|
inputHash: currentHash,
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
116
|
+
// The components that actually went into the hash for THIS entity,
|
|
117
|
+
// read off the parts rather than restated — a hardcoded label drifts
|
|
118
|
+
// the moment the payload changes, and this one had.
|
|
119
|
+
inputHashOf: [...new Set(Object.keys(currentParts).map(k => k.split('.')[0]))].join('+')
|
|
120
|
+
|| 'nothing',
|
|
102
121
|
inputs: entity.inputs ?? null,
|
|
103
122
|
checksum: entity.checksum ?? null,
|
|
104
123
|
source,
|
|
@@ -109,6 +128,15 @@ export async function explain(reference) {
|
|
|
109
128
|
// The single most useful field: does this entity's current hash
|
|
110
129
|
// match what it was last rendered at?
|
|
111
130
|
stale: snap.inputHash !== currentHash,
|
|
131
|
+
// WHICH input moved, not merely that one did. This is the whole
|
|
132
|
+
// question behind "why did this re-render" — answering it from
|
|
133
|
+
// the recorded parts costs nothing, and not answering it sends
|
|
134
|
+
// the reader to a database query for something already here.
|
|
135
|
+
moved: snap.inputHash === currentHash
|
|
136
|
+
? null
|
|
137
|
+
: snap.inputParts
|
|
138
|
+
? diffInputParts(snap.inputParts, currentParts)
|
|
139
|
+
: 'unknown',
|
|
112
140
|
outputHash: snap.outputHash ?? null,
|
|
113
141
|
parent: snap.parent ?? null,
|
|
114
142
|
refClosure: (snap.refClosure ?? []).map(entry =>
|
|
@@ -136,7 +164,7 @@ export async function explain(reference) {
|
|
|
136
164
|
: snapshots.length === 0
|
|
137
165
|
? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
|
|
138
166
|
: snapshots.some(s => s.inputHash !== currentHash)
|
|
139
|
-
?
|
|
167
|
+
? renderVerdict(snapshots, currentHash, currentParts)
|
|
140
168
|
: 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
|
|
141
169
|
lookupKeys: lookupKeys(entity),
|
|
142
170
|
}
|
package/src/manifest.js
CHANGED
|
@@ -56,7 +56,7 @@ import { useLogger } from './engine.js'
|
|
|
56
56
|
import { onLoaded, onFinalize } from './lifecycle.js'
|
|
57
57
|
import { useJournal } from './journal.js'
|
|
58
58
|
import { OPERATION } from './constants.js'
|
|
59
|
-
import { extractRefs, inputHashOf } from './utils.js'
|
|
59
|
+
import { extractRefs, inputHashOf, inputPartsOf, diffInputParts } from './utils.js'
|
|
60
60
|
import { filterKey } from './track.js'
|
|
61
61
|
import { findById } from './catalog.js'
|
|
62
62
|
import { useDatabase, registerSchema } from './database/index.js'
|
|
@@ -74,6 +74,7 @@ export const SNAPSHOTS_SCHEMA = `
|
|
|
74
74
|
id TEXT NOT NULL,
|
|
75
75
|
destination TEXT NOT NULL,
|
|
76
76
|
inputHash TEXT,
|
|
77
|
+
inputParts TEXT,
|
|
77
78
|
outputHash TEXT,
|
|
78
79
|
refClosure TEXT,
|
|
79
80
|
renderedAt INTEGER,
|
|
@@ -97,6 +98,20 @@ function sha1(payload) {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// refClosure builder — same logic as before, no DB involvement.
|
|
101
|
+
// Which input moved, as a flat list of part names ('content',
|
|
102
|
+
// 'meta.title', 'checksum', 'inputs.shared'). Empty when the snapshot
|
|
103
|
+
// predates part recording — the combined hash still says the entity
|
|
104
|
+
// changed, and saying nothing is better than guessing which part.
|
|
105
|
+
function describeInputChange(entity, snapshot) {
|
|
106
|
+
if (!snapshot?.inputParts) return []
|
|
107
|
+
const { changed, added, removed } = diffInputParts(snapshot.inputParts, inputPartsOf(entity))
|
|
108
|
+
return [
|
|
109
|
+
...changed,
|
|
110
|
+
...added.map(key => `${key} (added)`),
|
|
111
|
+
...removed.map(key => `${key} (removed)`),
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
function buildRefClosure(entity, deps) {
|
|
101
116
|
const closure = []
|
|
102
117
|
const seen = new Set()
|
|
@@ -155,6 +170,11 @@ function buildSnapshot(entity, deps, outputHash) {
|
|
|
155
170
|
id: entity.id,
|
|
156
171
|
destination: entity.destination,
|
|
157
172
|
inputHash: inputHashOf(entity),
|
|
173
|
+
// Per-component hashes of the same payload the combined hash covers,
|
|
174
|
+
// so a later run can name WHICH input moved instead of only that one
|
|
175
|
+
// did. Without it `inputs-changed` sends the reader to a database
|
|
176
|
+
// query for something the tool already has in hand.
|
|
177
|
+
inputParts: inputPartsOf(entity),
|
|
158
178
|
refClosure: buildRefClosure(entity, deps),
|
|
159
179
|
renderedAt: Date.now(),
|
|
160
180
|
}
|
|
@@ -171,6 +191,7 @@ function rowToSnap(row) {
|
|
|
171
191
|
id: row.id,
|
|
172
192
|
destination: row.destination,
|
|
173
193
|
inputHash: row.inputHash ?? undefined,
|
|
194
|
+
inputParts: row.inputParts ? JSON.parse(row.inputParts) : undefined,
|
|
174
195
|
outputHash: row.outputHash ?? undefined,
|
|
175
196
|
refClosure: row.refClosure ? JSON.parse(row.refClosure) : undefined,
|
|
176
197
|
renderedAt: row.renderedAt ?? undefined,
|
|
@@ -183,6 +204,7 @@ function snapToRow(snap) {
|
|
|
183
204
|
id: snap.id,
|
|
184
205
|
destination: snap.destination,
|
|
185
206
|
inputHash: snap.inputHash ?? null,
|
|
207
|
+
inputParts: snap.inputParts ? JSON.stringify(snap.inputParts) : null,
|
|
186
208
|
outputHash: snap.outputHash ?? null,
|
|
187
209
|
refClosure: snap.refClosure ? JSON.stringify(snap.refClosure) : null,
|
|
188
210
|
renderedAt: snap.renderedAt ?? null,
|
|
@@ -230,18 +252,18 @@ export function createManifest(db) {
|
|
|
230
252
|
if (!db) throw new Error('createManifest: db is required')
|
|
231
253
|
|
|
232
254
|
const stmtLookupById = db.prepare(`
|
|
233
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
255
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
234
256
|
FROM mikser_snapshots WHERE id = ? ORDER BY destination
|
|
235
257
|
`)
|
|
236
258
|
const stmtLookup = db.prepare(`
|
|
237
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
259
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
238
260
|
FROM mikser_snapshots WHERE id = ? AND destination = ?
|
|
239
261
|
`)
|
|
240
262
|
const stmtUpsert = db.prepare(`
|
|
241
263
|
INSERT OR REPLACE INTO mikser_snapshots
|
|
242
|
-
(id, destination, inputHash, outputHash, refClosure, renderedAt, parent)
|
|
264
|
+
(id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent)
|
|
243
265
|
VALUES
|
|
244
|
-
(@id, @destination, @inputHash, @outputHash, @refClosure, @renderedAt, @parent)
|
|
266
|
+
(@id, @destination, @inputHash, @inputParts, @outputHash, @refClosure, @renderedAt, @parent)
|
|
245
267
|
`)
|
|
246
268
|
const stmtDeleteByPK = db.prepare(`
|
|
247
269
|
DELETE FROM mikser_snapshots WHERE id = ? AND destination = ?
|
|
@@ -257,7 +279,7 @@ export function createManifest(db) {
|
|
|
257
279
|
SELECT id, destination FROM mikser_snapshots WHERE parent = ?
|
|
258
280
|
`)
|
|
259
281
|
const stmtSelectAll = db.prepare(`
|
|
260
|
-
SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
|
|
282
|
+
SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
|
|
261
283
|
FROM mikser_snapshots
|
|
262
284
|
`)
|
|
263
285
|
const stmtCount = db.prepare(`SELECT COUNT(*) AS c FROM mikser_snapshots`)
|
|
@@ -352,7 +374,16 @@ export function createManifest(db) {
|
|
|
352
374
|
if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
|
|
353
375
|
const snapshot = this.lookup(entity)
|
|
354
376
|
if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
|
|
355
|
-
if (inputHashOf(entity) !== snapshot.inputHash)
|
|
377
|
+
if (inputHashOf(entity) !== snapshot.inputHash) {
|
|
378
|
+
// Name the component that moved. "inputs-changed" alone is
|
|
379
|
+
// the answer to a question nobody asked — the reader wants to
|
|
380
|
+
// know WHICH input, and the recorded parts have it.
|
|
381
|
+
return {
|
|
382
|
+
skip: false,
|
|
383
|
+
reason: 'inputs-changed',
|
|
384
|
+
changed: describeInputChange(entity, snapshot),
|
|
385
|
+
}
|
|
386
|
+
}
|
|
356
387
|
if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
|
|
357
388
|
const sourceLang = entity?.meta?.lang ?? null
|
|
358
389
|
for (const entry of snapshot.refClosure) {
|
package/src/report.js
CHANGED
|
@@ -28,9 +28,16 @@ export function reportGated(count = 1) {
|
|
|
28
28
|
store().gated += count
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function reportRendered(entity, reason) {
|
|
31
|
+
export function reportRendered(entity, reason, changed) {
|
|
32
32
|
if (!runtime.options?.json) return
|
|
33
|
-
store().rendered.push({
|
|
33
|
+
store().rendered.push({
|
|
34
|
+
id: entity?.id,
|
|
35
|
+
destination: entity?.destination ?? null,
|
|
36
|
+
reason,
|
|
37
|
+
// Which input moved, when the reason is inputs-changed. Omitted
|
|
38
|
+
// rather than empty so a consumer can test for its presence.
|
|
39
|
+
...(changed?.length ? { changed } : {}),
|
|
40
|
+
})
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
// A render that RAN and produced bytes identical to what was already on
|
package/src/utils.js
CHANGED
|
@@ -15,9 +15,11 @@ import runtime from './runtime.js'
|
|
|
15
15
|
// seeding. Excludes volatile fields like stamp/time/uri so re-discovery
|
|
16
16
|
// on startup doesn't produce a different hash for an unchanged file.
|
|
17
17
|
// Pure: synchronous, no I/O, no engine state.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
// The payload both `inputHashOf` and `inputPartsOf` describe. One
|
|
19
|
+
// definition, because a hash and an attribution of that hash that disagree
|
|
20
|
+
// about what went into it is worse than having no attribution.
|
|
21
|
+
function inputPayload(entity) {
|
|
22
|
+
return {
|
|
21
23
|
meta: entity.meta ?? null,
|
|
22
24
|
content: entity.content ?? null,
|
|
23
25
|
// The bytes' fingerprint, for entities whose content is not in
|
|
@@ -46,7 +48,69 @@ export function inputHashOf(entity) {
|
|
|
46
48
|
// an entity that HAS content is hashed on {meta, content} and its
|
|
47
49
|
// checksum is ignored. This is the seam that was missing.
|
|
48
50
|
inputs: entity.inputs ?? null,
|
|
49
|
-
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function inputHashOf(entity) {
|
|
55
|
+
if (!entity) return ''
|
|
56
|
+
return crypto.createHash('sha1').update(JSON.stringify(inputPayload(entity))).digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Per-component hashes of the same payload, flat and one level deep:
|
|
60
|
+
//
|
|
61
|
+
// { 'meta.title': 'ab12cd34', content: '…', 'inputs.shared': '…' }
|
|
62
|
+
//
|
|
63
|
+
// Recorded alongside the combined hash so a later run can say WHICH input
|
|
64
|
+
// moved rather than only that one did. `inputs-changed` on its own sends
|
|
65
|
+
// the reader to a database query to answer something already known here.
|
|
66
|
+
//
|
|
67
|
+
// Components that are null are omitted rather than hashed, so a component
|
|
68
|
+
// appearing or disappearing reads as an added or removed key — which is
|
|
69
|
+
// the answer in its own right when a document gains front-matter or a
|
|
70
|
+
// layout gains a sidecar.
|
|
71
|
+
//
|
|
72
|
+
// Depth one: naming `meta.title` is the difference between a useful answer
|
|
73
|
+
// and "something under meta". Deeper nesting would grow the snapshot for
|
|
74
|
+
// diminishing returns — the key names the field to look at, and the field
|
|
75
|
+
// is then in front of you.
|
|
76
|
+
export function inputPartsOf(entity) {
|
|
77
|
+
if (!entity) return {}
|
|
78
|
+
const parts = {}
|
|
79
|
+
const short = (value) => crypto.createHash('sha1')
|
|
80
|
+
.update(JSON.stringify(value ?? null)).digest('hex').slice(0, 8)
|
|
81
|
+
const payload = inputPayload(entity)
|
|
82
|
+
for (const [component, value] of Object.entries(payload)) {
|
|
83
|
+
if (value == null) continue
|
|
84
|
+
if (component === 'meta' || component === 'inputs') {
|
|
85
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
86
|
+
parts[component] = short(value)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
90
|
+
parts[`${component}.${key}`] = short(inner)
|
|
91
|
+
}
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
parts[component] = short(value)
|
|
95
|
+
}
|
|
96
|
+
return parts
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What moved between two part maps. Returns the keys, split by how they
|
|
100
|
+
// differ, so a caller can say "content changed" or "meta.title added"
|
|
101
|
+
// without re-deriving the comparison.
|
|
102
|
+
export function diffInputParts(before, after) {
|
|
103
|
+
const from = before ?? {}
|
|
104
|
+
const to = after ?? {}
|
|
105
|
+
const changed = [], added = [], removed = []
|
|
106
|
+
for (const key of Object.keys(to)) {
|
|
107
|
+
if (!(key in from)) added.push(key)
|
|
108
|
+
else if (from[key] !== to[key]) changed.push(key)
|
|
109
|
+
}
|
|
110
|
+
for (const key of Object.keys(from)) {
|
|
111
|
+
if (!(key in to)) removed.push(key)
|
|
112
|
+
}
|
|
113
|
+
return { changed: changed.sort(), added: added.sort(), removed: removed.sort() }
|
|
50
114
|
}
|
|
51
115
|
|
|
52
116
|
// Canonical lookup variants for an entity — the same four forms the
|