mikser-io 9.99.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
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.99.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'
@@ -624,6 +625,11 @@ Which check answers which question:
624
625
  instance is ALWAYS in watch mode, so watch alone
625
626
  answers the wrong question.
626
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
+
627
633
  What did this build do, and cost?
628
634
  --json the whole report as one document on stdout, with
629
635
  every warning carrying a stable code, and per-phase
@@ -632,7 +638,34 @@ Which check answers which question:
632
638
 
633
639
  The full version, with what each code means: docs/diagnostics.md`)
634
640
 
635
- 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())
636
669
  // runtime.options.info gates the progress bar — gauge stays
637
670
  // silent in --debug/--trace modes because logs are voluminous
638
671
  // there and a bar on top would just be noise.
@@ -778,6 +811,15 @@ The full version, with what each code means: docs/diagnostics.md`)
778
811
  })
779
812
 
780
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
+
781
823
  const logger = useLogger()
782
824
  logger.debug(runtime.options, 'Mikser options')
783
825
 
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
  //
@@ -416,6 +417,17 @@ async function withRequestOutput(request, run) {
416
417
  prior[key] = runtime.options[key]
417
418
  if (request[key]) runtime.options[key] = request[key]
418
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
+ }
419
431
  try {
420
432
  return await run()
421
433
  } finally {
@@ -450,6 +462,10 @@ async function serveBuild(socket, request, logger) {
450
462
  // rebuild.
451
463
  const priorRenderPresets = runtime.options.renderPresets
452
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
453
469
  try {
454
470
  // The report is emitted by the cycle itself, from inside
455
471
  // rebuild() — the same call a one-shot makes. Nothing here
@@ -471,6 +487,7 @@ async function serveBuild(socket, request, logger) {
471
487
  })
472
488
  } finally {
473
489
  runtime.options.renderPresets = priorRenderPresets
490
+ runtime.options.force = priorForce
474
491
  }
475
492
 
476
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
@@ -194,6 +196,7 @@ export function reportWipe(cause, detail = {}) {
194
196
  // One source whose bytes moved. The complement of reportGated: between them
195
197
  // every file the engine looked at is accounted for.
196
198
  export function reportChanged(id) {
199
+ if (id) activity().changed++
197
200
  if (!reportWanted() || !id) return
198
201
  const store = changedStore()
199
202
  store.count++
@@ -277,6 +280,7 @@ export function reportGated(count = 1) {
277
280
  // `matched`, `dependency` each mean something specific, and a single
278
281
  // polymorphic key would push the type switch onto every consumer.
279
282
  export function reportRendered(entity, reason, decision = {}) {
283
+ activity().rendered++
280
284
  if (!reportWanted()) return
281
285
  store().rendered.push({
282
286
  id: entity?.id,
@@ -425,6 +429,45 @@ export function renderErrorCount() {
425
429
  return errorStore().length
426
430
  }
427
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
+
428
471
  // The cause, and enough detail to act on it.
429
472
  //
430
473
  // A wipe outranks changed sources: when the cache went, everything is a
@@ -451,6 +494,12 @@ function phaseTimings() {
451
494
  const entries = Object.entries(timings)
452
495
  .map(([phase, { ms, calls }]) => ({ phase, ms: Math.round(ms * 10) / 10, calls }))
453
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
+
454
503
  return {
455
504
  // This cycle, summed. The phases below add up to it.
456
505
  total: Math.round(entries.reduce((sum, e) => sum + e.ms, 0) * 10) / 10,
@@ -467,6 +516,7 @@ function phaseTimings() {
467
516
  // misled by a name like "elapsed".
468
517
  processUptime: Math.round(process.uptime() * 1000 * 10) / 10,
469
518
  phases: entries,
519
+ ...(byPlugin.length ? { plugins: byPlugin } : {}),
470
520
  }
471
521
  }
472
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