mikser-io 9.99.0 → 10.0.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 -1
- package/docs/diagnostics.md +11 -0
- package/index.js +5 -0
- package/package.json +1 -1
- package/src/cli.js +173 -0
- package/src/engine.js +66 -30
- package/src/instance.js +22 -3
- package/src/plugins/assets.js +34 -3
- package/src/plugins/data.js +16 -1
- package/src/plugins/documents.js +8 -0
- package/src/plugins/files.js +16 -1
- package/src/plugins/preview.js +11 -9
- package/src/plugins/resources.js +16 -1
- package/src/plugins.js +34 -2
- package/src/report.js +50 -0
- package/src/runtime.js +20 -0
- package/src/services.js +88 -0
- package/src/source.js +13 -3
- package/src/tools.js +18 -7
- package/testing/harness.js +6 -0
package/app.js
CHANGED
|
@@ -68,7 +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
|
-
|
|
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') }
|
|
72
78
|
|
|
73
79
|
return {
|
|
74
80
|
longRunning,
|
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
|
package/index.js
CHANGED
|
@@ -5,6 +5,11 @@ 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'
|
|
9
|
+
// What one plugin offers another. A plugin publishes an API under a name
|
|
10
|
+
// (provideService) or adds to an extension point (contribute); consumers ask
|
|
11
|
+
// core, never a sibling. Replaces reaching into runtime.options.<plugin>.
|
|
12
|
+
export * from './src/services.js'
|
|
8
13
|
// The diagnostics behind --explain. Exported so a transport — the MCP tool
|
|
9
14
|
// surface, the api plugin's routes — can serve the same structured report the
|
|
10
15
|
// CLI formats, rather than each one reimplementing the question.
|
package/package.json
CHANGED
package/src/cli.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
// but NOT at construction time. Declaring happens during the load phase and
|
|
45
|
+
// the table is not parsed until after it, so an option read while the plugin
|
|
46
|
+
// is being built is always undefined. Read it in a hook: onLoaded and later
|
|
47
|
+
// all run after stage two. documents() read its folder at construction first,
|
|
48
|
+
// and `--documents content` silently did nothing.
|
|
49
|
+
export function cliOption(flags, description, defaultValue) {
|
|
50
|
+
// No CLI at all — a plugin constructed by a test harness, or embedded
|
|
51
|
+
// programmatically through setup({ ... }) rather than run from a terminal.
|
|
52
|
+
// There is nothing to register the option on and nothing about that is a
|
|
53
|
+
// mistake, so it returns quietly. Throwing here made every plugin that
|
|
54
|
+
// declares an option unusable in its own unit tests, which is how this was
|
|
55
|
+
// found.
|
|
56
|
+
//
|
|
57
|
+
// Declaring LATE is still an error — see below — because that one really
|
|
58
|
+
// is a mistake: the option would never be parsed and the flag would look
|
|
59
|
+
// ignored.
|
|
60
|
+
const commander = runtime.engine?.commander
|
|
61
|
+
if (!commander) return undefined
|
|
62
|
+
if (runtime.engine.cliSealed) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`cliOption(${JSON.stringify(flags)}): the option table was already parsed. `
|
|
65
|
+
+ 'Declare options while the plugin is constructed (the load phase), not afterwards — '
|
|
66
|
+
+ 'a later one would never be read and the flag would look ignored.')
|
|
67
|
+
}
|
|
68
|
+
if (declared.has(flags)) return declared.get(flags)
|
|
69
|
+
const option = defaultValue === undefined
|
|
70
|
+
? commander.option(flags, description)
|
|
71
|
+
: commander.option(flags, description, defaultValue)
|
|
72
|
+
declared.set(flags, option)
|
|
73
|
+
return option
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every option a plugin added, in declaration order.
|
|
77
|
+
export function pluginCliOptions() {
|
|
78
|
+
return [...declared.keys()]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Stage two: the table is complete, so parse for real.
|
|
82
|
+
//
|
|
83
|
+
// Called once, after the load phase. Merges what the complete table yields
|
|
84
|
+
// over what stage one produced — a plugin's option lands here for the first
|
|
85
|
+
// time, and core's own options parse identically both times.
|
|
86
|
+
export function completeCliParse() {
|
|
87
|
+
const commander = runtime.engine?.commander
|
|
88
|
+
if (!commander || runtime.engine.cliSealed) return
|
|
89
|
+
runtime.engine.cliSealed = true
|
|
90
|
+
|
|
91
|
+
// Always, even when no plugin declared anything.
|
|
92
|
+
//
|
|
93
|
+
// Stage one tolerates an unknown option AND the excess argument it then
|
|
94
|
+
// looks like, so it cannot be the stage that judges argv — left to it,
|
|
95
|
+
// `mikser --bogus-flag` came back "too many arguments. Expected 0
|
|
96
|
+
// arguments but got 1", which is commander describing its own internal
|
|
97
|
+
// reading rather than the mistake a person made. Skipping stage two when
|
|
98
|
+
// no plugin declared would leave exactly that message on most builds.
|
|
99
|
+
//
|
|
100
|
+
// Strict here: every option any part of this build understands is
|
|
101
|
+
// registered, so anything left over is a misspelling, and saying which one
|
|
102
|
+
// is the whole point of the refusal.
|
|
103
|
+
commander.allowUnknownOption(false).allowExcessArguments(false)
|
|
104
|
+
|
|
105
|
+
// Help, now that the table is complete — including every option the
|
|
106
|
+
// project's own plugins declared, which is the whole reason it waited.
|
|
107
|
+
if (runtime.engine.helpRequested) {
|
|
108
|
+
commander.outputHelp()
|
|
109
|
+
process.exit(0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
commander.parse(process.argv)
|
|
113
|
+
|
|
114
|
+
// ONLY the options a plugin declared.
|
|
115
|
+
//
|
|
116
|
+
// Assigning the whole opts() blob put core's own options back to their
|
|
117
|
+
// parsed values — and by this point the engine has already NORMALISED
|
|
118
|
+
// several of them. `--working-folder` defaults to './' and onInitialize
|
|
119
|
+
// resolves it to an absolute path; re-assigning made it './' again, after
|
|
120
|
+
// the load phase, so every plugin that resolves a folder in onLoaded got a
|
|
121
|
+
// relative one. `path.join('./', 'schemas')` is `schemas`, which reaches
|
|
122
|
+
// import() as a BARE SPECIFIER — so schemas, presets and layout sidecars
|
|
123
|
+
// all failed with "Cannot find package 'schemas'" on a clean build that
|
|
124
|
+
// was otherwise green.
|
|
125
|
+
//
|
|
126
|
+
// Stage two exists to pick up what stage one could not know about. It has
|
|
127
|
+
// no business restating what stage one already produced and the engine has
|
|
128
|
+
// since corrected.
|
|
129
|
+
const parsed = commander.opts()
|
|
130
|
+
for (const option of commander.options) {
|
|
131
|
+
if (!declared.has(option.flags)) continue
|
|
132
|
+
const name = option.attributeName()
|
|
133
|
+
if (parsed[name] !== undefined) runtime.options[name] = parsed[name]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The plugin-declared options carried by a client's argv.
|
|
138
|
+
//
|
|
139
|
+
// A forwarded build is answered by an INSTANCE, which parsed its own argv
|
|
140
|
+
// (`--watch`) and never saw the client's. Core's own request options — json,
|
|
141
|
+
// clear, renderPresets — each travel explicitly for exactly this reason. A
|
|
142
|
+
// plugin option has to as well, or `mikser --lighthouse` works with nothing
|
|
143
|
+
// listening and silently does nothing with a watcher up, which is the failure
|
|
144
|
+
// this whole surface exists to remove.
|
|
145
|
+
//
|
|
146
|
+
// Read from the argv the client sent, against the table the INSTANCE has:
|
|
147
|
+
// both processes load the same config, so the instance knows every option the
|
|
148
|
+
// client could legitimately have passed.
|
|
149
|
+
export function pluginOptionsFrom(argv) {
|
|
150
|
+
const commander = runtime.engine?.commander
|
|
151
|
+
if (!commander || !declared.size || !Array.isArray(argv)) return {}
|
|
152
|
+
// A throwaway parse: `parseOptions` reads without applying, so the
|
|
153
|
+
// instance's own options are untouched by asking what a client sent.
|
|
154
|
+
let parsed
|
|
155
|
+
try {
|
|
156
|
+
const probe = commander.createCommand()
|
|
157
|
+
for (const flags of declared.keys()) probe.option(flags, '')
|
|
158
|
+
probe.allowUnknownOption(true).allowExcessArguments(true)
|
|
159
|
+
probe.parse(argv, { from: 'user' })
|
|
160
|
+
parsed = probe.opts()
|
|
161
|
+
} catch {
|
|
162
|
+
return {}
|
|
163
|
+
}
|
|
164
|
+
// Only what a plugin declared. Core's options travel on the request
|
|
165
|
+
// itself, and re-applying them from argv here would give two sources for
|
|
166
|
+
// one answer.
|
|
167
|
+
const names = new Set(Object.keys(parsed))
|
|
168
|
+
const values = {}
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (parsed[name] !== undefined) values[name] = parsed[name]
|
|
171
|
+
}
|
|
172
|
+
return values
|
|
173
|
+
}
|
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'
|
|
@@ -31,7 +32,7 @@ import { queryContext } from './database/query-context.js'
|
|
|
31
32
|
// Build a structuredClone-safe copy of runtime.options for WORKER
|
|
32
33
|
// dispatch. Plugin surfaces live under `runtime.options.<plugin>` per
|
|
33
34
|
// the engine's namespacing convention and routinely hold functions
|
|
34
|
-
// (e.g. `
|
|
35
|
+
// (e.g. the `layouts` service's inspect(), the `preview` service's get())
|
|
35
36
|
// — those don't cross thread boundaries via Piscina's structured clone.
|
|
36
37
|
//
|
|
37
38
|
// Per-key probe: anything that survives `structuredClone(value)` passes
|
|
@@ -538,7 +539,6 @@ export async function setup(options) {
|
|
|
538
539
|
.option('-c --config <file>', 'set mikser mikser.config.js location', './mikser.config.js')
|
|
539
540
|
.option('-m --mode <mode>', 'set mikser runtime mode', 'development')
|
|
540
541
|
.option('-r --clear', 'clear current state before execution', false)
|
|
541
|
-
.option('--render-presets [name]', 're-render preset derivatives whose sources and revisions are unchanged; with a name, only that preset')
|
|
542
542
|
.option('-o --output-folder <folder>', 'set mikser output folder relative to working folder', 'out')
|
|
543
543
|
.option('-w --watch', 'watch entities for changes', false)
|
|
544
544
|
.option('-f --force', 'rebuild everything; disable incremental dispatch', false)
|
|
@@ -599,7 +599,10 @@ Which check answers which question:
|
|
|
599
599
|
Are the derivatives current?
|
|
600
600
|
--render-presets [n] re-derives every preset, or one by name, without
|
|
601
601
|
touching anything else. For a preset edited without
|
|
602
|
-
bumping its revision.
|
|
602
|
+
bumping its revision. Declared by the assets
|
|
603
|
+
plugin, so it exists only in a project that loads
|
|
604
|
+
one — and a config without assets() refuses it as
|
|
605
|
+
unknown rather than accepting it and doing nothing.
|
|
603
606
|
|
|
604
607
|
Start over.
|
|
605
608
|
--clear removes the output folder and reopens the cache.
|
|
@@ -632,7 +635,34 @@ Which check answers which question:
|
|
|
632
635
|
|
|
633
636
|
The full version, with what each code means: docs/diagnostics.md`)
|
|
634
637
|
|
|
635
|
-
|
|
638
|
+
// Stage one of two. The config names the plugins and is not read
|
|
639
|
+
// until onLoad, so at this moment the engine does not yet know every
|
|
640
|
+
// option this build understands — a plugin's is not registered. Left
|
|
641
|
+
// strict, `mikser --lighthouse` would be rejected before the plugin
|
|
642
|
+
// that defines it had been constructed.
|
|
643
|
+
//
|
|
644
|
+
// Tolerated here and decided in stage two, where the table is complete
|
|
645
|
+
// — see completeCliParse(). The 9.81.0 refusal is not weakened, it is
|
|
646
|
+
// moved to the point where "unknown" can be answered.
|
|
647
|
+
runtime.engine.commander.allowUnknownOption(true).allowExcessArguments(true)
|
|
648
|
+
|
|
649
|
+
// --help is answered in stage two, for the same reason the parse is.
|
|
650
|
+
//
|
|
651
|
+
// Commander prints help and exits the moment it sees the flag, which
|
|
652
|
+
// in stage one is before any plugin has been constructed — so the help
|
|
653
|
+
// would list core's options and silently omit every option the
|
|
654
|
+
// project's own plugins add. A help text that is missing the flag you
|
|
655
|
+
// are looking for is worse than a slower one.
|
|
656
|
+
//
|
|
657
|
+
// Held out of this parse and answered after the table is complete. It
|
|
658
|
+
// does mean `--help` now loads the config, which is the honest cost of
|
|
659
|
+
// describing THIS project's mikser rather than a generic one.
|
|
660
|
+
const helpFlags = ['--help', '-h']
|
|
661
|
+
runtime.engine.helpRequested = process.argv.some(arg => helpFlags.includes(arg))
|
|
662
|
+
const argv = runtime.engine.helpRequested
|
|
663
|
+
? process.argv.filter(arg => !helpFlags.includes(arg))
|
|
664
|
+
: process.argv
|
|
665
|
+
Object.assign(runtime.options, options || runtime.engine.commander.parse(argv).opts())
|
|
636
666
|
// runtime.options.info gates the progress bar — gauge stays
|
|
637
667
|
// silent in --debug/--trace modes because logs are voluminous
|
|
638
668
|
// there and a bar on top would just be noise.
|
|
@@ -778,6 +808,15 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
778
808
|
})
|
|
779
809
|
|
|
780
810
|
onLoaded(async () => {
|
|
811
|
+
// Stage two of the parse, before anything reads an option.
|
|
812
|
+
//
|
|
813
|
+
// Every plugin has been constructed by now and has had its chance to
|
|
814
|
+
// declare, so the table is complete and argv is parsed against all of
|
|
815
|
+
// it. This engine hook is registered when the module is imported, so
|
|
816
|
+
// it runs ahead of every plugin's own onLoaded — which is what makes
|
|
817
|
+
// it safe for a plugin to read its own option there.
|
|
818
|
+
completeCliParse()
|
|
819
|
+
|
|
781
820
|
const logger = useLogger()
|
|
782
821
|
logger.debug(runtime.options, 'Mikser options')
|
|
783
822
|
|
|
@@ -803,8 +842,18 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
803
842
|
// The same three commands the instance answers over the socket —
|
|
804
843
|
// one implementation, so a forwarded --audit-output cannot disagree with a
|
|
805
844
|
// local one about what it checked.
|
|
806
|
-
|
|
807
|
-
|
|
845
|
+
//
|
|
846
|
+
// --tools and --tool are NOT among them: they are dispatched at `import`
|
|
847
|
+
// instead, because the tool registry is not complete until every
|
|
848
|
+
// plugin's onLoaded has run and this hook runs ahead of all of them.
|
|
849
|
+
// The import dispatch existed already and was documented as
|
|
850
|
+
// load-bearing, but this call reached the same code first and exited,
|
|
851
|
+
// so it never ran — and every tool a plugin registers was missing from
|
|
852
|
+
// the CLI while the listing looked healthy, just short.
|
|
853
|
+
if (!runtime.options.tools && !runtime.options.tool) {
|
|
854
|
+
const code = await runReportOnly()
|
|
855
|
+
if (code !== null) process.exit(code)
|
|
856
|
+
}
|
|
808
857
|
})
|
|
809
858
|
|
|
810
859
|
onRender(async (signal) => {
|
|
@@ -1012,7 +1061,7 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
1012
1061
|
mc.port2.unref()
|
|
1013
1062
|
renderOptions.port = mc.port1
|
|
1014
1063
|
// Strip plugin-surface functions
|
|
1015
|
-
// (
|
|
1064
|
+
// (the layouts service's inspect, etc.) so
|
|
1016
1065
|
// Piscina's structured clone doesn't choke on
|
|
1017
1066
|
// them. Engine-side primitives pass through;
|
|
1018
1067
|
// plugin surfaces are reachable via the
|
|
@@ -1459,30 +1508,17 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
1459
1508
|
if (failed) logger.error('Mikser completed with %d render error%s', failed, failed === 1 ? '' : 's')
|
|
1460
1509
|
else logger.notice('Mikser completed')
|
|
1461
1510
|
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
// The URL helpers build paths; they do not resolve them. So a preset
|
|
1465
|
-
// that never ran, a library that was not copied, or a template naming
|
|
1466
|
-
// an extension the preset stopped producing all yield a well-formed
|
|
1467
|
-
// link to a file that does not exist — and the only symptom is a
|
|
1468
|
-
// missing image on the deployed site, found by a person.
|
|
1511
|
+
// The `render-presets-unhandled` guard is gone, and so is the state it
|
|
1512
|
+
// read.
|
|
1469
1513
|
//
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1478
|
-
// onLoaded because the engine's own onLoaded is registered first and
|
|
1479
|
-
// runs before any plugin has set itself up.
|
|
1480
|
-
if (runtime.options.renderPresets && !runtime.state?.assets?.renderPresetsHandled) {
|
|
1481
|
-
useLogger().error({ code: 'render-presets-unhandled' },
|
|
1482
|
-
'--render-presets was passed, but no assets plugin is loaded to act on it. '
|
|
1483
|
-
+ 'Nothing was re-derived. Add assets() to the plugins array, or drop the flag.')
|
|
1484
|
-
process.exitCode = 1
|
|
1485
|
-
}
|
|
1514
|
+
// It existed because core declared `--render-presets` while the assets
|
|
1515
|
+
// plugin implemented it, so the flag could be passed at a build that
|
|
1516
|
+
// had no plugin to act on it — accepted, ignored, and reported after
|
|
1517
|
+
// the fact. The plugin declares the option itself now, so a config
|
|
1518
|
+
// without assets() does not have the flag at all and a misspelling is
|
|
1519
|
+
// refused by name before anything is built. There is nothing left to
|
|
1520
|
+
// warn about after the event.
|
|
1521
|
+
|
|
1486
1522
|
|
|
1487
1523
|
const brokenTargets = await reportBrokenReferences(useLogger())
|
|
1488
1524
|
await reportMissingAssets(useLogger(), brokenTargets)
|
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
|
//
|
|
@@ -72,6 +73,11 @@ export function socketPath(workingFolder) {
|
|
|
72
73
|
//
|
|
73
74
|
// → { type: 'build', config, clear, renderPresets }
|
|
74
75
|
// → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, fingerprint, json }
|
|
76
|
+
//
|
|
77
|
+
// A PLUGIN's options are not listed here and are not hand-carried: they travel
|
|
78
|
+
// on `argv` and are applied by withRequestOutput against the instance's own
|
|
79
|
+
// option table. --render-presets used to be named explicitly, which is what a
|
|
80
|
+
// core-declared plugin flag costs — one more thing to remember in two files.
|
|
75
81
|
// ← { type: 'log', chunk } (zero or more, in order)
|
|
76
82
|
// ← { type: 'done', code }
|
|
77
83
|
// ← { type: 'refused', reason, detail }
|
|
@@ -416,6 +422,17 @@ async function withRequestOutput(request, run) {
|
|
|
416
422
|
prior[key] = runtime.options[key]
|
|
417
423
|
if (request[key]) runtime.options[key] = request[key]
|
|
418
424
|
}
|
|
425
|
+
|
|
426
|
+
// Whatever the client's argv said about a PLUGIN's options.
|
|
427
|
+
//
|
|
428
|
+
// The instance parsed its own argv and never saw the client's, so a flag a
|
|
429
|
+
// plugin declared worked with nothing listening and did nothing with a
|
|
430
|
+
// watcher up. Applied and restored with the rest of the request's
|
|
431
|
+
// contract, so a watcher's own cycle before or after is unaffected.
|
|
432
|
+
for (const [name, value] of Object.entries(pluginOptionsFrom(request.argv))) {
|
|
433
|
+
if (!(name in prior)) prior[name] = runtime.options[name]
|
|
434
|
+
runtime.options[name] = value
|
|
435
|
+
}
|
|
419
436
|
try {
|
|
420
437
|
return await run()
|
|
421
438
|
} finally {
|
|
@@ -448,8 +465,10 @@ async function serveBuild(socket, request, logger) {
|
|
|
448
465
|
// silent no-op, which is the failure this surface exists to remove —
|
|
449
466
|
// and restoring it after keeps the watcher from forcing every later
|
|
450
467
|
// rebuild.
|
|
451
|
-
|
|
452
|
-
|
|
468
|
+
// Applied for THIS cycle only, like renderPresets. An instance left in
|
|
469
|
+
// force mode would re-render the whole site on every later save.
|
|
470
|
+
const priorForce = runtime.options.force
|
|
471
|
+
if (request.force) runtime.options.force = true
|
|
453
472
|
try {
|
|
454
473
|
// The report is emitted by the cycle itself, from inside
|
|
455
474
|
// rebuild() — the same call a one-shot makes. Nothing here
|
|
@@ -470,7 +489,7 @@ async function serveBuild(socket, request, logger) {
|
|
|
470
489
|
emitReport()
|
|
471
490
|
})
|
|
472
491
|
} finally {
|
|
473
|
-
runtime.options.
|
|
492
|
+
runtime.options.force = priorForce
|
|
474
493
|
}
|
|
475
494
|
|
|
476
495
|
// From the render-error count, NOT from process.exitCode.
|
package/src/plugins/assets.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reportEvaluated } from '../report.js'
|
|
2
2
|
import { countEntities } from '../catalog.js'
|
|
3
|
+
import { cliOption } from '../cli.js'
|
|
3
4
|
import path from 'node:path'
|
|
4
5
|
import { mkdir, writeFile, unlink, rm, readFile, symlink, } from 'fs/promises'
|
|
5
6
|
import { existsSync } from 'node:fs'
|
|
@@ -86,6 +87,33 @@ export function assets(options = {}) {
|
|
|
86
87
|
changeExtension,
|
|
87
88
|
constants: { ACTION, OPERATION },
|
|
88
89
|
}) => {
|
|
90
|
+
// Declared HERE, not in core.
|
|
91
|
+
//
|
|
92
|
+
// Everything this flag does happens in this plugin, and core carried it
|
|
93
|
+
// only because a plugin could not declare an option until 9.100.0. The
|
|
94
|
+
// cost of that was a flag a build could accept with nothing loaded to act
|
|
95
|
+
// on it: passed, ignored, and reported afterwards by a `render-presets-
|
|
96
|
+
// unhandled` guard in the engine. Declared here, a config without assets()
|
|
97
|
+
// simply does not have the option, so the mistake is refused by name
|
|
98
|
+
// before anything is built rather than explained after.
|
|
99
|
+
// The folders this plugin owns, on the command line.
|
|
100
|
+
//
|
|
101
|
+
// They were config-only, because a plugin could not declare an option
|
|
102
|
+
// before 9.100.0 — so overriding one for a single run meant editing the
|
|
103
|
+
// config, which is the file whose checksum decides whether the whole
|
|
104
|
+
// catalog is still valid. A flag is the difference between "try this
|
|
105
|
+
// once" and "invalidate everything".
|
|
106
|
+
//
|
|
107
|
+
// CLI beats config beats default, which is the order every other option
|
|
108
|
+
// here already follows.
|
|
109
|
+
cliOption('--assets <folder>',
|
|
110
|
+
'folder for derived assets, relative to the working folder (default: assets)')
|
|
111
|
+
cliOption('--presets <folder>',
|
|
112
|
+
'folder holding the preset modules, relative to the working folder (default: presets)')
|
|
113
|
+
cliOption('--render-presets [name]',
|
|
114
|
+
're-render preset derivatives whose sources and revisions are unchanged; '
|
|
115
|
+
+ 'with a name, only that preset')
|
|
116
|
+
|
|
89
117
|
const collection = 'presets'
|
|
90
118
|
const type = 'preset'
|
|
91
119
|
const checksumMap = new Set()
|
|
@@ -425,12 +453,16 @@ export function assets(options = {}) {
|
|
|
425
453
|
runtime.engine ??= {}
|
|
426
454
|
runtime.engine.assets = { explainMissing }
|
|
427
455
|
|
|
428
|
-
|
|
456
|
+
// `??`, not `||`: an option commander did not see is undefined, and
|
|
457
|
+
// falling through to the config is the point. `||` would also fall
|
|
458
|
+
// through for an intentional empty string, which is a different
|
|
459
|
+
// answer than "not given".
|
|
460
|
+
runtime.options.presets = runtime.options.presets ?? options.presetsFolder ?? collection
|
|
429
461
|
runtime.options.presetsFolder = path.join(runtime.options.workingFolder, runtime.options.presets)
|
|
430
462
|
logger.debug('Presets folder: %s', runtime.options.presetsFolder)
|
|
431
463
|
await mkdir(runtime.options.presetsFolder, { recursive: true })
|
|
432
464
|
|
|
433
|
-
runtime.options.assets = options.assetsFolder
|
|
465
|
+
runtime.options.assets = runtime.options.assets ?? options.assetsFolder ?? 'assets'
|
|
434
466
|
runtime.options.assetsFolder = path.join(runtime.options.workingFolder, runtime.options.assets)
|
|
435
467
|
logger.debug('Assets folder: %s', runtime.options.assetsFolder)
|
|
436
468
|
|
|
@@ -641,7 +673,6 @@ export function assets(options = {}) {
|
|
|
641
673
|
// end of the cycle: a flag that reaches no plugin has to say so,
|
|
642
674
|
// rather than building normally and leaving the operator to
|
|
643
675
|
// notice nothing was re-derived.
|
|
644
|
-
runtime.state.assets.renderPresetsHandled = true
|
|
645
676
|
|
|
646
677
|
const known = Object.keys(runtime.state.assets.presets)
|
|
647
678
|
const names = wanted === true ? known : [wanted]
|
package/src/plugins/data.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
|
+
import { cliOption } from '../cli.js'
|
|
2
3
|
import { mkdir, writeFile, unlink, open } from 'fs/promises'
|
|
3
4
|
import _ from 'lodash'
|
|
4
5
|
import sift from 'sift'
|
|
@@ -17,9 +18,23 @@ export function data(options = {}) {
|
|
|
17
18
|
onBeforeRender,
|
|
18
19
|
constants: { OPERATION },
|
|
19
20
|
}) => {
|
|
21
|
+
// The folder this plugin owns, on the command line.
|
|
22
|
+
//
|
|
23
|
+
// Config-only until 9.100.0, because a plugin could not declare an option
|
|
24
|
+
// — so overriding it for one run meant editing the config, which is the
|
|
25
|
+
// file whose checksum decides whether the catalog is still valid. A flag
|
|
26
|
+
// is the difference between trying something once and invalidating
|
|
27
|
+
// everything.
|
|
28
|
+
//
|
|
29
|
+
// CLI beats config beats default. `??` rather than `||` below: an option
|
|
30
|
+
// commander did not see is undefined, and an intentional empty string is a
|
|
31
|
+
// different answer than "not given".
|
|
32
|
+
cliOption('--data <folder>',
|
|
33
|
+
'folder for emitted data files, relative to the OUTPUT folder (default: data)')
|
|
34
|
+
|
|
20
35
|
onLoaded(async () => {
|
|
21
36
|
const logger = useLogger()
|
|
22
|
-
runtime.options.data = options.dataFolder
|
|
37
|
+
runtime.options.data = runtime.options.data ?? options.dataFolder ?? 'data'
|
|
23
38
|
runtime.options.dataFolder = path.join(runtime.options.outputFolder, runtime.options.data)
|
|
24
39
|
|
|
25
40
|
logger.debug('Data folder: %s', runtime.options.dataFolder)
|
package/src/plugins/documents.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useSource } from '../source.js'
|
|
2
|
+
import { cliOption } from '../cli.js'
|
|
2
3
|
|
|
3
4
|
// documents — the canonical content source plugin.
|
|
4
5
|
//
|
|
@@ -13,6 +14,13 @@ export function documents(options = {}) {
|
|
|
13
14
|
const collection = 'documents'
|
|
14
15
|
const type = 'document'
|
|
15
16
|
|
|
17
|
+
// The folder this plugin owns, on the command line. Config-only until
|
|
18
|
+
// 9.100.0, because a plugin could not declare an option — and editing
|
|
19
|
+
// the config to try another folder once invalidates the catalog,
|
|
20
|
+
// whose checksum covers it. CLI beats config beats default.
|
|
21
|
+
cliOption('--documents <folder>',
|
|
22
|
+
'folder of source documents, relative to the working folder (default: documents)')
|
|
23
|
+
|
|
16
24
|
useSource(core, {
|
|
17
25
|
collection,
|
|
18
26
|
type,
|
package/src/plugins/files.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
+
import { cliOption } from '../cli.js'
|
|
2
3
|
import { mkdir, symlink, unlink, lstat, realpath } from 'fs/promises'
|
|
3
4
|
import { globby } from 'globby'
|
|
4
5
|
import pMap from 'p-map'
|
|
@@ -23,6 +24,20 @@ export function files(options = {}) {
|
|
|
23
24
|
constants: { ACTION },
|
|
24
25
|
}) => {
|
|
25
26
|
const collection = 'files'
|
|
27
|
+
// The folder this plugin owns, on the command line.
|
|
28
|
+
//
|
|
29
|
+
// Config-only until 9.100.0, because a plugin could not declare an option
|
|
30
|
+
// — so overriding it for one run meant editing the config, which is the
|
|
31
|
+
// file whose checksum decides whether the catalog is still valid. A flag
|
|
32
|
+
// is the difference between trying something once and invalidating
|
|
33
|
+
// everything.
|
|
34
|
+
//
|
|
35
|
+
// CLI beats config beats default. `??` rather than `||` below: an option
|
|
36
|
+
// commander did not see is undefined, and an intentional empty string is a
|
|
37
|
+
// different answer than "not given".
|
|
38
|
+
cliOption('--files <folder>',
|
|
39
|
+
'folder of static files copied into the output by symlink, relative to the working folder (default: files)')
|
|
40
|
+
|
|
26
41
|
const type = 'file'
|
|
27
42
|
|
|
28
43
|
async function ensureLink(relativePath) {
|
|
@@ -156,7 +171,7 @@ export function files(options = {}) {
|
|
|
156
171
|
|
|
157
172
|
onLoaded(async () => {
|
|
158
173
|
const logger = useLogger()
|
|
159
|
-
runtime.options.files = options.filesFolder
|
|
174
|
+
runtime.options.files = runtime.options.files ?? options.filesFolder ?? collection
|
|
160
175
|
runtime.options.filesFolder = path.join(runtime.options.workingFolder, runtime.options.files)
|
|
161
176
|
|
|
162
177
|
logger.debug('Files folder: %s', runtime.options.filesFolder)
|
package/src/plugins/preview.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 1. An in-memory cache (Map<filename, { bytes, mime, expiresAt,
|
|
7
7
|
// size, deps }>) with LRU eviction past a configurable byte cap.
|
|
8
8
|
// 2. An Express GET /preview/:filename route that serves cache entries.
|
|
9
|
-
// 3.
|
|
9
|
+
// 3. Offers the cache surface as the `preview` service = { store,
|
|
10
10
|
// get, stats, config } so other plugins (mikser-io-mcp's
|
|
11
11
|
// mikser_preview_render tool, library-mode callers) can stash bytes
|
|
12
12
|
// and retrieve them by URL without going through MCP.
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// isn't mounted.
|
|
30
30
|
|
|
31
31
|
import sift from 'sift'
|
|
32
|
+
import { provideService } from '../services.js'
|
|
32
33
|
|
|
33
34
|
export function preview(options = {}) {
|
|
34
35
|
return ({ runtime, onLoaded, onPersist, useJournal, useLogger, registerRoute, constants: { OPERATION } }) => {
|
|
@@ -98,13 +99,14 @@ export function preview(options = {}) {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
102
|
+
// Offer the cache surface as a service, so a plugin that wants it asks
|
|
103
|
+
// core rather than reaching into runtime.options.preview. `config` is
|
|
104
|
+
// part of the surface too — mikser-io-mcp's mikser_preview_render reads
|
|
105
|
+
// it to derive URL paths + TTL clamps.
|
|
106
|
+
//
|
|
107
|
+
// Provided at factory-eval time, before any hook runs, so a consumer's
|
|
108
|
+
// onLoad / onLoaded can already see it whatever the plugin order.
|
|
109
|
+
provideService('preview', { store, get, stats, config }, { plugin: 'mikser-io' })
|
|
108
110
|
|
|
109
111
|
// Per-cycle invalidation. For every preview entry that has `deps`,
|
|
110
112
|
// walk this cycle's catalog mutations and evict the entry if any
|
|
@@ -144,7 +146,7 @@ export function preview(options = {}) {
|
|
|
144
146
|
|
|
145
147
|
// HTTP route: served regardless of whether MCP is on, so previews
|
|
146
148
|
// are also reachable from library-mode callers that stored bytes
|
|
147
|
-
// via
|
|
149
|
+
// via the preview service's store() directly.
|
|
148
150
|
onLoaded(async () => {
|
|
149
151
|
const logger = useLogger()
|
|
150
152
|
const app = runtime.options.app
|
package/src/plugins/resources.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, symlink, rename, unlink } from 'node:fs/promises'
|
|
2
|
+
import { cliOption } from '../cli.js'
|
|
2
3
|
import { createWriteStream } from 'node:fs'
|
|
3
4
|
import lodash from 'lodash'
|
|
4
5
|
import deepdash from 'deepdash'
|
|
@@ -27,6 +28,20 @@ export function resources(options = {}) {
|
|
|
27
28
|
updateProgress,
|
|
28
29
|
constants: { OPERATION },
|
|
29
30
|
}) => {
|
|
31
|
+
// The folder this plugin owns, on the command line.
|
|
32
|
+
//
|
|
33
|
+
// Config-only until 9.100.0, because a plugin could not declare an option
|
|
34
|
+
// — so overriding it for one run meant editing the config, which is the
|
|
35
|
+
// file whose checksum decides whether the catalog is still valid. A flag
|
|
36
|
+
// is the difference between trying something once and invalidating
|
|
37
|
+
// everything.
|
|
38
|
+
//
|
|
39
|
+
// CLI beats config beats default. `??` rather than `||` below: an option
|
|
40
|
+
// commander did not see is undefined, and an intentional empty string is a
|
|
41
|
+
// different answer than "not given".
|
|
42
|
+
cliOption('--resources <folder>',
|
|
43
|
+
'folder of library resources linked into the output, relative to the working folder (default: resources)')
|
|
44
|
+
|
|
30
45
|
const collection = 'resources'
|
|
31
46
|
const type = 'resource'
|
|
32
47
|
|
|
@@ -46,7 +61,7 @@ export function resources(options = {}) {
|
|
|
46
61
|
: resourcesName,
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
runtime.options.resources = options.resourcesFolder
|
|
64
|
+
runtime.options.resources = runtime.options.resources ?? options.resourcesFolder ?? collection
|
|
50
65
|
runtime.options.resourcesFolder = path.join(runtime.options.workingFolder, runtime.options.resources)
|
|
51
66
|
logger.debug('Resources folder: %s', runtime.options.resourcesFolder)
|
|
52
67
|
|
package/src/plugins.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useLogger } from './engine.js'
|
|
2
2
|
import { onLoad } from './lifecycle.js'
|
|
3
|
+
import { resetServices } from './services.js'
|
|
3
4
|
import runtime from './runtime.js'
|
|
4
5
|
|
|
5
6
|
import * as core from '../index.js'
|
|
@@ -23,6 +24,13 @@ import * as core from '../index.js'
|
|
|
23
24
|
onLoad(() => {
|
|
24
25
|
const logger = useLogger()
|
|
25
26
|
|
|
27
|
+
// The service and extension-point registries are module state, which
|
|
28
|
+
// outlives one load phase — a watcher re-reads its config in the same
|
|
29
|
+
// process. Clearing here, before any factory runs, means a reload
|
|
30
|
+
// rebuilds them from scratch instead of a provider colliding with the
|
|
31
|
+
// copy of itself it registered last time.
|
|
32
|
+
resetServices()
|
|
33
|
+
|
|
26
34
|
runtime.options.plugins = (runtime.options.plugins ?? [])
|
|
27
35
|
.concat(runtime.config.plugins ?? [])
|
|
28
36
|
.filter(Boolean)
|
|
@@ -110,11 +118,35 @@ onLoad(() => {
|
|
|
110
118
|
if (registeredPostprocessors) parts.push(`${registeredPostprocessors} postprocessor${registeredPostprocessors === 1 ? '' : 's'}`)
|
|
111
119
|
logger.info('Loading plugins: %s', parts.join(', '))
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
// Which plugin registered which hook, so a phase can be broken down.
|
|
122
|
+
//
|
|
123
|
+
// `timings` said `finalized: 19400ms` and stopped there — and finalized is
|
|
124
|
+
// where the reference check, schemas, lint and lighthouse all live, so the
|
|
125
|
+
// one number said nothing about which of them cost it. Downstream that
|
|
126
|
+
// took three separate measurements to attribute a 465ms lint pass.
|
|
127
|
+
//
|
|
128
|
+
// Labelled retroactively, because a plugin's identity is not knowable
|
|
129
|
+
// until its factory RETURNS: the entry in the plugins array is already the
|
|
130
|
+
// factory's result, so there is no name to read going in, and the
|
|
131
|
+
// descriptor with `collection` on it only exists coming out. So the hooks
|
|
132
|
+
// are diffed across the call and tagged with what the call produced.
|
|
133
|
+
const hookNames = Object.keys(runtime.hooks)
|
|
134
|
+
for (const [index, factoryReturn] of factoryEntries.entries()) {
|
|
135
|
+
const before = new Map(hookNames.map(name => [name, runtime.hooks[name].length]))
|
|
136
|
+
let descriptor
|
|
114
137
|
try {
|
|
115
|
-
factoryReturn(core)
|
|
138
|
+
descriptor = factoryReturn(core)
|
|
116
139
|
} catch (err) {
|
|
117
140
|
logger.error('Plugin factory threw on registration: %s', err.message)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
const label = descriptor?.collection ?? descriptor?.type ?? `plugin-${index + 1}`
|
|
144
|
+
for (const name of hookNames) {
|
|
145
|
+
for (const hook of runtime.hooks[name].slice(before.get(name))) {
|
|
146
|
+
// A plugin registering the same function twice keeps its first
|
|
147
|
+
// label rather than being renamed by a later registration.
|
|
148
|
+
if (typeof hook === 'function' && !hook.mikserPlugin) hook.mikserPlugin = label
|
|
149
|
+
}
|
|
118
150
|
}
|
|
119
151
|
}
|
|
120
152
|
})
|
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
|
package/src/services.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// What one plugin offers another, without either importing the other.
|
|
2
|
+
//
|
|
3
|
+
// Plugins reached into each other through `runtime.options`:
|
|
4
|
+
//
|
|
5
|
+
// runtime.options.layouts.inspect read by core
|
|
6
|
+
// runtime.options.schemas.lookup read by forms, layouts, ocr
|
|
7
|
+
// runtime.options.preview.get read by core, layouts, mcp
|
|
8
|
+
// runtime.options.roles read by mcp
|
|
9
|
+
//
|
|
10
|
+
// It worked, and it was wrong in a specific way: mikser-io-mcp and
|
|
11
|
+
// mikser-io-layouts were coupled through a shared mutable object neither of
|
|
12
|
+
// them owns. Nothing declared the relationship, so nothing could check it —
|
|
13
|
+
// a consumer's `if (!runtime.options.layouts) return` reads as a null check
|
|
14
|
+
// but is really an is-that-plugin-installed check wearing a disguise.
|
|
15
|
+
//
|
|
16
|
+
// It also cost the two best names. Commander derives an option's property
|
|
17
|
+
// from its long flag, so `--layouts <folder>` writes runtime.options.layouts
|
|
18
|
+
// — on top of the inspect API, after the load phase, with the failure
|
|
19
|
+
// surfacing three plugins away.
|
|
20
|
+
//
|
|
21
|
+
// This covers ONE direction: one plugin provides, others consume. The other
|
|
22
|
+
// direction — many plugins adding to a surface one plugin serves — already
|
|
23
|
+
// has a home for each surface it applies to: tools.js, routes.js, roles.js
|
|
24
|
+
// and cli.js. A generic contribution registry would be a fifth way to do
|
|
25
|
+
// what those four already do.
|
|
26
|
+
//
|
|
27
|
+
// So: core holds the registry, and neither side names the other.
|
|
28
|
+
|
|
29
|
+
const services = new Map() // name -> { api, plugin }
|
|
30
|
+
|
|
31
|
+
// Publish an API under a name other plugins can ask for.
|
|
32
|
+
//
|
|
33
|
+
// provideService('layouts', { inspect }, { plugin: 'mikser-io-layouts' })
|
|
34
|
+
//
|
|
35
|
+
// Call at factory-eval time, before any hook runs, so that a consumer's
|
|
36
|
+
// onLoaded can already see it. Same rule as cliOption: declare during
|
|
37
|
+
// construction, read during a hook.
|
|
38
|
+
export function provideService(name, api, { plugin } = {}) {
|
|
39
|
+
if (!name || !api) return
|
|
40
|
+
const existing = services.get(name)
|
|
41
|
+
if (existing) {
|
|
42
|
+
// Two providers for one name is a configuration mistake, not a
|
|
43
|
+
// precedence question. Last-write-wins would hand consumers whichever
|
|
44
|
+
// plugin happened to be constructed later — the exact order
|
|
45
|
+
// dependence this registry exists to remove. Thrown rather than
|
|
46
|
+
// logged: this runs at factory-eval time, where plugins.js already
|
|
47
|
+
// catches, names the plugin and carries on with the rest.
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Service "${name}" is already provided by ${existing.plugin ?? 'another plugin'}` +
|
|
50
|
+
`${plugin ? `; ${plugin} tried to provide it too` : ''}. Only one plugin may provide a service.`)
|
|
51
|
+
}
|
|
52
|
+
services.set(name, { api, plugin })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Ask for a service. Returns undefined when nothing provides it, so a
|
|
56
|
+
// consumer degrades on purpose:
|
|
57
|
+
//
|
|
58
|
+
// const layouts = useService('layouts')
|
|
59
|
+
// if (!layouts) return // layouts plugin isn't installed
|
|
60
|
+
//
|
|
61
|
+
// This replaces `if (!runtime.options.mcp) return`, which read as a null
|
|
62
|
+
// check but was really an is-that-plugin-loaded check wearing a disguise.
|
|
63
|
+
export function useService(name) {
|
|
64
|
+
return services.get(name)?.api
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ask for a service the caller cannot work without. Throws naming the
|
|
68
|
+
// package that provides it, because "cannot read properties of undefined"
|
|
69
|
+
// does not tell anyone which npm install they are missing.
|
|
70
|
+
export function requireService(name, { from } = {}) {
|
|
71
|
+
const found = services.get(name)
|
|
72
|
+
if (found) return found.api
|
|
73
|
+
throw new Error(
|
|
74
|
+
`No plugin provides the "${name}" service` +
|
|
75
|
+
(from ? `. Install ${from} and add it to the plugins array.` : '.'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Drop everything. The registries are module state, which outlives a single
|
|
79
|
+
// run inside one process — tests, and the watcher's repeated config reloads.
|
|
80
|
+
export function resetServices() {
|
|
81
|
+
services.clear()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// What is registered right now. For --explain and the MCP inventory tool:
|
|
85
|
+
// "which plugin provides what" is a question people ask.
|
|
86
|
+
export function serviceInventory() {
|
|
87
|
+
return [...services].map(([name, { plugin }]) => ({ name, plugin }))
|
|
88
|
+
}
|
package/src/source.js
CHANGED
|
@@ -337,9 +337,19 @@ export function useSource(core, options) {
|
|
|
337
337
|
// have already passed. We do folder resolution here — by which
|
|
338
338
|
// point engine has set workingFolder and journal + catalog
|
|
339
339
|
// have initialized.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
340
|
+
// A `--<collection> <folder>` flag, if the plugin declared one, wins
|
|
341
|
+
// over the configured folder.
|
|
342
|
+
//
|
|
343
|
+
// Resolved HERE rather than where useSource was called, and that is
|
|
344
|
+
// the whole point: plugin factories run during the load phase, and the
|
|
345
|
+
// CLI table is not complete until after it — so an option read at
|
|
346
|
+
// construction is always undefined. documents() read it there first
|
|
347
|
+
// and `--documents content` silently did nothing, which is the shape
|
|
348
|
+
// of bug this option mechanism exists to prevent rather than create.
|
|
349
|
+
const configured = runtime.options[collection] ?? folder
|
|
350
|
+
absFolder = path.isAbsolute(configured)
|
|
351
|
+
? configured
|
|
352
|
+
: path.join(runtime.options.workingFolder, configured)
|
|
343
353
|
runtime.options[`${collection}Folder`] = absFolder
|
|
344
354
|
// The authoritative set of folders whose files become entities.
|
|
345
355
|
//
|
package/src/tools.js
CHANGED
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
// the first. Closing that by adding a CLI flag per tool would drift the moment
|
|
8
8
|
// anyone added a tool.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// every tool
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
10
|
+
// Both directions. The mcp plugin mirrors its own registrations into here, and
|
|
11
|
+
// binds every tool registered here into each session — prefixing on the way
|
|
12
|
+
// out, since `mikser_` belongs to MCP's flat namespace and not to the engine.
|
|
13
|
+
// So a plugin registers once, against this registry, and reaches both surfaces
|
|
14
|
+
// without depending on the mcp plugin being installed, or on where it sits in
|
|
15
|
+
// the plugins array.
|
|
16
|
+
//
|
|
17
|
+
// `inputSchema` is stored opaquely, which is what makes that possible: a
|
|
18
|
+
// plugin may describe its parameters in the neutral vocabulary below or hand
|
|
19
|
+
// over a zod shape, and the engine passes either through untouched rather than
|
|
20
|
+
// taking a dependency on zod to hold it.
|
|
17
21
|
//
|
|
18
22
|
// So the REGISTRY is substrate and the transports are consumers. ADR-0006's
|
|
19
23
|
// five tests, which MCP itself failed on release cadence:
|
|
@@ -50,6 +54,13 @@ export function registerTool(name, definition = {}, handler) {
|
|
|
50
54
|
runtime.engine?.logger?.debug('Tool %s re-registered, replacing the previous handler', name)
|
|
51
55
|
}
|
|
52
56
|
tools.set(name, {
|
|
57
|
+
// The whole definition, not the three fields core happens to read.
|
|
58
|
+
// A transport needs more than core does — `mutates: true` is how a
|
|
59
|
+
// tool says it changes something, and MCP wraps those differently.
|
|
60
|
+
// Dropping unknown keys here meant a plugin could only reach that
|
|
61
|
+
// behaviour by registering with the mcp plugin directly, which is
|
|
62
|
+
// exactly the coupling this registry exists to remove.
|
|
63
|
+
...definition,
|
|
53
64
|
name,
|
|
54
65
|
description: definition.description ?? '',
|
|
55
66
|
inputSchema: definition.inputSchema ?? {},
|
package/testing/harness.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import _ from 'lodash'
|
|
9
9
|
import realRuntime from '../src/runtime.js'
|
|
10
|
+
import { resetServices } from '../src/services.js'
|
|
10
11
|
import { matchEntity, normalize, changeExtension, getFormatInfo, checksum, AbortError } from '../src/utils.js'
|
|
11
12
|
|
|
12
13
|
const OPERATION = {
|
|
@@ -31,6 +32,11 @@ export function createHarness({
|
|
|
31
32
|
entities = [],
|
|
32
33
|
journal = [],
|
|
33
34
|
} = {}) {
|
|
35
|
+
// A fresh harness is a fresh world. The service registry is module state,
|
|
36
|
+
// so without this the previous test's provider is still registered and
|
|
37
|
+
// constructing the plugin again is (correctly) an error — every package
|
|
38
|
+
// building a plugin per test would otherwise have to remember to clear it.
|
|
39
|
+
resetServices()
|
|
34
40
|
const hookNames = [
|
|
35
41
|
'load', 'loaded', 'import', 'imported',
|
|
36
42
|
'process', 'processed', 'persist', 'persisted',
|