mikser-io 9.97.0 → 9.101.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/app.js CHANGED
@@ -68,6 +68,13 @@ function locate(argv) {
68
68
  // was started without the flag. So the contract has to travel
69
69
  // with the request or it is not honoured at all.
70
70
  json: has('--json'),
71
+ // Travels for the same reason clear and renderPresets do: it
72
+ // changes what the CYCLE does, and the instance was started
73
+ // without it. A forwarded --force silently did not force — it
74
+ // rebuilt whatever the gates let through, which on a settled tree
75
+ // is nothing, and a caller asking for a full re-render got a no-op
76
+ // reported as success.
77
+ force: has('--force', '-f'),
71
78
  renderPresets: has('--render-presets') ? (value('--render-presets') ?? true) : undefined }
72
79
 
73
80
  return {
@@ -171,6 +171,17 @@ cycle being reported.
171
171
  }
172
172
  ```
173
173
 
174
+ `plugins` breaks a phase down by who spent it:
175
+
176
+ ```
177
+ "plugins": [ { "phase": "finalized", "plugin": "lighthouse", "ms": 6381.1, "calls": 1 } ]
178
+ ```
179
+
180
+ Inside the phases, not beside them — a plugin's time is part of its phase's, so
181
+ the phase totals still add up to `total`. Without it, `finalized` is one number
182
+ covering the reference check, schemas, lint and any audit, and attributing a
183
+ regression to one of them takes a separate measurement each.
184
+
174
185
  Ordered slowest first, so a diff between two versions leads with what moved.
175
186
  This exists because the report said what was *done* and never what it *took*: a
176
187
  preset fan-out that scanned the whole catalog every cycle shipped and ran for
@@ -385,9 +396,21 @@ the content: an upgraded renderer, a changed helper, a dependency that shifted
385
396
  under the build.
386
397
 
387
398
  Only entities that actually rendered can drift, so an ordinary build reports
388
- on what moved. Under `--force` everything re-renders with unchanged inputs,
389
- which makes it a full sweep **`mikser --force` after a package upgrade is
390
- the regression check**.
399
+ on what moved. Under `--force` every ENTITY re-renders with unchanged inputs,
400
+ which makes it the check for a renderer, helper or dependency that shifted
401
+ **`mikser --force` after a package upgrade is the regression check for
402
+ rendered output**.
403
+
404
+ It is not a full sweep, and calling it one was wrong. Derivatives are outside
405
+ it: an asset is re-derived when its source changes or its preset's `revision`
406
+ moves, and `--force` is neither — so on a site with image presets, `--force`
407
+ re-renders the documents and touches no derivative at all. A sharp upgrade,
408
+ which is exactly the kind of dependency shift this paragraph promises to
409
+ catch, is invisible to it.
410
+
411
+ Use `--render-presets` for that half, and `--fingerprint` before and after to
412
+ compare both halves at once — it hashes the derivatives too, which is the
413
+ thing `find out -type f` cannot do.
391
414
 
392
415
  ### The rest, briefly
393
416
 
package/index.js CHANGED
@@ -5,6 +5,7 @@ export * from './src/auth.js'
5
5
  export * from './src/roles.js'
6
6
  export * from './src/inventory.js'
7
7
  export * from './src/report.js'
8
+ export * from './src/cli.js'
8
9
  // The diagnostics behind --explain. Exported so a transport — the MCP tool
9
10
  // surface, the api plugin's routes — can serve the same structured report the
10
11
  // CLI formats, rather than each one reimplementing the question.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.97.0",
3
+ "version": "9.101.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/cli.js ADDED
@@ -0,0 +1,139 @@
1
+ // CLI options a plugin adds.
2
+ //
3
+ // An option is a contract between a person and a build, and a plugin is where
4
+ // half the build lives — so a plugin that cannot add one has to be reached
5
+ // some other way: an environment variable, a config key that means "do the
6
+ // expensive thing", a wrapper script. All three were tried downstream and all
7
+ // three are worse in the same way: they are invisible to `--help`, they cannot
8
+ // be refused when misspelled, and they do not appear beside the checks they
9
+ // belong next to.
10
+ //
11
+ // The reason it was not possible is an ordering fact rather than a decision.
12
+ // The engine parses argv in onInitialize, and the CONFIG — which is where the
13
+ // plugins are named — is not read until onLoad. At the moment the first parse
14
+ // runs, the engine genuinely does not know what options exist.
15
+ //
16
+ // So the parse happens in two stages, and the second one is the real one:
17
+ //
18
+ // 1. onInitialize — core's own options, tolerating unknowns, because the
19
+ // table is knowingly incomplete. Enough to find the config and the
20
+ // folders.
21
+ // 2. after the load phase — every plugin has been constructed and has had
22
+ // its chance to declare, so the table is complete. argv is parsed again
23
+ // against it, and NOW an unknown option is an error.
24
+ //
25
+ // The refusal that has to survive this is the one from 9.81.0: a misspelled
26
+ // flag must never be silently ignored. It is not weakened, only moved to the
27
+ // point where "unknown" can actually be decided.
28
+
29
+ import runtime from './runtime.js'
30
+
31
+ // What plugins declared, for the message when something is not recognised and
32
+ // for anyone asking what a build's option table actually contains.
33
+ const declared = new Map()
34
+
35
+ // Declare a CLI option from inside a plugin.
36
+ //
37
+ // Call it while the plugin is being constructed — that is during the load
38
+ // phase, before the second parse. Declaring later is not an error but the
39
+ // option will not be parsed, so it says so rather than doing nothing.
40
+ //
41
+ // cliOption('--lighthouse', 'audit the built pages with Lighthouse, and exit 0')
42
+ //
43
+ // Reads back from `runtime.options` under commander's usual camel-cased name.
44
+ export function cliOption(flags, description, defaultValue) {
45
+ const commander = runtime.engine?.commander
46
+ if (!commander) {
47
+ throw new Error('cliOption: no commander yet — call it while the plugin is being constructed')
48
+ }
49
+ if (runtime.engine.cliSealed) {
50
+ throw new Error(
51
+ `cliOption(${JSON.stringify(flags)}): the option table was already parsed. `
52
+ + 'Declare options while the plugin is constructed (the load phase), not afterwards — '
53
+ + 'a later one would never be read and the flag would look ignored.')
54
+ }
55
+ if (declared.has(flags)) return declared.get(flags)
56
+ const option = defaultValue === undefined
57
+ ? commander.option(flags, description)
58
+ : commander.option(flags, description, defaultValue)
59
+ declared.set(flags, option)
60
+ return option
61
+ }
62
+
63
+ // Every option a plugin added, in declaration order.
64
+ export function pluginCliOptions() {
65
+ return [...declared.keys()]
66
+ }
67
+
68
+ // Stage two: the table is complete, so parse for real.
69
+ //
70
+ // Called once, after the load phase. Merges what the complete table yields
71
+ // over what stage one produced — a plugin's option lands here for the first
72
+ // time, and core's own options parse identically both times.
73
+ export function completeCliParse() {
74
+ const commander = runtime.engine?.commander
75
+ if (!commander || runtime.engine.cliSealed) return
76
+ runtime.engine.cliSealed = true
77
+
78
+ // Always, even when no plugin declared anything.
79
+ //
80
+ // Stage one tolerates an unknown option AND the excess argument it then
81
+ // looks like, so it cannot be the stage that judges argv — left to it,
82
+ // `mikser --bogus-flag` came back "too many arguments. Expected 0
83
+ // arguments but got 1", which is commander describing its own internal
84
+ // reading rather than the mistake a person made. Skipping stage two when
85
+ // no plugin declared would leave exactly that message on most builds.
86
+ //
87
+ // Strict here: every option any part of this build understands is
88
+ // registered, so anything left over is a misspelling, and saying which one
89
+ // is the whole point of the refusal.
90
+ commander.allowUnknownOption(false).allowExcessArguments(false)
91
+
92
+ // Help, now that the table is complete — including every option the
93
+ // project's own plugins declared, which is the whole reason it waited.
94
+ if (runtime.engine.helpRequested) {
95
+ commander.outputHelp()
96
+ process.exit(0)
97
+ }
98
+
99
+ commander.parse(process.argv)
100
+ Object.assign(runtime.options, commander.opts())
101
+ }
102
+
103
+ // The plugin-declared options carried by a client's argv.
104
+ //
105
+ // A forwarded build is answered by an INSTANCE, which parsed its own argv
106
+ // (`--watch`) and never saw the client's. Core's own request options — json,
107
+ // clear, renderPresets — each travel explicitly for exactly this reason. A
108
+ // plugin option has to as well, or `mikser --lighthouse` works with nothing
109
+ // listening and silently does nothing with a watcher up, which is the failure
110
+ // this whole surface exists to remove.
111
+ //
112
+ // Read from the argv the client sent, against the table the INSTANCE has:
113
+ // both processes load the same config, so the instance knows every option the
114
+ // client could legitimately have passed.
115
+ export function pluginOptionsFrom(argv) {
116
+ const commander = runtime.engine?.commander
117
+ if (!commander || !declared.size || !Array.isArray(argv)) return {}
118
+ // A throwaway parse: `parseOptions` reads without applying, so the
119
+ // instance's own options are untouched by asking what a client sent.
120
+ let parsed
121
+ try {
122
+ const probe = commander.createCommand()
123
+ for (const flags of declared.keys()) probe.option(flags, '')
124
+ probe.allowUnknownOption(true).allowExcessArguments(true)
125
+ probe.parse(argv, { from: 'user' })
126
+ parsed = probe.opts()
127
+ } catch {
128
+ return {}
129
+ }
130
+ // Only what a plugin declared. Core's options travel on the request
131
+ // itself, and re-applying them from argv here would give two sources for
132
+ // one answer.
133
+ const names = new Set(Object.keys(parsed))
134
+ const values = {}
135
+ for (const name of names) {
136
+ if (parsed[name] !== undefined) values[name] = parsed[name]
137
+ }
138
+ return values
139
+ }
package/src/engine.js CHANGED
@@ -13,6 +13,7 @@ import { OPERATION, TASKS } from './constants.js'
13
13
  import { changeExtension, formatErrorContext, projectMeta, lookupKeys, siteRootFor } from './utils.js'
14
14
  import { reportRendered, reportSkipped, reportError, renderErrorCount, emitReport, finishCycle, reportAssetUse, assetUse } from './report.js'
15
15
  import { checkReferences } from './references.js'
16
+ import { completeCliParse } from './cli.js'
16
17
  import { fingerprintOutputs } from './fingerprint.js'
17
18
  import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
18
19
  import { registerBuiltinTools } from './builtin-tools.js'
@@ -579,11 +580,16 @@ Which check answers which question:
579
580
  with themselves.
580
581
 
581
582
  Did an upgrade change what a render produces?
582
- --force re-renders everything and reports output-drift:
583
+ --force re-renders every ENTITY and reports output-drift:
583
584
  same inputs, different bytes. This is the one that
584
585
  catches a renderer, helper or dependency moving
585
586
  under the build. It also reconciles deletions.
586
587
 
588
+ NOT derivatives. Assets are re-derived on a preset
589
+ revision or a source change, and --force is
590
+ neither — so a sharp upgrade is invisible to it.
591
+ Use --render-presets for that half.
592
+
587
593
  Do the URLs in the output point at anything?
588
594
  (runs every build) reads the emitted html and css and resolves each
589
595
  reference the way a browser would. Reports what
@@ -610,6 +616,20 @@ Which check answers which question:
610
616
  tree, stable across runs. Take it before and after
611
617
  an upgrade and compare.
612
618
 
619
+ Is this build one a person is waiting on?
620
+ (automatic) runtime.options.requested is true for a build a
621
+ client asked for — including one forwarded to a
622
+ running instance — and false for a watcher's own
623
+ cycle. An expensive check reads it to stand down in
624
+ the dev loop without standing down forever: an
625
+ instance is ALWAYS in watch mode, so watch alone
626
+ answers the wrong question.
627
+
628
+ Is the page fast, and can it be read?
629
+ --lighthouse from mikser-io-lighthouse, if installed. A plugin
630
+ declares its own options now, and they appear here
631
+ beside core's — see cliOption().
632
+
613
633
  What did this build do, and cost?
614
634
  --json the whole report as one document on stdout, with
615
635
  every warning carrying a stable code, and per-phase
@@ -618,7 +638,34 @@ Which check answers which question:
618
638
 
619
639
  The full version, with what each code means: docs/diagnostics.md`)
