mikser-io 9.89.0 → 9.95.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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.89.0",
3
+ "version": "9.95.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
  //
@@ -359,12 +360,24 @@ function refuseClear(socket, request) {
359
360
  return true
360
361
  }
361
362
 
363
+ // Which process to restart.
364
+ //
365
+ // "Restart it" is only actionable if you know which `it` — and the machine
366
+ // that hits this is the machine running several instances from several
367
+ // projects, because that is what makes a stale config likely in the first
368
+ // place. Without the pid, finding it meant walking /proc by cwd.
369
+ //
370
+ // The instance answers with its OWN pid, which is the one fact a client cannot
371
+ // work out: it knows the folder it asked about, not who is holding it. The
372
+ // wrong-config refusal beside this one already names both sides; this one
373
+ // named neither.
362
374
  function refuseStale(socket, movedFile) {
363
375
  frame(socket, {
364
376
  type: 'refused',
365
377
  reason: `this instance's config changed on disk since it started (${movedFile}).`,
366
- detail: 'It is still running the old one. Restart it, and this command will reach an instance that '
367
- + 'matches what you edited.',
378
+ detail: `It is still running the old one pid ${process.pid}, started in `
379
+ + `${runtime.options.workingFolder}. Restart that process and this command will reach an `
380
+ + 'instance that matches what you edited.',
368
381
  })
369
382
  }
370
383
 
@@ -429,7 +442,20 @@ async function serveBuild(socket, request, logger) {
429
442
  // rebuild() — the same call a one-shot makes. Nothing here
430
443
  // re-implements it: the contract is what decides whether it
431
444
  // writes, so setting the contract is the whole fix.
432
- await withRequestOutput(request, () => runtime.rebuild())
445
+ await withRequestOutput(request, async () => {
446
+ // Suppressed for the duration of the cycle and emitted once
447
+ // after it: any cycle already in flight when this request
448
+ // arrived would otherwise write a second document into a
449
+ // stream that promises one.
450
+ runtime.state ??= {}
451
+ runtime.state.suppressReport = true
452
+ try {
453
+ await runtime.rebuild()
454
+ } finally {
455
+ runtime.state.suppressReport = false
456
+ }
457
+ emitReport()
458
+ })
433
459
  } finally {
434
460
  runtime.options.renderPresets = priorRenderPresets
435
461
  }
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
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
  },