mikser-io 9.19.1 → 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.19.1",
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": {
@@ -331,6 +331,11 @@ export function createSqliteDatabase({
331
331
  logger: logger ?? null,
332
332
  }
333
333
 
334
+ // Published so plugins can tell an everything-was-evaluated cycle
335
+ // from an incremental one without reaching into the db handle. Read
336
+ // through `isFullCycle()` in utils.js rather than directly.
337
+ runtime.options.firstRun = provisioningCtx.firstRun
338
+
334
339
  // Fire provisioning callbacks before schema apply, so they can
335
340
  // load runtime extensions (sqlite-vec's vec0, etc.) that the
336
341
  // schemas about to apply might reference, register custom
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,
@@ -190,9 +212,34 @@ function snapToRow(snap) {
190
212
  }
191
213
  }
192
214
 
193
- async function hashOutputFile(destination) {
215
+ // Where a `destination` actually is on disk.
216
+ //
217
+ // Two shapes are in circulation and both are legitimate. A page's is
218
+ // output-relative with a leading slash (`/bg/index.html`); an asset's is a
219
+ // real filesystem path, because assets.js builds it from
220
+ // `runtime.options.assetsFolder`, which resolves inside the WORKING folder
221
+ // and can sit outside outputFolder entirely.
222
+ //
223
+ // `path.isAbsolute` cannot separate them — on POSIX `/bg/index.html` is
224
+ // absolute too — so resolve by EXISTENCE, output-relative first since that
225
+ // is the dominant shape. A page destination treated as a filesystem path
226
+ // would otherwise be looked for at the root of the disk.
227
+ //
228
+ // Shared, because the two callers needing it drifted into two separate
229
+ // bugs: verify() reported every asset missing while printing its real
230
+ // path, and hashOutputFile silently recorded no outputHash for one, which
231
+ // left 78% of a real project's snapshots presence-checked only.
232
+ export function resolveOutputPath(destination, outputFolder = runtime.options?.outputFolder) {
194
233
  if (!destination) return undefined
195
- const filePath = path.join(runtime.options.outputFolder, destination)
234
+ const joined = path.join(outputFolder ?? '', destination)
235
+ if (existsSync(joined)) return joined
236
+ if (path.isAbsolute(destination) && existsSync(destination)) return destination
237
+ return joined
238
+ }
239
+
240
+ async function hashOutputFile(destination) {
241
+ const filePath = resolveOutputPath(destination)
242
+ if (!filePath) return undefined
196
243
  try {
197
244
  const buf = await readFile(filePath)
198
245
  return sha1(buf)
@@ -205,18 +252,18 @@ export function createManifest(db) {
205
252
  if (!db) throw new Error('createManifest: db is required')
206
253
 
207
254
  const stmtLookupById = db.prepare(`
208
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
255
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
209
256
  FROM mikser_snapshots WHERE id = ? ORDER BY destination
210
257
  `)
211
258
  const stmtLookup = db.prepare(`
212
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
259
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
213
260
  FROM mikser_snapshots WHERE id = ? AND destination = ?
214
261
  `)
215
262
  const stmtUpsert = db.prepare(`
216
263
  INSERT OR REPLACE INTO mikser_snapshots
217
- (id, destination, inputHash, outputHash, refClosure, renderedAt, parent)
264
+ (id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent)
218
265
  VALUES
219
- (@id, @destination, @inputHash, @outputHash, @refClosure, @renderedAt, @parent)
266
+ (@id, @destination, @inputHash, @inputParts, @outputHash, @refClosure, @renderedAt, @parent)
220
267
  `)
221
268
  const stmtDeleteByPK = db.prepare(`
222
269
  DELETE FROM mikser_snapshots WHERE id = ? AND destination = ?
@@ -232,7 +279,7 @@ export function createManifest(db) {
232
279
  SELECT id, destination FROM mikser_snapshots WHERE parent = ?
233
280
  `)
234
281
  const stmtSelectAll = db.prepare(`
235
- SELECT id, destination, inputHash, outputHash, refClosure, renderedAt, parent
282
+ SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
236
283
  FROM mikser_snapshots
237
284
  `)
238
285
  const stmtCount = db.prepare(`SELECT COUNT(*) AS c FROM mikser_snapshots`)
@@ -327,7 +374,16 @@ export function createManifest(db) {
327
374
  if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
328
375
  const snapshot = this.lookup(entity)
329
376
  if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
330
- 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
+ }
331
387
  if (!snapshot.refClosure?.length) return { skip: true, reason: 'unchanged' }
332
388
  const sourceLang = entity?.meta?.lang ?? null
333
389
  for (const entry of snapshot.refClosure) {
@@ -552,31 +608,7 @@ export function createManifest(db) {
552
608
  for (const row of stmtSelectAll.iterate()) {
553
609
  const snap = rowToSnap(row)
554
610
  if (!snap.destination) continue
555
- // `destination` arrives in two shapes and both are legitimate.
556
- // A page's is output-relative with a leading slash
557
- // (`/bg/index.html`); an asset's is a real filesystem path,
558
- // because assets.js builds it from
559
- // `runtime.options.assetsFolder` — which resolves inside the
560
- // WORKING folder and may sit outside outputFolder entirely
561
- // (a `derived/` tree that `shares` symlinks into each language
562
- // root is the case that proves it).
563
- //
564
- // `path.isAbsolute` cannot tell them apart: on POSIX
565
- // `/bg/index.html` is absolute too. So resolve by EXISTENCE,
566
- // output-relative first — that is the dominant shape, and a
567
- // page destination interpreted as a filesystem path would
568
- // otherwise be looked for at the root of the disk.
569
- //
570
- // Only the fallback path is a filesystem path, so it is only
571
- // tried when the destination is absolute and joining changed
572
- // nothing that resolves.
573
- const joined = path.join(outputFolder, snap.destination)
574
- let filePath = joined
575
- if (!existsSync(joined)
576
- && path.isAbsolute(snap.destination)
577
- && existsSync(snap.destination)) {
578
- filePath = snap.destination
579
- }
611
+ const filePath = resolveOutputPath(snap.destination, outputFolder)
580
612
  // Orphan detection compares against a globby walk of
581
613
  // outputFolder, so `claimed` has to hold exactly the relative
582
614
  // form that walk produces. A destination resolving outside
@@ -6,6 +6,7 @@ import { globby } from 'globby'
6
6
  import _ from 'lodash'
7
7
  import map from 'p-map'
8
8
  import { reportWarning } from '../report.js'
9
+ import { isFullCycle } from '../utils.js'
9
10
 
10
11
  // Normalize a `options.presets[name]` value to a consistent
11
12
  // { matches, options } shape so callers don't have to inspect which form
@@ -120,6 +121,14 @@ export function assets(options = {}) {
120
121
  if (matchTally.reported) return
121
122
  const configured = Object.keys(options.presets || {})
122
123
  if (!configured.length || !matchTally.evaluated) return
124
+ // Only when the cycle evaluated everything. On an incremental run the
125
+ // evaluated set is whatever changed, so a healthy preset matches
126
+ // nothing in a run of two and the warning fires on every build —
127
+ // which trains the reader to filter it, and the filtered-out line is
128
+ // the real one. The message already told the reader to use --force to
129
+ // check the whole catalog; that condition gates it now instead of
130
+ // annotating it.
131
+ if (!isFullCycle(runtime)) return
123
132
  matchTally.reported = true
124
133
 
125
134
  for (const preset of configured) {
@@ -129,8 +138,7 @@ export function assets(options = {}) {
129
138
  logger.warn(
130
139
  'Assets preset %j matched none of the %d entities evaluated (patterns: %s). ' +
131
140
  'Patterns run against entity.id, which files({ outputFolder }) does NOT prefix — ' +
132
- 'the prefix appears on name and meta.url only. On an incremental run unchanged ' +
133
- 'entities are not re-evaluated; use --force to check the whole catalog.',
141
+ 'the prefix appears on name and meta.url only.',
134
142
  preset, matchTally.evaluated, matches.join(', '))
135
143
  }
136
144
  }
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
@@ -990,6 +1054,29 @@ export async function writeOutput(file, bytes) {
990
1054
  return true
991
1055
  }
992
1056
 
1057
+ // Did this cycle evaluate the whole corpus, or only what changed?
1058
+ //
1059
+ // The distinction is what makes a "declared X matched nothing" warning
1060
+ // worth printing. On an incremental cycle only changed entities are
1061
+ // re-evaluated, so a pattern or preset legitimately matches nothing in a
1062
+ // run of two — printing it there means the warning fires on every healthy
1063
+ // build, and a warning that always fires is one people filter out, taking
1064
+ // the real instance with it.
1065
+ //
1066
+ // True for: --force, a first-run or wiped database, and a cache
1067
+ // invalidation. Those are exactly the cases where the import gate is
1068
+ // bypassed and every entity is presented for evaluation.
1069
+ // Takes the runtime rather than reading the singleton, so a plugin passes
1070
+ // the one it was injected with — which is the singleton in a real build and
1071
+ // the harness's stand-in under test.
1072
+ export function isFullCycle(rt = runtime) {
1073
+ return !!(
1074
+ rt?.options?.force
1075
+ || rt?.options?.firstRun
1076
+ || rt?.catalog?.cacheInvalidated
1077
+ )
1078
+ }
1079
+
993
1080
  // ── operating-system and file-manager litter ────────────────────────────
994
1081
  //
995
1082
  // Exposing a source folder over a network filesystem (mikser-io-webdav) or