mikser-io 9.23.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,15 +102,28 @@ 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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.23.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,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,22 @@ export async function setup(options) {
522
526
  await updateEntry({ id, output: entry.output, deps: edges })
523
527
  }
524
528
 
529
+ reportRendered(entity, decision.reason, decision)
525
530
  logger.debug('Rendered: [%s] %s → %s', options.renderer, entity.name || entity.id, entity.destination)
526
531
  } catch (err) {
527
532
  if (!signal.aborted) {
528
533
  await updateEntry({ id, output: { success: false } })
529
- 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
+ })
530
545
  }
531
546
  logger.debug('Render canceled')
532
547
  }
@@ -537,8 +552,12 @@ export async function setup(options) {
537
552
  concurrency: runtime.options.threads,
538
553
  signal
539
554
  })
540
- 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)
541
559
  skipped && logger.info('Manifest skipped: %d', skipped)
560
+ failed && logger.error('Render errors: %d', failed)
542
561
  })
543
562
 
544
563
  onBeforePostprocess(async (signal) => {
@@ -765,11 +784,31 @@ export async function setup(options) {
765
784
  }
766
785
  }
767
786
  }
768
- 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
+
769
793
  // After the cycle, and only under --json. stdout has been kept clear
770
794
  // for exactly this (the logger writes to stderr under --json), so the
771
795
  // document is the only thing on it and can be piped to jq.
772
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
773
812
  })
774
813
 
775
814
  onCancelled(async () => {
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,