mikser-io 9.88.0 → 9.93.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.
@@ -133,8 +133,65 @@ anything.
133
133
  Add `--json` for the whole report as a machine-readable object. Exits `3`
134
134
  when the entity cannot be found.
135
135
 
136
+ Every coded warning and fault prints its code on the console, in brackets
137
+ before the sentence:
138
+
139
+ ```
140
+ 🟡 [output-drift] Output changed with unchanged inputs: /documents/index.html → /index.html
141
+ 🟡 [reference-wrong-base] Points at the wrong place: ...
142
+ ```
143
+
144
+ The code is added, not substituted: the sentence is what a person reads and the
145
+ code is what a script matches. Both come from the same field on the same
146
+ record, so the console and the report cannot drift apart — which they had. The
147
+ report called it `output-drift` and the console said "produced different bytes
148
+ from the same inputs", so anything watching the build had to match prose, the
149
+ half that is free to be reworded, while the stable identifier appeared only in
150
+ this document.
151
+
152
+ `--audit-output --json` writes its verdict as a document — `verdict`,
153
+ `snapshots`, a `summary` of the five counts, and the entries behind each. It
154
+ reported through the log and wrote nothing to stdout before, which made the
155
+ check a deploy script most wants the one it could not read. Exit codes are
156
+ unchanged: 2 for FAIL.
157
+
136
158
  ### `--json`
137
159
 
160
+ The report carries `timings`: what each phase COST, in milliseconds, for the
161
+ cycle being reported.
162
+
163
+ ```
164
+ "timings": {
165
+ "total": 114, // the phases below, summed — this cycle
166
+ "processUptime": 415.5, // how long THIS PROCESS has been alive
167
+ "phases": [
168
+ { "phase": "load", "ms": 80.5, "calls": 1 },
169
+ { "phase": "import", "ms": 17.5, "calls": 1 }
170
+ ]
171
+ }
172
+ ```
173
+
174
+ Ordered slowest first, so a diff between two versions leads with what moved.
175
+ This exists because the report said what was *done* and never what it *took*: a
176
+ preset fan-out that scanned the whole catalog every cycle shipped and ran for
177
+ four releases, with byte-identical output and every check passing, while the
178
+ build was twice as slow. An upgrade check could prove no bytes moved and could
179
+ not prove the speed had not halved.
180
+
181
+ `finishedAt - startedAt` is not this — it spans the processing cycle only,
182
+ measured at 12ms of a 443ms run, leaving boot, the config graph, the plugin
183
+ load and the import scan with no number anywhere.
184
+
185
+ `processUptime` is deliberately not called "elapsed". For a one-shot it is the
186
+ whole run, and subtracting `total` gives what the phases do not cover — module
187
+ loading and engine construction, which happen before any hook. For a build
188
+ forwarded to a watcher it is the *instance's* age, which says nothing about the
189
+ build.
190
+
191
+ Milliseconds because the console's progress lines round to whole seconds, so a
192
+ phase that doubled from 400ms to 800ms prints `0s` both times — and they are
193
+ suppressed off a TTY, which is every CI run and every `--json` invocation.
194
+
138
195
  A build report on stdout, as JSON. Logs and the banner move to stderr
139
196
  under this flag, so stdout parses whole.
140
197
 
@@ -948,6 +1005,15 @@ surfaces that turn silence into a statement:
948
1005
  sitemap — and warns under `asset-missing`. Where both can see the
949
1006
  same file, the output scan reports it and this one stays quiet.
950
1007
 
1008
+ A `url()` inside a **custom property** is resolved from the stylesheet that
1009
+ substitutes it, for both of these questions and for the climb check below.
1010
+ It is substituted where it is *used*, so the page that declared it in a
1011
+ style attribute is not its base — a bundle at `styles/` is. Every emitted
1012
+ stylesheet is a candidate, since which one substitutes the variable is not
1013
+ knowable from the bytes; a clean resolution settles it, one that resolves
1014
+ only by climbing is a genuine over-deep against *that* base, and a url that
1015
+ resolves from none of them is reported as broken.
1016
+
951
1017
  A url under the assets folder gets a cause rather than a guess. Whether a
952
1018
  preset covers a file is decided by `match` against the entity id, which is
953
1019
  not visible from a url, so the assets plugin is asked and the answer is one
