mikser-io 9.23.0 → 9.25.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.
@@ -54,7 +54,23 @@ rendered 2026-08-22 21:07:52 → /page-a.html [STALE: input hash moved si
54
54
  ```
55
55
 
56
56
  A snapshot written before per-input recording says so rather than
57
- guessing. Note that `--explain` compares the CATALOG's entity against the
57
+ guessing.
58
+
59
+ A destination whose **last render attempt threw** reads as such, rather
60
+ than as current:
61
+
62
+ ```
63
+ rendered 2026-08-22 21:52:24 → /page-a.html [STALE: last render attempt failed]
64
+ failed 2026-08-22 21:52:25 The partial partials/btn could not be found
65
+ 3 attempts since 2026-08-22 21:52:24
66
+ partial /layouts/partials/btn.hbs b6ab7ccc [TARGET DELETED SINCE]
67
+ would re-render — the last render attempt failed and nothing has changed since
68
+ ```
69
+
70
+ `[TARGET DELETED SINCE]` is the same distinction one level down: an edge's
71
+ binding is what it resolved to *when recorded*, so a target deleted
72
+ afterwards still shows an id and a hash and reads as healthy unless the
73
+ catalog is asked. Note that `--explain` compares the CATALOG's entity against the
58
74
  snapshot: if you have edited a file and not yet built, the verdict is
59
75
  `source differs from the catalog` — the edit has not been imported yet, so
60
76
  there is nothing to attribute. Build, then ask.
@@ -102,15 +118,39 @@ under this flag, so stdout parses whole.
102
118
  npx mikser --json | jq '.summary'
103
119
  ```
104
120
 
105
- Four buckets, and the distinction between them is the point:
121
+ Five buckets, and the distinction between them is the point:
106
122
 
107
123
  | Bucket | Meaning |
108
124
  | --- | --- |
109
- | `rendered` | the render ran, with a `reason` per entity |
125
+ | `rendered` | the render ran and the output moved, with a `reason` per entity |
110
126
  | `skipped` | the manifest decided not to render, with a `reason` |
111
127
  | `unchanged` | the render ran and produced bytes identical to what was already on disk |
128
+ | `errors` | the render ran and **threw** — with `id`, `destination`, `error`, `layout` |
112
129
  | `gated` | a count — the source was unchanged, so no render was ever scheduled |
113
130
 
131
+ A failed render appears in `errors` and **not** in `rendered`: that bucket
132
+ means the output moved, and a throw writes nothing. The previous good bytes
133
+ stay on disk, which is what makes a failed render survivable — and also
134
+ what makes it invisible without this bucket.
135
+
136
+ A failed render is **retried on every subsequent build** until it
137
+ succeeds, reported as `reason: "retry-failed"`. Nothing else would schedule
138
+ it — the entity's own source has not changed, so it is gated at import, and
139
+ the manifest still describes the last good render — so without the retry a
140
+ build after a failing one reported success with the site still stale.
141
+
142
+ Retries are unbounded and deliberately noisy: a page that fails every cycle
143
+ is failing every cycle. `errors[].since` and `errors[].attempts` are what
144
+ make that readable — "broke just now" and "broken for an hour" are
145
+ different situations. The marker clears itself on the first success.
146
+
147
+ **A one-shot build with render errors exits `1`.** That is the signal a CI
148
+ gate needs, because `mikser && mikser --verify` would otherwise pass a
149
+ build in which nothing rendered: `--verify` compares the output against the
150
+ manifest, both of which still describe the last good render. Watch mode
151
+ keeps running — a failed render there is a state to fix on the next cycle,
152
+ not a reason to tear down the watcher.
153
+
114
154
  `reason` is a stable vocabulary you can assert on: `unchanged`,
115
155
  `never-rendered`, `inputs-changed`, `ref-changed`, `query-matched`,
116
156
  `cache-disabled`, `postprocessor`, `force`, `no-manifest`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.23.0",
3
+ "version": "9.25.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,11 @@ 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)
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
+ //
378
382
  // Same detail at debug, for tailing a watch run. One line per
379
383
  // render is too much for a build's normal output — the counts
380
384
  // are the summary and --json is the record — but when you are
@@ -522,11 +526,39 @@ export async function setup(options) {
522
526
  await updateEntry({ id, output: entry.output, deps: edges })
523
527
  }
524
528
 
529
+ // A success clears whatever was recorded about this
530
+ // destination failing, so the retry set drains itself.
531
+ runtime.manifest?.clearFailure(entity)
532
+ reportRendered(entity, decision.reason, decision)
525
533
  logger.debug('Rendered: [%s] %s → %s', options.renderer, entity.name || entity.id, entity.destination)
526
534
  } catch (err) {
527
535
  if (!signal.aborted) {
528
536
  await updateEntry({ id, output: { success: false } })
529
- logger.error('Render error: %s%s %s', entity.id, formatErrorContext(entity, err, runtime.options), err.message)
537
+ const context = formatErrorContext(entity, err, runtime.options)
538
+ logger.error('Render error: %s%s %s', entity.id, context, err.message)
539
+ // The machine-readable half of that same line. Without
540
+ // it a build that fails every page reports rendered:N,
541
+ // warnings:0 and exits 0 — three clean signals and only
542
+ // the human log knowing otherwise.
543
+ // Durable, so the next cycle knows to try again and
544
+ // --explain stops calling this destination current.
545
+ runtime.manifest?.recordFailure(entity, {
546
+ error: err.message,
547
+ context: context.trim() || null,
548
+ at: Date.now(),
549
+ })
550
+ const failure = runtime.manifest?.failureAt(entity.id, entity.destination)
551
+ reportError(entity, err, {
552
+ renderer: options.renderer ?? null,
553
+ layout: entity.layout?.id ?? null,
554
+ context: context.trim() || null,
555
+ // When it STARTED failing, and how many attempts.
556
+ // "broke just now" and "broken since 14:02" are
557
+ // different situations and the reader needs to
558
+ // tell them apart at a glance.
559
+ since: failure?.firstFailedAt ?? null,
560
+ attempts: failure?.attempts ?? 1,
561
+ })
530
562
  }
531
563
  logger.debug('Render canceled')
532
564
  }