620
640
 
621
- Object.assign(runtime.options, options || runtime.engine.commander.parse(process.argv).opts())
641
+ // Stage one of two. The config names the plugins and is not read
642
+ // until onLoad, so at this moment the engine does not yet know every
643
+ // option this build understands — a plugin's is not registered. Left
644
+ // strict, `mikser --lighthouse` would be rejected before the plugin
645
+ // that defines it had been constructed.
646
+ //
647
+ // Tolerated here and decided in stage two, where the table is complete
648
+ // — see completeCliParse(). The 9.81.0 refusal is not weakened, it is
649
+ // moved to the point where "unknown" can be answered.
650
+ runtime.engine.commander.allowUnknownOption(true).allowExcessArguments(true)
651
+
652
+ // --help is answered in stage two, for the same reason the parse is.
653
+ //
654
+ // Commander prints help and exits the moment it sees the flag, which
655
+ // in stage one is before any plugin has been constructed — so the help
656
+ // would list core's options and silently omit every option the
657
+ // project's own plugins add. A help text that is missing the flag you
658
+ // are looking for is worse than a slower one.
659
+ //
660
+ // Held out of this parse and answered after the table is complete. It
661
+ // does mean `--help` now loads the config, which is the honest cost of
662
+ // describing THIS project's mikser rather than a generic one.
663
+ const helpFlags = ['--help', '-h']
664
+ runtime.engine.helpRequested = process.argv.some(arg => helpFlags.includes(arg))
665
+ const argv = runtime.engine.helpRequested
666
+ ? process.argv.filter(arg => !helpFlags.includes(arg))
667
+ : process.argv
668
+ Object.assign(runtime.options, options || runtime.engine.commander.parse(argv).opts())
622
669
  // runtime.options.info gates the progress bar — gauge stays
