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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "11.0.2",
3
+ "version": "11.1.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
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
- // A caveat rather than a bug: some plugins compose a checksum from more
153
- // than one file (layouts folds in its .js sidecar), so a difference does
154
- // not always mean the entity's own source moved. Both values are reported
155
- // and the wording avoids claiming more than is known.
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
- if (entity.uri) {
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
- differs: entity.checksum != null && entity.checksum !== onDisk,
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?.error === 'file is gone'
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. '
@@ -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. Every entity input is in inputHash by construction, so if
253
- // that did not move, the cause was not the content.
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().rendered++
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
@@ -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>`).
@@ -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'