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.
- package/docs/diagnostics.md +44 -4
- package/package.json +1 -1
- package/src/database/index.js +5 -0
- package/src/engine.js +1 -1
- package/src/explain.js +33 -5
- package/src/manifest.js +66 -34
- package/src/plugins/assets.js +10 -2
- package/src/report.js +9 -2
- package/src/utils.js +91 -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/database/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -190,9 +212,34 @@ function snapToRow(snap) {
|
|
|
190
212
|
}
|
|
191
213
|
}
|
|
192
214
|
|
|
193
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
package/src/plugins/assets.js
CHANGED
|
@@ -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.
|
|
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({
|
|
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
|
|
@@ -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
|