mikser-io 9.22.0 → 9.24.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.
@@ -102,21 +102,60 @@ under this flag, so stdout parses whole.
102
102
  npx mikser --json | jq '.summary'
103
103
  ```
104
104
 
105
- Four buckets, and the distinction between them is the point:
105
+ Five buckets, and the distinction between them is the point:
106
106
 
107
107
  | Bucket | Meaning |
108
108
  | --- | --- |
109
- | `rendered` | the render ran, with a `reason` per entity |
109
+ | `rendered` | the render ran and the output moved, with a `reason` per entity |
110
110
  | `skipped` | the manifest decided not to render, with a `reason` |
111
111
  | `unchanged` | the render ran and produced bytes identical to what was already on disk |
112
+ | `errors` | the render ran and **threw** — with `id`, `destination`, `error`, `layout` |
112
113
  | `gated` | a count — the source was unchanged, so no render was ever scheduled |
113
114
 
115
+ A failed render appears in `errors` and **not** in `rendered`: that bucket
116
+ means the output moved, and a throw writes nothing. The previous good bytes
117
+ stay on disk, which is what makes a failed render survivable — and also
118
+ what makes it invisible without this bucket.
119
+
120
+ **A one-shot build with render errors exits `1`.** That is the signal a CI
121
+ gate needs, because `mikser && mikser --verify` would otherwise pass a
122
+ build in which nothing rendered: `--verify` compares the output against the
123
+ manifest, both of which still describe the last good render. Watch mode
124
+ keeps running — a failed render there is a state to fix on the next cycle,
125
+ not a reason to tear down the watcher.
126
+
114
127
  `reason` is a stable vocabulary you can assert on: `unchanged`,
115
128
  `never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
116
129
  `cache-disabled`, `postprocessor`, `force`, `no-manifest`.
117
130
 
118
- `inputs-changed` carries a `changed` array naming **which** input moved,
119
- so you do not have to go to the database to find out:
131
+ Each reason carries the detail behind it, so the answer does not require a
132
+ database query. The key is per-reason `changed`, `matched`,
133
+ `dependency` — because each means something specific:
134
+
135
+ | reason | detail | says |
136
+ | --- | --- | --- |
137
+ | `inputs-changed` | `changed: ["meta.title"]` | which of the entity's own inputs moved |
138
+ | `query-matched` | `matched: { filter, by }` | which query fired, and which mutated entity tripped it |
139
+ | `ref-changed` | `dependency: { kind, target, key, cause }` | which dependency, and whether it `changed`, was `deleted`, or was `unhashed` (nothing resolved when the edge was recorded) |
140
+
141
+ `query-matched` is the one worth reading. It fires on pages you were not
142
+ thinking about — a listing whose filter happens to cover the document you
143
+ just edited — and on a page with a dozen query edges, *which* one fired is
144
+ the whole question:
145
+
146
+ ```json
147
+ { "destination": "/index.html", "reason": "query-matched",
148
+ "matched": { "filter": { "id": { "$regex": "^/documents/devices/" } },
149
+ "by": "/documents/devices/hera.md" } }
150
+ ```
151
+
152
+ A `matched.filter` of `null` is a different statement: the page's predicate
153
+ could not be serialized (`findEntities()` with no argument, or a function
154
+ filter), so it re-renders on **any** mutation. That is not a query that
155
+ matched — it is a page with no filter, and narrowing it is the fix the
156
+ catalog already warns about at record time.
157
+
158
+ `inputs-changed` carries a `changed` array naming **which** input moved:
120
159
 