623
670
  // silent in --debug/--trace modes because logs are voluminous
624
671
  // there and a bar on top would just be noise.
@@ -764,6 +811,15 @@ The full version, with what each code means: docs/diagnostics.md`)
764
811
  })
765
812
 
766
813
  onLoaded(async () => {
814
+ // Stage two of the parse, before anything reads an option.
815
+ //
816
+ // Every plugin has been constructed by now and has had its chance to
817
+ // declare, so the table is complete and argv is parsed against all of
818
+ // it. This engine hook is registered when the module is imported, so
819
+ // it runs ahead of every plugin's own onLoaded — which is what makes
820
+ // it safe for a plugin to read its own option there.
821
+ completeCliParse()
822
+
767
823
  const logger = useLogger()
768
824
  logger.debug(runtime.options, 'Mikser options')
769
825
 
@@ -1473,10 +1529,10 @@ The full version, with what each code means: docs/diagnostics.md`)
1473
1529
  const brokenTargets = await reportBrokenReferences(useLogger())
1474
1530
  await reportMissingAssets(useLogger(), brokenTargets)
1475
1531
 
1476
- // After the cycle, and only under --json. stdout has been kept clear
1477
- // for exactly this (the logger writes to stderr under --json), so the
1478
- // document is the only thing on it and can be piped to jq.
1479
- emitReport()
1532
+ // The report is NOT emitted here any more. This hook is registered
1533
+ // when the engine is imported, so it runs first among finalized hooks
1534
+ // and every plugin's findings would land after the document was
1535
+ // written. runtime.finalize() emits it once every hook has run.
1480
1536
 
