mikser-io 11.0.2 → 11.1.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/package.json +1 -1
- package/src/explain.js +44 -8
- package/src/manifest/cycle.js +24 -3
- package/src/report.js +23 -3
- package/src/source.js +48 -0
- package/src/utils/entity.js +19 -0
- package/src/utils/index.js +1 -1
package/package.json
CHANGED
package/src/explain.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Follows --audit-output's shape: report and exit, no build phases run.
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
|
-
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils/index.js'
|
|
12
|
+
import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum, isLocalUri, uriScheme } from './utils/index.js'
|
|
13
|
+
import { recomputeSourceChecksum } from './source.js'
|
|
13
14
|
import { outputMissing } from './invalidation.js'
|
|
14
15
|
import { filterKey } from './track.js'
|
|
15
16
|
import { findEntity, findEntities, findById } from './catalog.js'
|
|
@@ -149,19 +150,51 @@ export async function explain(reference) {
|
|
|
149
150
|
// would say "skipped", which is true of the catalog and misleading about
|
|
150
151
|
// the next build. So check the file too, and say which is being reported.
|
|
151
152
|
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
153
|
+
// Compared like with like.
|
|
154
|
+
//
|
|
155
|
+
// Some collections store a COMPOSED checksum rather than a file hash —
|
|
156
|
+
// layouts stores md5("<template>:<sidecar>:<shared>") so a sidecar edit
|
|
157
|
+
// invalidates the layout. Comparing that against a fresh md5 of the
|
|
158
|
+
// template is comparing two recipes: it never matches, and this reported
|
|
159
|
+
// `differs: true` for every layout on every site, permanently, for files
|
|
160
|
+
// nobody had touched.
|
|
161
|
+
//
|
|
162
|
+
// So the collection is asked how to recompute its own value. Only where
|
|
163
|
+
// nothing is registered — every ordinary source — is the file hash the
|
|
164
|
+
// right comparison, and there it still is.
|
|
156
165
|
let source = null
|
|
157
|
-
|
|
166
|
+
// A provider-backed entity has no local file, and asking the filesystem
|
|
167
|
+
// about `https://...` returns ENOENT — which this used to report as
|
|
168
|
+
// "file is gone", and the verdict then told the reader a build would
|
|
169
|
+
// DELETE the entity. It would do no such thing: mikser-io-csv pulls rows
|
|
170
|
+
// over http and the entity is perfectly healthy.
|
|
171
|
+
//
|
|
172
|
+
// Whether the remote moved is a real question, but it is not one a local
|
|
173
|
+
// checksum can answer, and answering it here would put a network fetch
|
|
174
|
+
// inside a read-only explain. So the comparison is declined, out loud.
|
|
175
|
+
if (entity.uri && !isLocalUri(entity.uri)) {
|
|
176
|
+
source = {
|
|
177
|
+
uri: entity.uri,
|
|
178
|
+
scheme: uriScheme(entity.uri),
|
|
179
|
+
catalogChecksum: entity.checksum ?? null,
|
|
180
|
+
remote: true,
|
|
181
|
+
// No `differs`. Absent would read as false to anything checking
|
|
182
|
+
// truthiness, so the reason it is absent is stated instead.
|
|
183
|
+
notCompared: 'the source is fetched by a provider, so there is no local file to compare against',
|
|
184
|
+
}
|
|
185
|
+
} else if (entity.uri) {
|
|
158
186
|
try {
|
|
159
187
|
const onDisk = await fileChecksum(entity.uri)
|
|
188
|
+
const composed = await recomputeSourceChecksum(entity)
|
|
189
|
+
const comparable = composed ?? onDisk
|
|
160
190
|
source = {
|
|
161
191
|
uri: entity.uri,
|
|
162
192
|
catalogChecksum: entity.checksum ?? null,
|
|
163
193
|
fileChecksum: onDisk,
|
|
164
|
-
|
|
194
|
+
// Reported only when it is not simply the file hash, so its
|
|
195
|
+
// presence means "this collection composes" rather than noise.
|
|
196
|
+
...(composed ? { comparableChecksum: composed } : {}),
|
|
197
|
+
differs: entity.checksum != null && entity.checksum !== comparable,
|
|
165
198
|
}
|
|
166
199
|
} catch (err) {
|
|
167
200
|
source = { uri: entity.uri, error: err.code === 'ENOENT' ? 'file is gone' : err.message }
|
|
@@ -293,7 +326,10 @@ export async function explain(reference) {
|
|
|
293
326
|
})),
|
|
294
327
|
// What a plain build would do next, stated plainly.
|
|
295
328
|
verdict: contestedVerdict(competingFor(snapshots, entity.id))
|
|
296
|
-
?? (source?.
|
|
329
|
+
?? (source?.remote
|
|
330
|
+
? `source is fetched over ${source.scheme} — a build asks its provider, and no local `
|
|
331
|
+
+ 'file was compared here'
|
|
332
|
+
: source?.error === 'file is gone'
|
|
297
333
|
? 'source file is gone — a build would DELETE this entity and unlink its output'
|
|
298
334
|
: source?.differs
|
|
299
335
|
? 'source differs from the catalog — a build would re-import it first, then re-render. '
|
package/src/manifest/cycle.js
CHANGED
|
@@ -12,6 +12,7 @@ import { useJournal } from '../journal.js'
|
|
|
12
12
|
import { onFinalize, onLoaded } from '../lifecycle.js'
|
|
13
13
|
import { unlink } from 'fs/promises'
|
|
14
14
|
import { buildSnapshot, hashOutputFile, snapToRow } from './snapshot.js'
|
|
15
|
+
import { renderedByDependency } from '../report.js'
|
|
15
16
|
|
|
16
17
|
// The manifest instance and its database, owned here because this is the only
|
|
17
18
|
// place either is assigned: onLoaded builds them, onFinalize commits through
|
|
@@ -249,8 +250,26 @@ onFinalize(async () => {
|
|
|
249
250
|
// still is knowable at the moment it happens, for the cost of one
|
|
250
251
|
// lookup. That is a rendering change nobody asked for: an upgraded
|
|
251
252
|
// renderer, a changed helper, a dependency that shifted under the
|
|
252
|
-
// build.
|
|
253
|
-
//
|
|
253
|
+
// build.
|
|
254
|
+
//
|
|
255
|
+
// "Every entity input is in inputHash by construction" is what this
|
|
256
|
+
// used to say, and it is false. inputHash covers the entity's OWN
|
|
257
|
+
// meta and checksum. An entity assembled from a query — a CSS bundle
|
|
258
|
+
// globbing styles/**/*.css, a page listing its collection — has every
|
|
259
|
+
// real input outside it, reaching the render through refClosure. Its
|
|
260
|
+
// inputHash is CONSTANT by construction, so editing any part tripped
|
|
261
|
+
// this check on every build, forever, telling the reader the cause was
|
|
262
|
+
// an upgraded renderer when it was the file they had just saved.
|
|
263
|
+
//
|
|
264
|
+
// The refClosure cannot settle it either: a query edge records the
|
|
265
|
+
// filter and how many matched, not a hash of what they contained, so
|
|
266
|
+
// it is identical before and after the edit.
|
|
267
|
+
//
|
|
268
|
+
// What does settle it is WHY the render happened. `ref-changed` and
|
|
269
|
+
// `query-matched` mean something this entity consumes moved — that is
|
|
270
|
+
// an input change the hashes cannot see, and not drift. Everything
|
|
271
|
+
// else with an unchanged inputHash still is: --force sweeps, retries,
|
|
272
|
+
// a renderer that stopped being a function of its inputs.
|
|
254
273
|
//
|
|
255
274
|
// Recorded here rather than checked later because later is too late —
|
|
256
275
|
// the render rewrites its own snapshot, so by the time anything asks,
|
|
@@ -261,6 +280,7 @@ onFinalize(async () => {
|
|
|
261
280
|
// build the unchanged ones are skipped and never reach here, so this
|
|
262
281
|
// reports on what moved; under --force everything re-renders with
|
|
263
282
|
// unchanged inputs, which makes it a full sweep.
|
|
283
|
+
const dependencyDriven = renderedByDependency()
|
|
264
284
|
for (const snap of recordedSnapshots) {
|
|
265
285
|
const prior = m._stmtLookup.get(snap.id, snap.destination)
|
|
266
286
|
if (prior
|
|
@@ -268,7 +288,8 @@ onFinalize(async () => {
|
|
|
268
288
|
&& prior.inputHash === snap.inputHash
|
|
269
289
|
&& prior.outputHash
|
|
270
290
|
&& snap.outputHash
|
|
271
|
-
&& prior.outputHash !== snap.outputHash
|
|
291
|
+
&& prior.outputHash !== snap.outputHash
|
|
292
|
+
&& !dependencyDriven.has(snap.id)) {
|
|
272
293
|
drifted.push({ id: snap.id, destination: snap.destination })
|
|
273
294
|
}
|
|
274
295
|
m._stmtUpsert.run(snapToRow(snap))
|
package/src/report.js
CHANGED
|
@@ -128,7 +128,7 @@ export function resetReport() {
|
|
|
128
128
|
// not in later ones, which is what actually happened.
|
|
129
129
|
runtime.state.timings = {}
|
|
130
130
|
runtime.state.pluginTimings = {}
|
|
131
|
-
runtime.state.activity = { rendered: 0, changed: 0 }
|
|
131
|
+
runtime.state.activity = { rendered: 0, changed: 0, dependencyDriven: new Set() }
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Published on the runtime so runtime.js can start a fresh cycle for a
|
|
@@ -281,7 +281,19 @@ export function reportGated(count = 1) {
|
|
|
281
281
|
// `matched`, `dependency` each mean something specific, and a single
|
|
282
282
|
// polymorphic key would push the type switch onto every consumer.
|
|
283
283
|
export function reportRendered(entity, reason, decision = {}) {
|
|
284
|
-
activity()
|
|
284
|
+
const seen = activity()
|
|
285
|
+
seen.rendered++
|
|
286
|
+
// Recorded BEFORE the reportWanted gate, because the drift check depends
|
|
287
|
+
// on it and a check that only works under --report is not a check.
|
|
288
|
+
//
|
|
289
|
+
// These two reasons mean the entity re-rendered because something it
|
|
290
|
+
// consumes moved — a $-ref, a partial, an entity matching a recorded
|
|
291
|
+
// query. Its own inputHash did not move and cannot have: for a bundle
|
|
292
|
+
// assembled from a query, every real input is OUTSIDE inputHash by
|
|
293
|
+
// construction. See the drift check in manifest/cycle.js.
|
|
294
|
+
if (reason === 'ref-changed' || reason === 'query-matched') {
|
|
295
|
+
if (entity?.id) seen.dependencyDriven.add(entity.id)
|
|
296
|
+
}
|
|
285
297
|
if (!reportWanted()) return
|
|
286
298
|
store().rendered.push({
|
|
287
299
|
id: entity?.id,
|
|
@@ -442,9 +454,17 @@ export function renderErrorCount() {
|
|
|
442
454
|
// "nothing moved" on a build that had just rendered.
|
|
443
455
|
//
|
|
444
456
|
// Two integers cost nothing and are always true.
|
|
457
|
+
// The entities that re-rendered this cycle because a DEPENDENCY moved, rather
|
|
458
|
+
// than because their own inputs did. Read by the drift check, which cannot
|
|
459
|
+
// tell the difference from hashes alone.
|
|
460
|
+
export function renderedByDependency() {
|
|
461
|
+
return activity().dependencyDriven
|
|
462
|
+
}
|
|
463
|
+
|
|
445
464
|
function activity() {
|
|
446
465
|
runtime.state ??= {}
|
|
447
|
-
runtime.state.activity ??= { rendered: 0, changed: 0 }
|
|
466
|
+
runtime.state.activity ??= { rendered: 0, changed: 0, dependencyDriven: new Set() }
|
|
467
|
+
runtime.state.activity.dependencyDriven ??= new Set()
|
|
448
468
|
return runtime.state.activity
|
|
449
469
|
}
|
|
450
470
|
|
package/src/source.js
CHANGED
|
@@ -42,6 +42,7 @@ import { mkdir, readFile } from 'node:fs/promises'
|
|
|
42
42
|
import { globby } from 'globby'
|
|
43
43
|
import pMap from 'p-map'
|
|
44
44
|
import runtime from './runtime.js'
|
|
45
|
+
import { useLogger } from './engine/index.js'
|
|
45
46
|
import { ACTION } from './constants.js'
|
|
46
47
|
import { checksum as fileChecksum, checksumOf, junkIgnore } from './utils/index.js'
|
|
47
48
|
import { reportGated, reportChanged } from './report.js'
|
|
@@ -82,6 +83,53 @@ const SCAN_CONCURRENCY = 16
|
|
|
82
83
|
// disagree, and the losing combination (empty content + a checksum correct
|
|
83
84
|
// for the finished file) is permanent, because every later sync then
|
|
84
85
|
// short-circuits on "unchanged".
|
|
86
|
+
// How to recompute a collection's CATALOG checksum, for the collections that
|
|
87
|
+
// do not store a plain file hash.
|
|
88
|
+
//
|
|
89
|
+
// `gateChecksum` accepts `bytes`, so a plugin can store a checksum composed
|
|
90
|
+
// from several files — layouts folds in its .js sidecar and the shared digest,
|
|
91
|
+
// as md5("<template>:<sidecar>:<shared>"). That is deliberate and correct.
|
|
92
|
+
//
|
|
93
|
+
// What was not correct is anything comparing that stored value against a fresh
|
|
94
|
+
// md5 of the file: two different recipes, so they never match, and
|
|
95
|
+
// mikser_explain reported `differs: true` for EVERY layout on every site —
|
|
96
|
+
// telling the reader that someone had edited a file outside the build when
|
|
97
|
+
// nobody had. A permanent false alarm is worse than no alarm, because it
|
|
98
|
+
// trains the reader to skip the real one.
|
|
99
|
+
//
|
|
100
|
+
// A collection with nothing registered keeps the plain file hash, which is
|
|
101
|
+
// right for every ordinary source. A registered recompute may ALSO return null
|
|
102
|
+
// for an individual entity, meaning "this one is not composed" — layouts needs
|
|
103
|
+
// that for its sidecars, which live in the same collection and are gated on
|
|
104
|
+
// their own bytes.
|
|
105
|
+
const sourceChecksums = new Map()
|
|
106
|
+
|
|
107
|
+
export function registerSourceChecksum(collection, recompute) {
|
|
108
|
+
if (!collection || typeof recompute !== 'function') {
|
|
109
|
+
throw new Error('registerSourceChecksum(collection, recompute) requires both')
|
|
110
|
+
}
|
|
111
|
+
sourceChecksums.set(collection, recompute)
|
|
112
|
+
return () => { sourceChecksums.delete(collection) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The checksum the CATALOG would hold for this entity as it stands on disk —
|
|
116
|
+
// composed the way its own collection composes it. Returns null when the
|
|
117
|
+
// collection composes nothing, which tells the caller to use the file hash.
|
|
118
|
+
export async function recomputeSourceChecksum(entity) {
|
|
119
|
+
const recompute = sourceChecksums.get(entity?.collection)
|
|
120
|
+
if (!recompute) return null
|
|
121
|
+
try {
|
|
122
|
+
return await recompute(entity)
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// "Cannot tell" rather than a crash — explain must still answer. But
|
|
125
|
+
// it says so: a silently swallowed recompute would send the reader
|
|
126
|
+
// back to comparing raw hashes with no hint that anything went wrong.
|
|
127
|
+
useLogger?.()?.debug('Source checksum for %s could not be recomputed: %s',
|
|
128
|
+
entity?.collection, err.message)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
85
133
|
export async function gateChecksum(file, id, { reload = false, priorChecksums, bytes } = {}) {
|
|
86
134
|
const compute = () => (bytes !== undefined ? checksumOf(bytes) : fileChecksum(file))
|
|
87
135
|
// What overrides the checksum is not this function's to decide — see
|
package/src/utils/entity.js
CHANGED
|
@@ -149,6 +149,25 @@ const providerModuleCache = new Map()
|
|
|
149
149
|
// built-in filesystem read.
|
|
150
150
|
const URI_SCHEME_RE = /^([a-z][a-z0-9+\-.]*):\/\//i
|
|
151
151
|
|
|
152
|
+
// The scheme a uri names, lowercased, or null when it names none.
|
|
153
|
+
//
|
|
154
|
+
// `null` and `file` both mean the local filesystem — that is the split
|
|
155
|
+
// readEntityContent below dispatches on, and anything else asking "is there a
|
|
156
|
+
// file here to read" has to make the same distinction. Exported so it is made
|
|
157
|
+
// once: mikser_explain used to run a filesystem checksum over `https://...`,
|
|
158
|
+
// get ENOENT, and report "source file is gone — a build would DELETE this
|
|
159
|
+
// entity", about a healthy entity a provider had fetched.
|
|
160
|
+
export function uriScheme(uri) {
|
|
161
|
+
if (typeof uri !== 'string') return null
|
|
162
|
+
return URI_SCHEME_RE.exec(uri)?.[1]?.toLowerCase() ?? null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Does this uri point at something on the local filesystem?
|
|
166
|
+
export function isLocalUri(uri) {
|
|
167
|
+
const scheme = uriScheme(uri)
|
|
168
|
+
return scheme === null || scheme === 'file'
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
// Resolve `<scheme>` to the package `mikser-io-provider-<scheme>` and
|
|
153
172
|
// import it. Cached per scheme; same package convention as renderers
|
|
154
173
|
// (`mikser-io-render-<name>`) and postprocessors (`mikser-io-post-<name>`).
|
package/src/utils/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This is the surface. Everything imported from './utils/index.js' is
|
|
9
9
|
// re-exported here, so the split costs no caller anything.
|
|
10
10
|
|
|
11
|
-
export { changeExtension, getFormatInfo, isTextEntity, looksTextual, matchEntity, mimeForEntity, projectMeta, readEntityContent, useCollection } from './entity.js'
|
|
11
|
+
export { changeExtension, getFormatInfo, isLocalUri, isTextEntity, looksTextual, matchEntity, mimeForEntity, projectMeta, readEntityContent, uriScheme, useCollection } from './entity.js'
|
|
12
12
|
export { AbortError, formatErrorContext, formatLogArgs } from './errors.js'
|
|
13
13
|
export { ExpandError, expandEntity } from './expand.js'
|
|
14
14
|
export { checksum, checksumOf, diffInputParts, inputHashOf, inputPartsOf, normalize } from './hash.js'
|