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.
@@ -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`, whether the file on disk still agrees with
39
- the catalog, every recorded render with its `refClosure`, and a verdict
40
- in plain words — `would be SKIPPED — input hash unchanged`, or `would
41
- re-render`, or `source file is gone`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.20.0",
3
+ "version": "9.21.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
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
- inputHashOf: entity.checksum && entity.meta == null && entity.content == null
100
- ? 'checksum'
101
- : 'meta+content+inputs',
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
- ? 'would re-render — the entity\'s input hash differs from what it was last rendered at'
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) return { skip: false, reason: 'inputs-changed' }
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({ id: entity?.id, destination: entity?.destination ?? null, reason })
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
- export function inputHashOf(entity) {
19
- if (!entity) return ''
20
- return crypto.createHash('sha1').update(JSON.stringify({
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
- })).digest('hex')
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