1481
1537
  // Non-zero for a one-shot build, so `mikser && mikser --audit-output` cannot
1482
1538
  // pass with every page in the site stale. `exitCode` rather than
package/src/instance.js CHANGED
@@ -38,6 +38,7 @@ import { onLoaded } from './lifecycle.js'
38
38
  import { renderErrorCount } from './report.js'
39
39
  import { runReportOnly } from './engine.js'
40
40
  import { emitReport } from './report.js'
41
+ import { pluginOptionsFrom } from './cli.js'
41
42
 
42
43
  // Where the endpoint lives.
43
44
  //
@@ -399,10 +400,34 @@ function refuseStale(socket, movedFile) {
399
400
  // not put the instance into json mode for everyone.
400
401
  async function withRequestOutput(request, run) {
401
402
  const prior = {}
402
- for (const key of ['json', 'tool', 'tools']) {
403
+ // `requested` is not a flag the client sent — it is the fact that a client
404
+ // sent anything at all.
405
+ //
406
+ // An instance is always in watch or server mode, so a plugin asking "am I
407
+ // in the dev loop" gets `yes` for a build a PERSON just typed and is
408
+ // waiting on. An expensive check that stands down in the dev loop then
409
+ // stands down forever on a project whose documented model is a watcher
410
+ // always up — it never runs, and says nothing, which is the failure this
411
+ // whole surface exists to remove.
412
+ //
413
+ // Set for the duration of the request and restored with the rest, so a
414
+ // watcher's own cycle before or after is unaffected.
415
+ request = { ...request, requested: true }
416
+ for (const key of ['json', 'tool', 'tools', 'requested']) {
403
417
  prior[key] = runtime.options[key]
404
418
  if (request[key]) runtime.options[key] = request[key]
405
419
  }
420
+
421
+ // Whatever the client's argv said about a PLUGIN's options.
422
+ //
423
+ // The instance parsed its own argv and never saw the client's, so a flag a
424
+ // plugin declared worked with nothing listening and did nothing with a
425
+ // watcher up. Applied and restored with the rest of the request's
426
+ // contract, so a watcher's own cycle before or after is unaffected.
427
+ for (const [name, value] of Object.entries(pluginOptionsFrom(request.argv))) {
428
+ if (!(name in prior)) prior[name] = runtime.options[name]
429
+ runtime.options[name] = value
430
+ }
406
431
  try {
407
432
  return await run()
408
433
  } finally {
@@ -437,6 +462,10 @@ async function serveBuild(socket, request, logger) {
437
462
  // rebuild.
438
463
  const priorRenderPresets = runtime.options.renderPresets
439
464
  if (request.renderPresets !== undefined) runtime.options.renderPresets = request.renderPresets
465
+ // Applied for THIS cycle only, like renderPresets. An instance left in
466
+ // force mode would re-render the whole site on every later save.
467
+ const priorForce = runtime.options.force
468
+ if (request.force) runtime.options.force = true
440
469
  try {
441
470
  // The report is emitted by the cycle itself, from inside
442
471
  // rebuild() — the same call a one-shot makes. Nothing here
@@ -458,6 +487,7 @@ async function serveBuild(socket, request, logger) {
458
487
  })
459
488
  } finally {
460
489
  runtime.options.renderPresets = priorRenderPresets
490
+ runtime.options.force = priorForce
461
491
  }
462
492
 
463
493
  // From the render-error count, NOT from process.exitCode.
package/src/plugins.js CHANGED
@@ -110,11 +110,35 @@ onLoad(() => {
110
110
  if (registeredPostprocessors) parts.push(`${registeredPostprocessors} postprocessor${registeredPostprocessors === 1 ? '' : 's'}`)
111
111
  logger.info('Loading plugins: %s', parts.join(', '))
112
112
 
113
- for (const factoryReturn of factoryEntries) {
113
+ // Which plugin registered which hook, so a phase can be broken down.
114
+ //
115
+ // `timings` said `finalized: 19400ms` and stopped there — and finalized is
116
+ // where the reference check, schemas, lint and lighthouse all live, so the
117
+ // one number said nothing about which of them cost it. Downstream that
118
+ // took three separate measurements to attribute a 465ms lint pass.
119
+ //
120
+ // Labelled retroactively, because a plugin's identity is not knowable
121
+ // until its factory RETURNS: the entry in the plugins array is already the
122
+ // factory's result, so there is no name to read going in, and the
123
+ // descriptor with `collection` on it only exists coming out. So the hooks
124
+ // are diffed across the call and tagged with what the call produced.
125
+ const hookNames = Object.keys(runtime.hooks)
126
+ for (const [index, factoryReturn] of factoryEntries.entries()) {
127
+ const before = new Map(hookNames.map(name => [name, runtime.hooks[name].length]))
128
+ let descriptor
114
129
  try {
115
- factoryReturn(core)
130
+ descriptor = factoryReturn(core)
116
131
  } catch (err) {
117
132
  logger.error('Plugin factory threw on registration: %s', err.message)
133
+ continue
134
+ }
135
+ const label = descriptor?.collection ?? descriptor?.type ?? `plugin-${index + 1}`
136
+ for (const name of hookNames) {
137
+ for (const hook of runtime.hooks[name].slice(before.get(name))) {
138
+ // A plugin registering the same function twice keeps its first
139
+ // label rather than being renamed by a later registration.
140
+ if (typeof hook === 'function' && !hook.mikserPlugin) hook.mikserPlugin = label
141
+ }
118
142
  }
119
143
  }
120
144
  })
package/src/report.js CHANGED
@@ -126,6 +126,8 @@ export function resetReport() {
126
126
  // asking. The boot phases therefore appear in the first cycle's report and
127
127
  // not in later ones, which is what actually happened.
128
128
  runtime.state.timings = {}
129
+ runtime.state.pluginTimings = {}
130
+ runtime.state.activity = { rendered: 0, changed: 0 }
129
131
  }
130
132
 
131
133
  // Published on the runtime so runtime.js can start a fresh cycle for a
@@ -133,6 +135,10 @@ export function resetReport() {
133
135
  // an import here would close the cycle.
134
136
  runtime.resetReport = resetReport
135
137
 
138
+ // And so it can emit the report AFTER every finalized hook, for the same
139
+ // reason and by the same route. See runtime.finalize().
140
+ runtime.emitReport = emitReport
141
+
136
142
  // End of a cycle: stamp it, file it, and wake anyone waiting on it.
137
143
  export function finishCycle() {
138
144
  if (!runtime.state?.cycle || runtime.state.cycle.finishedAt) return
@@ -190,6 +196,7 @@ export function reportWipe(cause, detail = {}) {
190
196
  // One source whose bytes moved. The complement of reportGated: between them
191
197
  // every file the engine looked at is accounted for.
192
198
  export function reportChanged(id) {
199
+ if (id) activity().changed++
193
200
  if (!reportWanted() || !id) return
194
201
  const store = changedStore()
195
202
  store.count++
@@ -273,6 +280,7 @@ export function reportGated(count = 1) {
273
280
  // `matched`, `dependency` each mean something specific, and a single
274
281
  // polymorphic key would push the type switch onto every consumer.
275
282
  export function reportRendered(entity, reason, decision = {}) {
283
+ activity().rendered++
276
284
  if (!reportWanted()) return
277
285
  store().rendered.push({
278
286
  id: entity?.id,
@@ -421,6 +429,45 @@ export function renderErrorCount() {
421
429
  return errorStore().length
422
430
  }
423
431
 
432
+ // A bare count of what the cycle did, kept whether or not anyone is reading
433
+ // the report.
434
+ //
435
+ // The rendered/skipped/unchanged ARRAYS are only recorded when something can
436
+ // read them — one entry per entity per cycle is not free, which is why `gated`
437
+ // was already a bare count. So a check asking "did this cycle do anything"
438
+ // cannot read those arrays: without --json they are empty, and a cold build
439
+ // that rendered the whole site looks identical to a no-op. That is exactly the
440
+ // mistake the first version of cycleMovedNothing() made, and it reported
441
+ // "nothing moved" on a build that had just rendered.
442
+ //
443
+ // Two integers cost nothing and are always true.
444
+ function activity() {
445
+ runtime.state ??= {}
446
+ runtime.state.activity ??= { rendered: 0, changed: 0 }
447
+ return runtime.state.activity
448
+ }
449
+
450
+ // Did this cycle move anything?
451
+ //
452
+ // For a check that reads the OUTPUT — a linter, an audit — a cycle that
453
+ // rendered nothing and changed nothing produced the same bytes the last one
454
+ // did, so running again spends time to reprint what was already said.
455
+ // Downstream that was measured: a lint pass costing 465ms of every no-op
456
+ // rebuild, and a Lighthouse audit costing 19 SECONDS of one.
457
+ //
458
+ // Both halves matter. `rendered` alone is not enough: files() emits by
459
+ // symlinking rather than rendering, so a new static file changes the output
460
+ // with nothing rendered. `changed` covers that — it counts the sources whose
461
+ // bytes moved this cycle.
462
+ //
463
+ // A plugin skipping on this must SAY so. "Nothing to report" and "did not run"
464
+ // are indistinguishable otherwise, which is the property this codebase keeps
465
+ // having to remove.
466
+ export function cycleMovedNothing() {
467
+ const { rendered, changed } = activity()
468
+ return rendered === 0 && changed === 0
469
+ }
470
+
424
471
  // The cause, and enough detail to act on it.
425
472
  //
426
473
  // A wipe outranks changed sources: when the cache went, everything is a
@@ -447,6 +494,12 @@ function phaseTimings() {
447
494
  const entries = Object.entries(timings)
448
495
  .map(([phase, { ms, calls }]) => ({ phase, ms: Math.round(ms * 10) / 10, calls }))
449
496
  .sort((a, b) => b.ms - a.ms)
497
+ // Inside the phases, not beside them: a plugin's time is part of its
498
+ // phase's, so this is a breakdown rather than an addition.
499
+ const byPlugin = Object.values(runtime.state?.pluginTimings ?? {})
500
+ .map(({ phase, plugin, ms, calls }) => ({ phase, plugin, ms: Math.round(ms * 10) / 10, calls }))
501
+ .sort((a, b) => b.ms - a.ms)
502
+
450
503
  return {
451
504
  // This cycle, summed. The phases below add up to it.
452
505
  total: Math.round(entries.reduce((sum, e) => sum + e.ms, 0) * 10) / 10,
@@ -463,6 +516,7 @@ function phaseTimings() {
463
516
  // misled by a name like "elapsed".
464
517
  processUptime: Math.round(process.uptime() * 1000 * 10) / 10,
465
518
  phases: entries,
519
+ ...(byPlugin.length ? { plugins: byPlugin } : {}),
466
520
  }
467
521
  }
468
522
 
package/src/runtime.js CHANGED
@@ -82,6 +82,19 @@ const runtime = {
82
82
  //
83
83
  // Accumulated per phase rather than assigned, because a phase runs more
84
84
  // than once in a watch process and a cycle can re-enter one.
85
+ // Per plugin within a phase. Kept apart from `phases` so the phase totals
86
+ // still add up to `total` — a plugin's time is INSIDE its phase, not
87
+ // beside it, and adding both would double-count the run.
88
+ recordPluginPhase(phaseName, plugin, ms) {
89
+ if (!phaseName || !plugin) return
90
+ this.state ??= {}
91
+ const byPlugin = (this.state.pluginTimings ??= {})
92
+ const key = `${phaseName}:${plugin}`
93
+ const entry = (byPlugin[key] ??= { phase: phaseName, plugin, ms: 0, calls: 0 })
94
+ entry.ms += ms
95
+ entry.calls++
96
+ },
97
+
85
98
  recordPhase(phaseName, ms) {
86
99
  if (!phaseName) return
87
100
  this.state ??= {}
@@ -101,7 +114,14 @@ const runtime = {
101
114
  try {
102
115
  for (let hook of hooks) {
103
116
  if (signal?.aborted) throw new AbortError()
117
+ // Timed per hook when it belongs to a plugin, so a phase can
118
+ // be broken down by who spent it. `finalized` alone is where
119
+ // the reference check, schemas, lint and an audit all live.
120
+ const hookStarted = hook.mikserPlugin ? performance.now() : 0
104
121
  await hook(signal)
122
+ if (hook.mikserPlugin) {
123
+ this.recordPluginPhase(phaseName, hook.mikserPlugin, performance.now() - hookStarted)
124
+ }
105
125
  }
106
126
  } finally {
107
127
  // In `finally`, so a phase that threw still reports what it spent
@@ -213,6 +233,23 @@ const runtime = {
213
233
  async finalize(signal) {
214
234
  await this.callHooks(this.hooks.finalize, signal, 'finalize')
215
235
  await this.callHooks(this.hooks.finalized, signal, 'finalized')
236
+
237
+ // The report is the LAST thing that happens, after every hook that
238
+ // could still add to it.
239
+ //
240
+ // It used to be emitted from the engine's own onFinalized — which is
241
+ // registered when the engine module is imported, so it ran FIRST among
242
+ // finalized hooks and every plugin's findings landed after the
243
+ // document was already written. A plugin could print a warning to the
244
+ // console and have it absent from --json, with nothing to suggest the
245
+ // two disagreed.
246
+ //
247
+ // That is not a lint bug or a schemas bug; it is one ordering bug that
248
+ // every plugin inherits, which is why it is fixed here rather than in
249
+ // each of them. A finding raised through logger.warn reaches the
250
+ // report because the report is a VIEW of that stream — and a view has
251
+ // to be taken after the writing stops.
252
+ await this.emitReport?.()
216
253
  },
217
254
 
218
255
  async sync(operation) {