@@ -537,8 +569,12 @@ export async function setup(options) {
537
569
  concurrency: runtime.options.threads,
538
570
  signal
539
571
  })
540
- renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped)
572
+ // Jobs minus skips minus THROWS. Counting a failed render as
573
+ // rendered is the same overstatement the report used to make.
574
+ const failed = renderErrorCount()
575
+ renderJobs.size && logger.info('Rendered: %d', renderJobs.size - skipped - failed)
541
576
  skipped && logger.info('Manifest skipped: %d', skipped)
577
+ failed && logger.error('Render errors: %d', failed)
542
578
  })
543
579
 
544
580
  onBeforePostprocess(async (signal) => {
@@ -765,11 +801,31 @@ export async function setup(options) {
765
801
  }
766
802
  }
767
803
  }
768
- logger.notice('Mikser completed')
804
+ // A cycle with failed renders is not a completed build, and the word
805
+ // people read is this one.
806
+ const failed = renderErrorCount()
807
+ if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
808
+ else logger.notice('Mikser completed')
809
+
769
810
  // After the cycle, and only under --json. stdout has been kept clear
770
811
  // for exactly this (the logger writes to stderr under --json), so the
771
812
  // document is the only thing on it and can be piped to jq.
772
813
  emitReport()
814
+
815
+ // Non-zero for a one-shot build, so `mikser && mikser --verify` cannot
816
+ // pass with every page in the site stale. `exitCode` rather than
817
+ // process.exit so the report above is flushed and shutdown runs.
818
+ //
819
+ // Watch mode keeps going: a failed render there is a state to fix in
820
+ // the next cycle, not a reason to tear down the watcher. That is also
821
+ // what makes the failure self-concealing in watch — the errors scroll
822
+ // past between two green builds — so the exit code is precisely the
823
+ // signal CI needs and the one interactive use must not have.
824
+ //
825
+ // 1, not 2: --verify already uses 2 for output drift and --explain 3
826
+ // for not-found. "The build ran and some renders threw" is its own
827
+ // thing.
828
+ if (failed && !runtime.options.watch) process.exitCode = 1
773
829
  })
774
830
 
