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.
- package/docs/diagnostics.md +51 -7
- package/package.json +1 -1
- package/src/engine.js +57 -5
- package/src/manifest.js +45 -5
- package/src/report.js +53 -5
package/docs/diagnostics.md
CHANGED
|
@@ -102,21 +102,60 @@ under this flag, so stdout parses whole.
|
|
|
102
102
|
npx mikser --json | jq '.summary'
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
440
|
-
if (currentHash !== entry.hash) return
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
...(
|
|
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,
|