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 +7 -0
- package/docs/diagnostics.md +26 -3
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/cli.js +139 -0
- package/src/engine.js +62 -6
- package/src/instance.js +31 -1
- package/src/plugins.js +26 -2
- package/src/report.js +54 -0
- package/src/runtime.js +37 -0
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 {
|
package/docs/diagnostics.md
CHANGED
|
@@ -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`
|
|
389
|
-
which makes it
|
|
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
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|