775
831
  onCancelled(async () => {
package/src/explain.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // Follows --verify's shape: report and exit, no build phases run.
11
11
  import { inputHashOf, inputPartsOf, diffInputParts, lookupKeys, checksum as fileChecksum } from './utils.js'
12
12
  import { filterKey } from './track.js'
13
- import { findEntity, findEntities } from './catalog.js'
13
+ import { findEntity, findEntities, findById } from './catalog.js'
14
14
  import runtime from './runtime.js'
15
15
 
16
16
  const shortHash = (h) => (h ? String(h).slice(0, 8) : null)
@@ -118,6 +118,11 @@ export async function explain(reference) {
118
118
  const currentHash = inputHashOf(entity)
119
119
  const currentParts = inputPartsOf(entity)
120
120
  const queryMatches = await countQueryMatches(snapshots)
121
+ // Checked before the hash comparison in the verdict: a destination whose
122
+ // last attempt threw will re-render regardless of what the hashes say.
123
+ const failedSnapshots = snapshots
124
+ .map(snap => runtime.manifest?.failureAt?.(entity.id, snap.destination))
125
+ .filter(Boolean)
121
126
 
122
127
  // The catalog is as of the LAST BUILD. If the file has been edited since,
123
128
  // nothing here knows it yet — the hashes would all agree and the verdict
@@ -186,6 +191,21 @@ export async function explain(reference) {
186
191
  // The single most useful field: does this entity's current hash
187
192
  // match what it was last rendered at?
188
193
  stale: snap.inputHash !== currentHash,
194
+ // The last render ATTEMPT for this destination, if it threw.
195
+ // Without this, a destination whose render is failing reports
196
+ // `[current]` and `would be SKIPPED` — both true of the recorded
197
+ // state, and together the wrong answer to the only question
198
+ // --explain is ever asked.
199
+ failed: (() => {
200
+ const f = runtime.manifest?.failureAt?.(entity.id, snap.destination)
201
+ if (!f) return null
202
+ return {
203
+ error: f.error,
204
+ since: when(f.firstFailedAt),
205
+ lastAttempt: when(f.lastFailedAt),
206
+ attempts: f.attempts ?? 1,
207
+ }
208
+ })(),
189
209
  // WHICH input moved, not merely that one did. This is the whole
190
210
  // question behind "why did this re-render" — answering it from
191
211
  // the recorded parts costs nothing, and not answering it sends
@@ -220,6 +240,14 @@ export async function explain(reference) {
220
240
  bound: entry.targetIds?.length ? entry.targetIds
221
241
  : entry.targetId ? [entry.targetId]
222
242
  : [],
243
+ // `bound` is what the edge resolved to WHEN RECORDED.
244
+ // A target deleted since then still shows an id and a
245
+ // hash, which reads as healthy — so check the catalog
246
+ // rather than describing the record as if it were
247
+ // current.
248
+ gone: (entry.targetIds?.length ? entry.targetIds
249
+ : entry.targetId ? [entry.targetId]
250
+ : []).filter(id => !findById(id)),
223
251
  hash: shortHash(entry.hash),
224
252
  }),
225
253
  })),