121
160
  ```json
122
161
  { "id": "/files/hero.jpg", "reason": "inputs-changed", "changed": ["checksum"] }
@@ -132,9 +171,14 @@ reads as `meta.weight (added)` / `(removed)`, which is the answer when a
132
171
  document gains or loses front-matter.
133
172
 
134
173
  The array is absent on a first render — there is no prior snapshot to
135
- compare against and on `ref-changed`, because that is a dependency
136
- moving rather than the entity's own inputs. Conflating the two would make
137
- the attribution misleading.
174
+ compare against. It never appears alongside `matched` or `dependency`
175
+ either: a consumer switches on `reason` and reads one field, so a stray
176
+ key from another branch would make that switch wrong.
177
+
178
+ The same detail appears at `--debug` for a watch run, one line per render.
179
+ It is deliberately not in the build's normal output — the counts are the
180
+ summary and `--json` is the record — but when you are watching one page
181
+ misbehave, the trigger is the point.
138
182
 
139
183
  `unchanged` is the interesting one. It means invalidation was coarser
140
184
  than it needed to be — the render was scheduled, ran, and produced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.22.0",
3
+ "version": "9.24.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
@@ -10,7 +10,7 @@ import { useJournal, updateEntry } from './journal.js'
10
10
  import { globby } from 'globby'
11
11
  import { OPERATION, TASKS } from './constants.js'
12
12
  import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
13
- import { reportRendered, reportSkipped, emitReport } from './report.js'
13
+ import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport } from './report.js'
14
14
  import render from './render.js'
15
15
  import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
16
16
  import map from 'p-map'
@@ -374,7 +374,24 @@ 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, decision.changed)
377
+ // Reported on the way OUT, not here: `rendered` means the
378
+ // output moved, and a render that throws writes nothing. The
379
+ // decision is carried down to the success path so the reason
380
+ // and its detail still travel with it.
381
+ //
382
+ // Same detail at debug, for tailing a watch run. One line per
383
+ // render is too much for a build's normal output — the counts
384
+ // are the summary and --json is the record — but when you are
385
+ // watching one page misbehave, the trigger is the whole point.
386
+ if (logger.isLevelEnabled?.('debug') ?? true) {
387
+ logger.debug('Render %s: %s%s', entity.id, decision.reason,
388
+ decision.changed?.length ? ` (${decision.changed.join(', ')})`
389
+ : decision.matched ? ` (${JSON.stringify(decision.matched.filter)}`
390
+ + `${decision.matched.by ? ` ← ${decision.matched.by}` : ''})`
391
+ : decision.dependency ? ` (${decision.dependency.kind} `
392
+ + `${decision.dependency.target} ${decision.dependency.cause})`
393
+ : '')
394
+ }
378
395
  // Project reference-marker keys (`$author`, `$hero`, …)
379
396
  // into their normalized form (`author`, `hero`) before
380
397
  // the entity crosses into the renderer — applies whether
@@ -509,11 +526,22 @@ export async function setup(options) {
509
526
  await updateEntry({ id, output: entry.output, deps: edges })
510
527
  }
511
528
 
529
+ reportRendered(entity, decision.reason, decision)
512
530
  logger.debug('Rendered: [%s] %s → %s', options.renderer, entity.name || entity.id, entity.destination)
513
531
  } catch (err) {
514
532
  if (!signal.aborted) {
515
533
  await updateEntry({ id, output: { success: false } })
516
- logger.error('Render error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
534
+ const context = formatErrorContext(entity, err, runtime.options)
535
+ logger.error('Render error: %s%s %s', entity.id, context, err.message)
536
+ // The machine-readable half of that same line. Without
537
+ // it a build that fails every page reports rendered:N,
538
+ // warnings:0 and exits 0 — three clean signals and only
539
+ // the human log knowing otherwise.
540
+ reportError(entity, err, {
541
+ renderer: options.renderer ?? null,
542
+ layout: entity.layout?.id ?? null,
543
+ context: context.trim() || null,
544
+ })
517
545
  }
518
546
  logger.debug('Render canceled')
519
547
  }
@@ -524,8 +552,12 @@ export async function setup(options) {
524
552
  concurrency: runtime.options.threads,
525
553
  signal
526
554
  })
527
- renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped)
555
+ // Jobs minus skips minus THROWS. Counting a failed render as
556
+ // rendered is the same overstatement the report used to make.
557
+ const failed = renderErrorCount()
558
+ renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped - failed)
528
559
  skipped && logger.info('Manifest skipped: %d', skipped)
560
+ failed && logger.error('Render errors: %d', failed)
529
561
  })
530
562
 
531
563
  onBeforePostprocess(async (signal) => {
@@ -752,11 +784,31 @@ export async function setup(options) {
752
784
  }
753
785
  }
754
786
  }
755
- logger.notice('Mikser completed')
787
+ // A cycle with failed renders is not a completed build, and the word
788
+ // people read is this one.
789
+ const failed = renderErrorCount()
790
+ if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
791
+ else logger.notice('Mikser completed')
792
+
756
793
  // After the cycle, and only under --json. stdout has been kept clear
757
794
  // for exactly this (the logger writes to stderr under --json), so the
758
795
  // document is the only thing on it and can be piped to jq.
759
796
  emitReport()
797
+
798
+ // Non-zero for a one-shot build, so `mikser && mikser --verify` cannot
799
+ // pass with every page in the site stale. `exitCode` rather than
800
+ // process.exit so the report above is flushed and shutdown runs.
801
+ //
802
+ // Watch mode keeps going: a failed render there is a state to fix in
803
+ // the next cycle, not a reason to tear down the watcher. That is also
804
+ // what makes the failure self-concealing in watch — the errors scroll
805
+ // past between two green builds — so the exit code is precisely the
806
+ // signal CI needs and the one interactive use must not have.
807
+ //
808
+ // 1, not 2: --verify already uses 2 for output drift and --explain 3
809
+ // for not-found. "The build ran and some renders threw" is its own
810
+ // thing.
811
+ if (failed && !runtime.options.watch) process.exitCode = 1
760
812
  })
761
813
 
762
814
  onCancelled(async () => {
package/src/manifest.js CHANGED
@@ -389,11 +389,36 @@ export function createManifest(db) {
389
389
  const sourceLang = entity?.meta?.lang ?? null
390
390
  for (const entry of snapshot.refClosure) {
391
391
  if (entry.kind === 'query') {
392
- if (!entry.filter) return { skip: false, reason: 'query-matched' }
392
+ // A null filter is the sentinel for a predicate that
393
+ // could not be serialized — findEntities() with no
394
+ // argument, or a function filter. It re-renders on ANY
395
+ // mutation, which is a materially different situation
396
+ // from "this specific query matched" and is reported as
397
+ // such: an aggregate page that always re-renders is a
398
+ // filter worth narrowing, which is what catalog.js
399
+ // already warns about at record time.
400
+ if (!entry.filter) {
401
+ return {
402
+ skip: false,
403
+ reason: 'query-matched',
404
+ matched: { filter: null, by: null },
405
+ }
406
+ }
393
407
  if (!mutatedEntities?.size) continue
394
408
  const matcher = sift(entry.filter)
395
409
  for (const mutated of mutatedEntities.values()) {
396
- if (matcher(mutated)) return { skip: false, reason: 'query-matched' }
410
+ // Both the filter and the entity that tripped it are
411
+ // live here. Reporting only "a query matched" throws
412
+ // away the two facts the reader needs — on a page with
413
+ // eighteen query edges, which one fired and what set
414
+ // it off is the entire question.
415
+ if (matcher(mutated)) {
416
+ return {
417
+ skip: false,
418
+ reason: 'query-matched',
419
+ matched: { filter: entry.filter, by: mutated.id ?? null },
420
+ }
421
+ }
397
422
  }
398
423
  continue
399
424
  }
@@ -433,11 +458,26 @@ export function createManifest(db) {
433
458
  continue
434
459
  }
435
460
  }
436
- if (!entry.hash) return { skip: false, reason: 'ref-changed' }
461
+ // Which dependency, and why. Three distinct causes reach
462
+ // the same reason, and the difference is what the reader
463
+ // is after: a layout whose bytes moved, a $-ref whose
464
+ // target was deleted, and an edge recorded with no hash
465
+ // are three different things to go and look at.
466
+ //
467
+ // unhashed nothing resolved when the edge was recorded,
468
+ // so any mutation of that name re-renders
469
+ // deleted the target is gone from the catalog
470
+ // changed the target's own input hash moved
471
+ const dependency = (cause) => ({
472
+ skip: false,
473
+ reason: 'ref-changed',
474
+ dependency: { kind: entry.kind, target: entry.target, key, cause },
475
+ })
476
+ if (!entry.hash) return dependency('unhashed')
437
477
  const currentHash = currentHashes?.get(key)
438
478
  if (currentHash === undefined) continue
439
- if (currentHash === null) return { skip: false, reason: 'ref-changed' }
440
- if (currentHash !== entry.hash) return { skip: false, reason: 'ref-changed' }
479
+ if (currentHash === null) return dependency('deleted')
480
+ if (currentHash !== entry.hash) return dependency('changed')
441
481
  }
442
482
  }
443
483
  return { skip: true, reason: 'unchanged' }
package/src/report.js CHANGED
@@ -13,7 +13,7 @@ import runtime from './runtime.js'
13
13
 
14
14
  function store() {
15
15
  runtime.state ??= {}
16
- runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], warnings: [], gated: 0 }
16
+ runtime.state.report ??= { rendered: [], skipped: [], unchanged: [], errors: [], warnings: [], gated: 0 }
17
17
  return runtime.state.report
18
18
  }
19
19
 
@@ -28,15 +28,25 @@ export function reportGated(count = 1) {
28
28
  store().gated += count
29
29
  }
30
30
 
31
- export function reportRendered(entity, reason, changed) {
31
+ // `decision` is the skipDecision that led here. Its detail travels with the
32
+ // reason, because a reason on its own answers a question nobody asked: WHICH
33
+ // input moved, WHICH query matched and what tripped it, WHICH dependency
34
+ // changed and how. All of it is in hand where the decision is made, and a
35
+ // consumer that has to go to the database for it is one the report failed.
36
+ //
37
+ // Detail keys are per-reason rather than one generic field — `changed`,
38
+ // `matched`, `dependency` each mean something specific, and a single
39
+ // polymorphic key would push the type switch onto every consumer.
40
+ export function reportRendered(entity, reason, decision = {}) {
32
41
  if (!runtime.options?.json) return
33
42
  store().rendered.push({
34
43
  id: entity?.id,
35
44
  destination: entity?.destination ?? null,
36
45
  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 } : {}),
46
+ // Omitted rather than empty, so presence is meaningful.
47
+ ...(decision.changed?.length ? { changed: decision.changed } : {}),
48
+ ...(decision.matched ? { matched: decision.matched } : {}),
49
+ ...(decision.dependency ? { dependency: decision.dependency } : {}),
40
50
  })
41
51
  }
42
52
 
@@ -67,15 +77,53 @@ export function reportWarning(code, fields = {}) {
67
77
  store().warnings.push({ code, ...fields })
68
78
  }
69
79
 
80
+ // A render that RAN and THREW. Recorded unconditionally — not gated on
81
+ // --json like the other buckets — because the exit code depends on the
82
+ // count, and a build that fails 12 renders must not exit 0 just because
83
+ // nobody asked for a report.
84
+ //
85
+ // Failed entities do NOT appear in `rendered`. That bucket means the output
86
+ // moved, and a throw writes nothing: the previous good bytes stay on disk,
87
+ // which is what makes the failure survivable and also what makes it
88
+ // invisible. `rendered: 12` beside zero written files is the misleading
89
+ // half, and summing buckets should not require knowing that.
90
+ export function reportError(entity, err, context = {}) {
91
+ const store = errorStore()
92
+ store.push({
93
+ id: entity?.id ?? null,
94
+ destination: entity?.destination ?? null,
95
+ error: err?.message ?? String(err),
96
+ ...context,
97
+ })
98
+ }
99
+
100
+ // Errors are counted even without --json, so they need a store that does
101
+ // not depend on the report being requested.
102
+ function errorStore() {
103
+ runtime.state ??= {}
104
+ runtime.state.renderErrors ??= []
105
+ return runtime.state.renderErrors
106
+ }
107
+
108
+ // How many renders threw this cycle. Read by the engine to decide the
109
+ // process exit code.
110
+ export function renderErrorCount() {
111
+ return errorStore().length
112
+ }
113
+
70
114
  export function buildReport() {
71
115
  const report = store()
72
116
  return {
73
117
  rendered: report.rendered,
74
118
  skipped: report.skipped,
75
119
  unchanged: report.unchanged,
120
+ // Renders that ran and threw. A build with a non-empty `errors` is a
121
+ // failed build, whatever the other counts say.
122
+ errors: errorStore(),
76
123
  warnings: report.warnings,
77
124
  summary: {
78
125
  rendered: report.rendered.length,
126
+ errors: errorStore().length,
79
127
  // Of those renders, how many produced bytes identical to
80
128
  // what was already on disk — see reportUnchanged.
81
129
  unchanged: report.unchanged.length,