@@ -971,7 +1037,11 @@ surfaces that turn silence into a statement:
971
1037
  - **A link that works only by accident** — a url with one `..` too many
972
1038
  still loads, because a browser discards a climb above the origin root
973
1039
  rather than failing. Reported under `reference-over-deep`, separately
974
- from the outright failures, and grouped by how far each climbed:
1040
+ from the outright failures, and grouped by how far each climbed. A custom
1041
+ property is judged against its stylesheet here too: judged against the page
1042
+ it produced a climb report for references that are correct, with a reason
1043
+ — "a browser discards the extra `..`" — that was false for them, which
1044
+ sends the reader to fix a base that is right:
975
1045
  - **One url, or several climbing different distances** — each is a
976
1046
  latent 404, working today and broken as soon as the same markup
977
1047
  renders one level deeper.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.88.0",
3
+ "version": "9.93.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -125,7 +125,7 @@ async function reportBrokenReferences(logger) {
125
125
  // because the preset does not cover that file. Confidently naming the
126
126
  // wrong cause is worse than naming none, so a real answer wins over the
127
127
  // heuristic wherever there is one.
128
- const explain = runtime.state?.assets?.explainMissing
128
+ const explain = runtime.engine?.assets?.explainMissing
129
129
  const reasons = new Map()