@@ -231,6 +259,9 @@ export async function explain(reference) {
231
259
  + '(Some plugins compose a checksum from several files, so verify before concluding.)'
232
260
  : snapshots.length === 0
233
261
  ? 'never rendered — no manifest snapshot. Either it has no layout, or its layout produced no destination.'
262
+ : failedSnapshots.length
263
+ ? `would re-render — the last render attempt failed and nothing has changed since `
264
+ + `(${failedSnapshots[0].error})`
234
265
  : snapshots.some(s => s.inputHash !== currentHash)
235
266
  ? renderVerdict(snapshots, currentHash, currentParts)
236
267
  : 'would be SKIPPED — input hash unchanged. A dependency in refClosure changing is the only other thing that would re-render it.',
@@ -285,7 +316,15 @@ export function formatExplain(report) {
285
316
  }
286
317
  for (const r of report.renders) {
287
318
  row('rendered', `${r.renderedAt ?? 'unknown'} → ${r.destination}`
288
- + (r.stale ? ' [STALE: input hash moved since]' : ' [current]'))
319
+ + (r.failed ? ' [STALE: last render attempt failed]'
320
+ : r.stale ? ' [STALE: input hash moved since]'
321
+ : ' [current]'))
322
+ if (r.failed) {
323
+ out.push(` failed ${r.failed.lastAttempt} ${r.failed.error}`)
324
+ if (r.failed.attempts > 1) {
325
+ out.push(` ${r.failed.attempts} attempts since ${r.failed.since}`)
326
+ }
327
+ }
289
328
  const closure = r.refClosure
290
329
  row('refClosure', `${closure.length} edge${closure.length === 1 ? '' : 's'}`)
291
330
  for (const e of closure) {
@@ -308,7 +347,8 @@ export function formatExplain(report) {
308
347
  const bound = e.bound?.length
309
348
  ? (e.bound.length === 1 && e.bound[0] === e.target ? '' : ` → ${e.bound.join(', ')}`)
310
349
  : ' [UNRESOLVED — nothing answers to this name]'
311
- out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}`)
350
+ const gone = e.gone?.length ? ' [TARGET DELETED SINCE]' : ''
351
+ out.push(` ${e.kind.padEnd(10)} ${e.target}${bound}${e.hash ? ` ${e.hash}` : ''}${gone}`)
312
352
  }
313
353
  }
314
354
 
package/src/manifest.js CHANGED
@@ -85,6 +85,38 @@ export const SNAPSHOTS_SCHEMA = `
85
85
  `
86
86
  registerSchema('mikser_snapshots', SNAPSHOTS_SCHEMA)
87
87
 
88
+ // Failed render attempts, durably.
89
+ //
90
+ // A failed render writes no snapshot — deliberately, so the last good bytes
91
+ // survive — which leaves nothing anywhere saying the attempt happened. The
92
+ // consequences all follow from that one absence: the entity is gated at
93
+ // import next cycle (its own source did not change), so it is never
94
+ // re-dispatched; the manifest still describes the last good render, so
95
+ // --verify is clean; and --explain reports `[current]` and `would be
96
+ // SKIPPED` for a page whose render is throwing — the one tool whose job is
97
+ // "why is this not rebuilding", answering "because there is nothing to do".
98
+ //
99
+ // Keyed by (id, destination) like snapshots, but a SEPARATE table because a
100
+ // render that has never once succeeded has no snapshot to hang a column on.
101
+ //
102
+ // firstFailedAt is kept distinct from lastFailedAt so a report can say
103
+ // "since 14:02" — the difference between "this broke just now" and "this has
104
+ // been broken for an hour" is most of what a reader wants.
105
+ export const FAILURES_SCHEMA = `
106
+ CREATE TABLE IF NOT EXISTS mikser_failures (
107
+ id TEXT NOT NULL,
108
+ destination TEXT NOT NULL,
109
+ error TEXT,
110
+ context TEXT,
111
+ firstFailedAt INTEGER,
112
+ lastFailedAt INTEGER,
113
+ attempts INTEGER NOT NULL DEFAULT 1,
114
+ PRIMARY KEY (id, destination)
115
+ ) WITHOUT ROWID;
116
+ CREATE INDEX IF NOT EXISTS idx_mikser_failures_id ON mikser_failures(id);
117
+ `
118
+ registerSchema('mikser_failures', FAILURES_SCHEMA)
119
+
88
120
  // Module-level DB handle + prepared statements for the lifecycle
89
121
  // integration. Tests build their own via `createManifest(db)` —
90
122
  // the lifecycle hook below grabs useDatabase() and stashes the
@@ -252,6 +284,32 @@ async function hashOutputFile(destination) {
252
284
  export function createManifest(db) {
253
285
  if (!db) throw new Error('createManifest: db is required')
254
286
 
287
+ const stmtRecordFailure = db.prepare(`
288
+ INSERT INTO mikser_failures
289
+ (id, destination, error, context, firstFailedAt, lastFailedAt, attempts)
290
+ VALUES (@id, @destination, @error, @context, @at, @at, 1)
291
+ ON CONFLICT(id, destination) DO UPDATE SET
292
+ error = excluded.error,
293
+ context = excluded.context,
294
+ lastFailedAt = excluded.lastFailedAt,
295
+ attempts = mikser_failures.attempts + 1
296
+ `)
297
+ const stmtClearFailure = db.prepare(`
298
+ DELETE FROM mikser_failures WHERE id = ? AND destination = ?
299
+ `)
300
+ const stmtFailuresFor = db.prepare(`
301
+ SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
302
+ FROM mikser_failures WHERE id = ?
303
+ `)
304
+ const stmtFailureAt = db.prepare(`
305
+ SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
306
+ FROM mikser_failures WHERE id = ? AND destination = ?
307
+ `)
308
+ const stmtAllFailures = db.prepare(`
309
+ SELECT id, destination, error, context, firstFailedAt, lastFailedAt, attempts
310
+ FROM mikser_failures
311
+ `)
312
+
255
313
  const stmtLookupById = db.prepare(`
256
314
  SELECT id, destination, inputHash, inputParts, outputHash, refClosure, renderedAt, parent
257
315
  FROM mikser_snapshots WHERE id = ? ORDER BY destination
@@ -359,6 +417,8 @@ export function createManifest(db) {
359
417
  // query-matched an entity matching a recorded query mutated
360
418
  // cache-disabled meta.cache === false
361
419
  // force --force: skip nothing, ask nothing
420
+ // retry-failed the last render attempt for this destination
421
+ // threw; nothing else would reschedule it
362
422
  skipDecision(entity, mutatedRefs, currentHashes, mutatedEntities) {
363
423
  // --force means "ignore what you think you know". THREE gates
364
424
  // can stop a render — source.js's import checksum gate,
@@ -373,6 +433,31 @@ export function createManifest(db) {
373
433
  // no-match warning tells the operator to use it.
374
434
  if (runtime.options?.force) return { skip: false, reason: 'force' }
375
435
  if (entity?.meta?.cache === false) return { skip: false, reason: 'cache-disabled' }
436
+ // A render whose last attempt threw must be retried, and checked
437
+ // before anything else: every other branch reasons about hashes,
438
+ // and the hashes are consistent — the entity did not change, the
439
+ // snapshot still describes the last GOOD render. Consistency is
440
+ // exactly why the failure is invisible without this.
441
+ //
442
+ // Retried unbounded, and noisily. A page that fails every cycle IS
443
+ // failing every cycle, and a build that stops mentioning it after
444
+ // the third attempt is making the same trade as reporting
445
+ // `rendered: 12, exit 0`. What makes it tolerable is presentation
446
+ // — one line per failing entity, and `since` on the report so a
447
+ // reader can tell "broke just now" from "broken since 14:02" —
448
+ // not backoff.
449
+ const failure = this.failureAt(entity?.id, entity?.destination)
450
+ if (failure) {
451
+ return {
452
+ skip: false,
453
+ reason: 'retry-failed',
454
+ failure: {
455
+ error: failure.error,
456
+ since: failure.firstFailedAt ?? null,
457
+ attempts: failure.attempts ?? 1,
458
+ },
459
+ }
460
+ }
376
461
  const snapshot = this.lookup(entity)
377
462
  if (!snapshot?.inputHash) return { skip: false, reason: 'never-rendered' }
378
463
  if (inputHashOf(entity) !== snapshot.inputHash) {
@@ -483,6 +568,51 @@ export function createManifest(db) {
483
568
  return { skip: true, reason: 'unchanged' }
484
569
  },
485
570
 
571
+ // Record that a render attempt threw. `at` is passed in rather than
572
+ // read from the clock here so the caller owns the timestamp.
573
+ recordFailure(entity, { error, context, at }) {
574
+ if (!entity?.id || !entity?.destination) return
575
+ stmtRecordFailure.run({
576
+ id: entity.id,
577
+ destination: entity.destination,
578
+ error: error ?? null,
579
+ context: context ?? null,
580
+ at: at ?? Date.now(),
581
+ })
582
+ },
583
+
584
+ // A render succeeded, so whatever was recorded about it failing is
585
+ // no longer true. Called on every success, not only after a failure —
586
+ // it is a cheap DELETE and forgetting it would strand the marker.
587
+ clearFailure(entity) {
588
+ if (!entity?.id || !entity?.destination) return
589
+ stmtClearFailure.run(entity.id, entity.destination)
590
+ },
591
+
592
+ // Every recorded failure for an entity, across destinations.
593
+ failuresFor(id) {
594
+ return id ? stmtFailuresFor.all(id) : []
595
+ },
596
+
597
+ // One, at a known destination.
598
+ failureAt(id, destination) {
599
+ if (!id || !destination) return null
600
+ return stmtFailureAt.get(id, destination) ?? null
601
+ },
602
+
603
+ // Every entity id with a recorded failure. The dispatch set a
604
+ // task-production plugin unions in so a failed render is retried:
605
+ // the entity's own source has not changed, so nothing else will
606
+ // schedule it, and going quiet about a page that will not build is
607
+ // the failure mode this whole area exists to avoid.
608
+ failedIds() {
609
+ return [...new Set(stmtAllFailures.all().map(row => row.id))]
610
+ },
611
+
612
+ allFailures() {
613
+ return stmtAllFailures.all()
614
+ },
615
+
486
616
  // Record a successful render. Single INSERT OR REPLACE.
487
617
  record(entity, deps) {
488
618
  stmtUpsert.run(snapToRow(buildSnapshot(entity, deps)))
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
 
@@ -77,15 +77,53 @@ export function reportWarning(code, fields = {}) {
77
77
  store().warnings.push({ code, ...fields })
78
78
  }
79
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
+
80
114
  export function buildReport() {
81
115
  const report = store()
82
116
  return {
83
117
  rendered: report.rendered,
84
118
  skipped: report.skipped,
85
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(),
86
123
  warnings: report.warnings,
87
124
  summary: {
88
125
  rendered: report.rendered.length,
126
+ errors: errorStore().length,
89
127
  // Of those renders, how many produced bytes identical to
90
128
  // what was already on disk — see reportUnchanged.
91
129
  unchanged: report.unchanged.length,