130
130
  if (explain) {
131
131
  // Every broken entry, not the ones that happen to sort first. The cap
@@ -261,7 +261,7 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
261
261
  // every page on the site, and a thousand lines of it buries whatever else
262
262
  // the build said.
263
263
  const SHOWN = 10
264
- const explain = runtime.state?.assets?.explainMissing
264
+ const explain = runtime.engine?.assets?.explainMissing
265
265
  for (const [destination, ids] of missing.slice(0, SHOWN)) {
266
266
  // Same question, same answer, wherever the symptom surfaces. This path
267
267
  // sees urls that never reach an html file at all — a sitemap, a feed —
@@ -381,6 +381,33 @@ export async function runReportOnly(request = {}) {
381
381
  await runtime.manifest.auditOutput()
382
382
  const total = runtime.manifest.size()
383
383
 
384
+ // The document, under --json.
385
+ //
386
+ // This is the check a deploy script wants and it was the one that
387
+ // could not be read programmatically: it reported through the log and
388
+ // wrote nothing to stdout, so `--audit-output --json` returned zero
389
+ // bytes and exit 0 or 2 — a caller had to parse prose, or trust the
390
+ // exit code and lose every detail behind it.
391
+ //
392
+ // Emitted here and returned immediately, because the log lines below
393
+ // are the human rendering of exactly this and printing both would put
394
+ // the prose on stderr for no one.
395
+ if (json) {
396
+ process.stdout.write(JSON.stringify({
397
+ verdict,
398
+ snapshots: total,
399
+ summary: {
400
+ missing: missing.length,
401
+ mismatched: mismatched.length,
402
+ unverifiable: unverifiable.length,
403
+ orphaned: orphaned.length,
404
+ collisions: collisions.length,
405
+ },
406
+ missing, mismatched, unverifiable, orphaned, collisions,
407
+ }, null, 2) + '\n')
408
+ return verdict === 'FAIL' ? 2 : 0
409
+ }
410
+
384
411
  for (const e of missing) logger.error('Missing: %s (entity %s)', e.destination, e.id)
385
412
  for (const e of mismatched) logger.error('Mismatched: %s (entity %s)%s', e.destination, e.id,
386
413
  e.writtenBy ? ` — the bytes on disk are ${e.writtenBy}'s` : '')
@@ -495,6 +522,58 @@ export async function setup(options) {
495
522
  .option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
496
523
  attachServerCliOptions(runtime.engine.commander)
497
524
 
525
+ // Which check answers which question.
526
+ //
527
+ // The boundaries between these are real and none of them is redundant,
528
+ // but that knowledge lived only in commit messages — which are the
529
+ // wrong place for it, because nobody reads them before they need the
530
+ // answer. A caller choosing how to verify a build was left to learn it
531
+ // by experiment, one release at a time.
532
+ //
533
+ // On --help rather than in a doc alone: this is read at the moment the
534
+ // question is being asked.
535
+ runtime.engine.commander.addHelpText('after', `
536
+ Which check answers which question:
537
+
538
+ Did this build write what it thought it wrote?
539
+ --audit-output compares the output on disk against the manifest.
540
+ Catches tampering, truncation and a file deleted
541
+ behind the build's back. Structurally CANNOT catch
542
+ a render that changed, because a render rewrites
543
+ its own snapshot — afterwards the new bytes agree
544
+ with themselves.
545
+
546
+ Did an upgrade change what a render produces?
547
+ --force re-renders everything and reports output-drift:
548
+ same inputs, different bytes. This is the one that
549
+ catches a renderer, helper or dependency moving
550
+ under the build. It also reconciles deletions.
551
+
552
+ Do the URLs in the output point at anything?
553
+ (runs every build) reads the emitted html and css and resolves each
554
+ reference the way a browser would. Reports what
555
+ resolves to nothing, what a preset never produced,
556
+ and what loads only because a browser discarded a
557
+ climb above the site root.
558
+
559
+ Are the derivatives current?
560
+ --render-presets [n] re-derives every preset, or one by name, without
561
+ touching anything else. For a preset edited without
562
+ bumping its revision.
563
+
564
+ Start over.
565
+ --clear removes the output folder and reopens the cache.
566
+ A boot operation: it is refused while an instance
567
+ is running in the same folder.
568
+
569
+ What did this build do, and cost?
570
+ --json the whole report as one document on stdout, with
571
+ every warning carrying a stable code, and per-phase
572
+ timings in milliseconds so two releases can be
573
+ compared.
574
+
575
+ The full version, with what each code means: docs/diagnostics.md`)
576
+
498
577
  Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
499
578
  // runtime.options.info gates the progress bar — gauge stays
500
579
  // silent in --debug/--trace modes because logs are voluminous
package/src/instance.js CHANGED
@@ -37,6 +37,7 @@ import runtime from './runtime.js'
37
37
  import { onLoaded } from './lifecycle.js'
38
38
  import { renderErrorCount } from './report.js'
39
39
  import { runReportOnly } from './engine.js'
40
+ import { emitReport } from './report.js'
40
41
 
41
42
  // Where the endpoint lives.
42
43
  //
@@ -429,7 +430,20 @@ async function serveBuild(socket, request, logger) {
429
430
  // rebuild() — the same call a one-shot makes. Nothing here
430
431
  // re-implements it: the contract is what decides whether it
431
432
  // writes, so setting the contract is the whole fix.
432
- await withRequestOutput(request, () => runtime.rebuild())
433
+ await withRequestOutput(request, async () => {
434
+ // Suppressed for the duration of the cycle and emitted once
435
+ // after it: any cycle already in flight when this request
436
+ // arrived would otherwise write a second document into a
437
+ // stream that promises one.
438
+ runtime.state ??= {}
439
+ runtime.state.suppressReport = true
440
+ try {
441
+ await runtime.rebuild()
442
+ } finally {
443
+ runtime.state.suppressReport = false
444
+ }
445
+ emitReport()
446
+ })
433
447
  } finally {
434
448
  runtime.options.renderPresets = priorRenderPresets
435
449
  }
package/src/logger.js CHANGED
@@ -178,9 +178,28 @@ export function createMikserLogger(level = 'info') {
178
178
  customPrettifiers: {
179
179
  level: () => '',
180
180
  },
181
+ // The CODE, on the line a person is reading.
182
+ //
183
+ // A finding had two names: the report called it `output-drift` and the
184
+ // console said "produced different bytes from the same inputs", and
185
+ // nothing on the console contained the string someone reading
186
+ // docs/diagnostics.md would grep for. So a script watching the build
187
+ // matched prose — the half that is free to be reworded — while the
188
+ // stable identifier existed only in a document that script was not
189
+ // reading.
190
+ //
191
+ // Printed here because this is the one function every line passes
192
+ // through, so the code cannot be attached to the record and missing
193
+ // from the terminal: they come from the same field. `hideObject` still
194
+ // suppresses the rest of the structured fields, which belong in the
195
+ // report and would turn a one-line warning into a block.
196
+ //
197
+ // Only where there is a code. An ordinary info line has no identity to
198
+ // print and gains nothing from a bracket.
181
199
  messageFormat: (log, key) => {
182
200
  const icon = ICONS[LEVEL_LABELS[log.level]] ?? ''
183
- return icon + (log[key] ?? '')
201
+ const code = typeof log.code === 'string' && log.code ? `[${log.code}] ` : ''
202
+ return icon + code + (log[key] ?? '')
184
203
  },
185
204
  })
186
205
 
@@ -405,16 +405,26 @@ export function assets(options = {}) {
405
405
  runtime.state.assets = {
406
406
  presets: {},
407
407
  assetsMap: {},
408
- // The engine asks this when a linked derivative is not on disk.
409
- // Published on state rather than imported, so the engine keeps
410
- // knowing nothing about presets and says nothing when this plugin
411
- // is not loaded.
412
- explainMissing,
413
408
  assetsFolder: options.outputFolder
414
409
  ? path.join(options.outputFolder, assetsName)
415
410
  : assetsName,
416
411
  }
417
412
 
413
+ // The engine asks this when a linked derivative is not on disk.
414
+ //
415
+ // On runtime.engine, NOT on runtime.state. State is structured-cloned
416
+ // into render and postprocess workers, and a function cannot be
417
+ // cloned: putting it there made every worker-dispatched render fail
418
+ // with DataCloneError on any build that loads this plugin, reported as
419
+ // a render error whose message was this function's own source. Options
420
+ // already had workerSafeOptions for exactly this hazard; state had no
421
+ // equivalent, so state is the wrong place to publish anything callable.
422
+ //
423
+ // Still published rather than imported, so the engine keeps knowing
424
+ // nothing about presets and says nothing when this plugin is absent.
425
+ runtime.engine ??= {}
426
+ runtime.engine.assets = { explainMissing }
427
+
418
428
  runtime.options.presets = options.presetsFolder || collection
419
429
  runtime.options.presetsFolder = path.join(runtime.options.workingFolder, runtime.options.presets)
420
430
  logger.debug('Presets folder: %s', runtime.options.presetsFolder)
package/src/references.js CHANGED
@@ -120,9 +120,22 @@ export function extractReferences(rawSource) {
120
120
  export function resolveUrl(pageDir, url, { root = '' } = {}) {
121
121
  const clean = url.split('#')[0].split('?')[0]
122
122
  const absolute = clean.startsWith('/')
123
- const segments = clean.split('/').filter(s => s !== '' && s !== '.')
123
+ // `.` is not a directory to climb out of, in either half of this.
124
+ //
125
+ // The url segments have always dropped it and the page's directory did
126
+ // not, which mattered for exactly one page on a site: the one at its root,
127
+ // where path.dirname gives '.'. That lone '.' counted as a real segment,
128
+ // so a `..` popped it instead of flooring, and the reference resolved to
129
+ // the same file a browser reaches while reporting floored: 0.
130
+ //
131
+ // The target was right and the verdict was wrong, which is why nothing
132
+ // noticed: a root page's over-deep url was silently exempt on a
133
+ // single-root build, and reported on a multi-site one where the site root
134
+ // makes pageDir genuinely empty. Same markup, two answers.
135
+ const segment = (s) => s !== '' && s !== '.'
136
+ const segments = clean.split('/').filter(segment)
124
137
 
125
- const parts = absolute ? [] : pageDir.split('/').filter(Boolean)
138
+ const parts = absolute ? [] : pageDir.split('/').filter(segment)
126
139
  // How FAR above the root it climbed, not merely that it did. When every
127
140
  // over-deep url on a site climbs the same distance, that is one base
128
141
  // mismatch reported once — not N findings, which is how a real signal gets
@@ -198,21 +211,46 @@ export async function checkReferences(outputFolder, { siteRoots = [] } = {}) {
198
211
  const pageDir = path.dirname(file).slice(root.length).replace(/^\/+/, '')
199
212
 
200
213
  for (const { url, customProperty } of extractReferences(source)) {
201
- const { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
214
+ let { target, overDeep, floored } = resolveUrl(pageDir, url, { root })
202
215
  checked++
203
216
 
204
- if (!exists.has(target)) {
205
- exists.set(target, existsSync(path.join(outputFolder, target)))
217
+ const resolves = (candidate) => {
218
+ if (!exists.has(candidate)) {
219
+ exists.set(candidate, existsSync(path.join(outputFolder, candidate)))
220
+ }
221
+ return exists.get(candidate)
206
222
  }
223
+ resolves(target)
207
224
 
208
- // Resolved from a stylesheet instead, and correct there.
209
- if (!exists.get(target) && customProperty && styleBases.some((base) => {
210
- const from = resolveUrl(base.dir, url, { root: base.root }).target
211
- if (!exists.has(from)) {
212
- exists.set(from, existsSync(path.join(outputFolder, from)))
225
+ // A custom property is resolved from the STYLESHEET, for BOTH
226
+ // questions this check asks.
227
+ //
228
+ // The first version of this only replaced the verdict when the
229
+ // page-relative target was missing, which left the climb check
230
+ // answering from a base it had already been told was the wrong
231
+ // one. Where the discarded `..` happened to land on a real file,
232
+ // the reference was reported as climbing above the site root, and
233
+ // the explanation said it loads "because a browser discards the
234
+ // extra `..`" — when it loads because it is correct where it is
235
+ // actually read from. A wrong reason on a correct reference is
236
+ // worse than the original false positive: it sends the reader to
237
+ // fix a base that is right.
238
+ //
239
+ // Clean beats floored beats missing. A stylesheet that resolves it
240
+ // without climbing settles it; one that resolves it only by
241
+ // climbing is a genuine over-deep against THAT base; and if none
242
+ // resolves it the page-relative answer stands and the url is
243
+ // simply broken.
244
+ if (customProperty && styleBases.length) {
245
+ let best = null
246
+ for (const base of styleBases) {
247
+ const from = resolveUrl(base.dir, url, { root: base.root })
248
+ if (!resolves(from.target)) continue
249
+ if (!from.overDeep) { best = from; break }
250
+ best ??= from
213
251
  }
214
- return exists.get(from)
215
- })) continue
252
+ if (best) ({ target, overDeep, floored } = best)
253
+ }
216
254
 
217
255
  // Broken outranks over-deep: a url that resolves nowhere is the
218
256
  // failure, and adding that it is also one level too deep is noise.
package/src/report.js CHANGED
@@ -119,6 +119,13 @@ export function resetReport() {
119
119
  // time was not re-checked, and claiming otherwise would be the kind of
120
120
  // completeness this codebase keeps having to walk back.
121
121
  runtime.state.assetUse = new Map()
122
+ // Per cycle, like everything else here. Accumulating for the life of the
123
+ // process would make a watcher's second build report what the instance has
124
+ // spent since boot — a number that only grows, and never answers "what did
125
+ // this cycle cost", which is the question someone comparing builds is
126
+ // asking. The boot phases therefore appear in the first cycle's report and
127
+ // not in later ones, which is what actually happened.
128
+ runtime.state.timings = {}
122
129
  }
123
130
 
124
131
  // Published on the runtime so runtime.js can start a fresh cycle for a
@@ -433,6 +440,32 @@ function invalidation() {
433
440
  }
434
441
  }
435
442
 
443
+ // Phase durations, rounded to a tenth of a millisecond and ordered slowest
444
+ // first — so a diff between two versions leads with what moved.
445
+ function phaseTimings() {
446
+ const timings = runtime.state?.timings ?? {}
447
+ const entries = Object.entries(timings)
448
+ .map(([phase, { ms, calls }]) => ({ phase, ms: Math.round(ms * 10) / 10, calls }))
449
+ .sort((a, b) => b.ms - a.ms)
450
+ return {
451
+ // This cycle, summed. The phases below add up to it.
452
+ total: Math.round(entries.reduce((sum, e) => sum + e.ms, 0) * 10) / 10,
453
+ // How long THIS PROCESS has been alive, which is not the same thing
454
+ // and is named so nobody reads it as one.
455
+ //
456
+ // For a one-shot it is the whole run, and subtracting `total` gives
457
+ // what the phases do not cover: module loading and engine
458
+ // construction, which happen before any hook and on a small site are
459
+ // most of the elapsed time. For a build forwarded to a watcher it is
460
+ // the INSTANCE's age — minutes or days — and says nothing about the
461
+ // build. Reported either way, because a caller that knows which case
462
+ // it is in can use it, and a caller that does not would have been
463
+ // misled by a name like "elapsed".
464
+ processUptime: Math.round(process.uptime() * 1000 * 10) / 10,
465
+ phases: entries,
466
+ }
467
+ }
468
+
436
469
  export function buildReport() {
437
470
  const report = store()
438
471
  const cycle = runtime.state?.cycle
@@ -442,6 +475,17 @@ export function buildReport() {
442
475
  cycleId: cycle?.id ?? null,
443
476
  startedAt: cycle?.startedAt ?? null,
444
477
  finishedAt: cycle?.finishedAt ?? null,
478
+ // What the run COST, per phase, in milliseconds.
479
+ //
480
+ // `finishedAt - startedAt` is not this. It spans the processing cycle
481
+ // only — measured on a small site, 12ms of a 443ms run — so boot, the
482
+ // config graph, the plugin load and the import scan, which is most of
483
+ // where a regression lands, had no number anywhere. A caller could
484
+ // prove an upgrade moved no bytes and not that it halved the speed.
485
+ //
486
+ // Milliseconds, because the console's whole-second rounding reports a
487
+ // phase that doubled from 400ms to 800ms as "0s" both times.
488
+ ...(runtime.state?.timings ? { timings: phaseTimings() } : {}),
445
489
  rendered: report.rendered,
446
490
  skipped: report.skipped,
447
491
  unchanged: report.unchanged,
@@ -494,5 +538,15 @@ export function emitReport() {
494
538
  // questions: a server with the mcp plugin records so a tool can read it,
495
539
  // and must not write a document to stdout.
496
540
  if (!runtime.options?.json) return
541
+ // Not while a forwarded request owns the stream.
542
+ //
543
+ // The control socket opens in onLoaded, before the instance's own first
544
+ // build has finished, so a client can connect into that window — turn on
545
+ // the json contract, and have the INSTANCE's startup cycle write its
546
+ // report into the client's stdout, followed by the requested build's.
547
+ // Two documents in a stream promising one, which is the same broken
548
+ // contract as emitting none. The request emits exactly once, after the
549
+ // cycle it asked for.
550
+ if (runtime.state?.suppressReport) return
497
551
  process.stdout.write(JSON.stringify(buildReport(), null, 2) + '\n')
498
552
  }
package/src/runtime.js CHANGED
@@ -62,18 +62,52 @@ const runtime = {
62
62
  completed: [],
63
63
  },
64
64
 
65
+ // What each phase COST, not only what it did.
66
+ //
67
+ // A build report says what was done and never what it took, so a
68
+ // regression is invisible to the one caller that would catch it. The
69
+ // preset fan-out shipped scanning the whole catalog every cycle and ran
70
+ // for four releases before anyone happened to time a rebuild by hand:
71
+ // output was byte-identical, every check passed, and the build was twice
72
+ // as slow.
73
+ //
74
+ // Recorded here because this is the one place every phase passes through,
75
+ // so nothing has to be instrumented plugin by plugin and no phase can be
76
+ // added later without being counted. The console's progress lines are not
77
+ // this: they are per-collection, rounded to whole seconds — so a phase
78
+ // that doubled from 400ms to 800ms prints "0s" either way — and they are
79
+ // suppressed entirely off a TTY, which is every CI run and every --json
80
+ // invocation, meaning the numbers did not exist where a script could read
81
+ // them.
82
+ //
83
+ // Accumulated per phase rather than assigned, because a phase runs more
84
+ // than once in a watch process and a cycle can re-enter one.
85
+ recordPhase(phaseName, ms) {
86
+ if (!phaseName) return
87
+ this.state ??= {}
88
+ const timings = (this.state.timings ??= {})
89
+ const entry = (timings[phaseName] ??= { ms: 0, calls: 0 })
90
+ entry.ms += ms
91
+ entry.calls++
92
+ },
93
+
65
94
  async callHooks(hooks, signal, phaseName) {
66
95
  // Lifecycle methods below pass `phaseName` so introspection
67
96
  // tools (mikser-io-mcp's mikser://lifecycle resource, debuggers)
68
97
  // can see what's running. Direct callers (tests, plugins driving
69
98
  // sub-flows) can omit it.
70
99
  if (phaseName) this.phase = phaseName
100
+ const started = performance.now()
71
101
  try {
72
102
  for (let hook of hooks) {
73
103
  if (signal?.aborted) throw new AbortError()
74
104
  await hook(signal)
75
105
  }
76
106
  } finally {
107
+ // In `finally`, so a phase that threw still reports what it spent
108
+ // before throwing — which is exactly the phase someone is about to
109
+ // go looking at.
110
+ this.recordPhase(phaseName, performance.now() - started)
77
111
  if (phaseName) this.phase = null
78
112
  }
79
113
  },