mikser-io 9.96.0 → 9.99.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 +1 -0
- package/docs/diagnostics.md +15 -3
- package/package.json +1 -1
- package/src/engine.js +63 -5
- package/src/fingerprint.js +144 -0
- package/src/instance.js +15 -2
- package/src/report.js +4 -0
- package/src/runtime.js +17 -0
- package/src/tools.js +2 -1
package/app.js
CHANGED
|
@@ -58,6 +58,7 @@ function locate(argv) {
|
|
|
58
58
|
: tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
|
|
59
59
|
: explain ? { type: 'report', explain, json: has('--json') }
|
|
60
60
|
: has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
|
|
61
|
+
: has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json') }
|
|
61
62
|
: { type: 'build',
|
|
62
63
|
clear: has('--clear'),
|
|
63
64
|
// Not a flag that happens to be set — the client's OUTPUT
|
package/docs/diagnostics.md
CHANGED
|
@@ -385,9 +385,21 @@ the content: an upgraded renderer, a changed helper, a dependency that shifted
|
|
|
385
385
|
under the build.
|
|
386
386
|
|
|
387
387
|
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
|
|
388
|
+
on what moved. Under `--force` every ENTITY re-renders with unchanged inputs,
|
|
389
|
+
which makes it the check for a renderer, helper or dependency that shifted —
|
|
390
|
+
**`mikser --force` after a package upgrade is the regression check for
|
|
391
|
+
rendered output**.
|
|
392
|
+
|
|
393
|
+
It is not a full sweep, and calling it one was wrong. Derivatives are outside
|
|
394
|
+
it: an asset is re-derived when its source changes or its preset's `revision`
|
|
395
|
+
moves, and `--force` is neither — so on a site with image presets, `--force`
|
|
396
|
+
re-renders the documents and touches no derivative at all. A sharp upgrade,
|
|
397
|
+
which is exactly the kind of dependency shift this paragraph promises to
|
|
398
|
+
catch, is invisible to it.
|
|
399
|
+
|
|
400
|
+
Use `--render-presets` for that half, and `--fingerprint` before and after to
|
|
401
|
+
compare both halves at once — it hashes the derivatives too, which is the
|
|
402
|
+
thing `find out -type f` cannot do.
|
|
391
403
|
|
|
392
404
|
### The rest, briefly
|
|
393
405
|
|
package/package.json
CHANGED
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 { fingerprintOutputs } from './fingerprint.js'
|
|
16
17
|
import { toolSchemas, invokeTool, toolResultText, toolResultFailed } from './tools.js'
|
|
17
18
|
import { registerBuiltinTools } from './builtin-tools.js'
|
|
18
19
|
import { useDatabase } from './database/index.js'
|
|
@@ -294,6 +295,17 @@ async function reportMissingAssets(logger, alreadyReported = new Set()) {
|
|
|
294
295
|
// `request` carries the CLIENT's arguments. Reading runtime.options here would
|
|
295
296
|
// answer with the instance's own flags, which are whatever it happened to be
|
|
296
297
|
// started with.
|
|
298
|
+
// Bytes at a size a person reads. Not in the document — that carries the
|
|
299
|
+
// integer, because a caller comparing two builds subtracts.
|
|
300
|
+
function formatBytes(bytes) {
|
|
301
|
+
if (bytes < 1024) return `${bytes} B`
|
|
302
|
+
const units = ['kB', 'MB', 'GB']
|
|
303
|
+
let value = bytes / 1024
|
|
304
|
+
let unit = 0
|
|
305
|
+
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
|
|
306
|
+
return `${value.toFixed(1)} ${units[unit]}`
|
|
307
|
+
}
|
|
308
|
+
|
|
297
309
|
export async function runReportOnly(request = {}) {
|
|
298
310
|
const logger = useLogger()
|
|
299
311
|
const {
|
|
@@ -303,6 +315,7 @@ export async function runReportOnly(request = {}) {
|
|
|
303
315
|
json = runtime.options.json,
|
|
304
316
|
explain = runtime.options.explain,
|
|
305
317
|
auditOutput = runtime.options.auditOutput,
|
|
318
|
+
fingerprint = runtime.options.fingerprint,
|
|
306
319
|
} = request
|
|
307
320
|
|
|
308
321
|
if (tools) {
|
|
@@ -372,6 +385,25 @@ export async function runReportOnly(request = {}) {
|
|
|
372
385
|
return report.found ? 0 : 3
|
|
373
386
|
}
|
|
374
387
|
|
|
388
|
+
if (fingerprint) {
|
|
389
|
+
const result = await fingerprintOutputs()
|
|
390
|
+
if (!result) {
|
|
391
|
+
logger.error('No output folder — nothing to fingerprint.')
|
|
392
|
+
return 2
|
|
393
|
+
}
|
|
394
|
+
if (json) {
|
|
395
|
+
process.stdout.write(JSON.stringify({ version: packageInfo.version, ...result }, null, 2) + '\n')
|
|
396
|
+
} else {
|
|
397
|
+
logger.notice('Output %s — %d file(s), %s',
|
|
398
|
+
result.output.hash, result.output.files, formatBytes(result.output.bytes))
|
|
399
|
+
for (const [name, group] of Object.entries(result.trees)) {
|
|
400
|
+
logger.info(' %s: %s — %d file(s), %s',
|
|
401
|
+
name, group.hash, group.files, formatBytes(group.bytes))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return 0
|
|
405
|
+
}
|
|
406
|
+
|
|
375
407
|
if (auditOutput) {
|
|
376
408
|
if (!runtime.manifest) {
|
|
377
409
|
logger.error('Verify: no manifest available — nothing to check against')
|
|
@@ -517,6 +549,9 @@ export async function setup(options) {
|
|
|
517
549
|
.option('--tools', 'list the tools this build exposes, then exit', false)
|
|
518
550
|
.option('--tool <name>', 'run one tool and print its result, then exit. The same tools an MCP client sees, so an agent reading CLI output and an agent speaking MCP ask the engine the same questions.')
|
|
519
551
|
.option('--tool-args <json>', 'JSON arguments for --tool (e.g. \'{"destination":"/bg/index.html"}\')')
|
|
552
|
+
.option('--fingerprint', 'hash everything this build wrote — including what it wrote through a '
|
|
553
|
+
+ 'symlink, which `find` does not descend into — and exit. One comparable number per output '
|
|
554
|
+
+ 'tree, plus one per asset preset, for proving an upgrade moved no bytes.', false)
|
|
520
555
|
.option('-d --debug', 'display debug statements')
|
|
521
556
|
.option('-t --trace', 'display trace statements')
|
|
522
557
|
.option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
|
|
@@ -544,11 +579,16 @@ Which check answers which question:
|
|
|
544
579
|
with themselves.
|
|
545
580
|
|
|
546
581
|
Did an upgrade change what a render produces?
|
|
547
|
-
--force re-renders
|
|
582
|
+
--force re-renders every ENTITY and reports output-drift:
|
|
548
583
|
same inputs, different bytes. This is the one that
|
|
549
584
|
catches a renderer, helper or dependency moving
|
|
550
585
|
under the build. It also reconciles deletions.
|
|
551
586
|
|
|
587
|
+
NOT derivatives. Assets are re-derived on a preset
|
|
588
|
+
revision or a source change, and --force is
|
|
589
|
+
neither — so a sharp upgrade is invisible to it.
|
|
590
|
+
Use --render-presets for that half.
|
|
591
|
+
|
|
552
592
|
Do the URLs in the output point at anything?
|
|
553
593
|
(runs every build) reads the emitted html and css and resolves each
|
|
554
594
|
reference the way a browser would. Reports what
|
|
@@ -566,6 +606,24 @@ Which check answers which question:
|
|
|
566
606
|
A boot operation: it is refused while an instance
|
|
567
607
|
is running in the same folder.
|
|
568
608
|
|
|
609
|
+
Did an upgrade move any bytes?
|
|
610
|
+
--fingerprint hash everything the build wrote, including what it
|
|
611
|
+
wrote THROUGH A SYMLINK — files() emits by
|
|
612
|
+
symlinking and assets links the derivatives tree
|
|
613
|
+
in, so \`find out -type f\` descends into neither.
|
|
614
|
+
One number for the whole output and one per shared
|
|
615
|
+
tree, stable across runs. Take it before and after
|
|
616
|
+
an upgrade and compare.
|
|
617
|
+
|
|
618
|
+
Is this build one a person is waiting on?
|
|
619
|
+
(automatic) runtime.options.requested is true for a build a
|
|
620
|
+
client asked for — including one forwarded to a
|
|
621
|
+
running instance — and false for a watcher's own
|
|
622
|
+
cycle. An expensive check reads it to stand down in
|
|
623
|
+
the dev loop without standing down forever: an
|
|
624
|
+
instance is ALWAYS in watch mode, so watch alone
|
|
625
|
+
answers the wrong question.
|
|
626
|
+
|
|
569
627
|
What did this build do, and cost?
|
|
570
628
|
--json the whole report as one document on stdout, with
|
|
571
629
|
every warning carrying a stable code, and per-phase
|
|
@@ -1429,10 +1487,10 @@ The full version, with what each code means: docs/diagnostics.md`)
|
|
|
1429
1487
|
const brokenTargets = await reportBrokenReferences(useLogger())
|
|
1430
1488
|
await reportMissingAssets(useLogger(), brokenTargets)
|
|
1431
1489
|
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1490
|
+
// The report is NOT emitted here any more. This hook is registered
|
|
1491
|
+
// when the engine is imported, so it runs first among finalized hooks
|
|
1492
|
+
// and every plugin's findings would land after the document was
|
|
1493
|
+
// written. runtime.finalize() emits it once every hook has run.
|
|
1436
1494
|
|
|
1437
1495
|
// Non-zero for a one-shot build, so `mikser && mikser --audit-output` cannot
|
|
1438
1496
|
// pass with every page in the site stale. `exitCode` rather than
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// What this build actually wrote, as one comparable number.
|
|
2
|
+
//
|
|
3
|
+
// Proving an upgrade moved no bytes is the check every mikser project needs,
|
|
4
|
+
// and it is the one thing a shell script cannot compute correctly from
|
|
5
|
+
// outside. A real one, written twice and wrong both times, hit all of this:
|
|
6
|
+
//
|
|
7
|
+
// - `find out -type f` does NOT descend into a symlink, and files() emits by
|
|
8
|
+
// symlinking while assets symlinks the whole derivatives tree in. Every
|
|
9
|
+
// "byte-identical" it printed was a statement about html, css and js only,
|
|
10
|
+
// with the derivatives silently excluded.
|
|
11
|
+
// - the derivatives live wherever `assetsFolder` says, which is config the
|
|
12
|
+
// script had to re-derive.
|
|
13
|
+
// - hashing per directory block meant the order depended on the order the
|
|
14
|
+
// blocks came back, and two runs over byte-identical trees hashed
|
|
15
|
+
// differently — a false CHANGED, which sends someone hunting a regression
|
|
16
|
+
// that never happened.
|
|
17
|
+
// - which presets are cheap to re-render (sharp, an npm dependency that an
|
|
18
|
+
// upgrade CAN change) and which are not (ffmpeg, a host binary it cannot)
|
|
19
|
+
// had to be inferred by grepping the preset sources.
|
|
20
|
+
//
|
|
21
|
+
// The engine knows all four without inferring anything. So it answers, and the
|
|
22
|
+
// script that orchestrates the upgrade keeps only the part that is genuinely
|
|
23
|
+
// its own: talking to npm.
|
|
24
|
+
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { createReadStream } from 'node:fs'
|
|
28
|
+
import { stat, lstat } from 'node:fs/promises'
|
|
29
|
+
import { globby } from 'globby'
|
|
30
|
+
import runtime from './runtime.js'
|
|
31
|
+
|
|
32
|
+
function sha256(value) {
|
|
33
|
+
return createHash('sha256').update(value).digest('hex')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Streamed, not read into memory. A derivatives tree is the largest thing in
|
|
37
|
+
// the output and the reason to fingerprint at all; loading a 40MB video to
|
|
38
|
+
// hash it would make the check cost more than the build.
|
|
39
|
+
function hashFile(file) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const hash = createHash('sha256')
|
|
42
|
+
createReadStream(file)
|
|
43
|
+
.on('error', reject)
|
|
44
|
+
.on('data', chunk => hash.update(chunk))
|
|
45
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One hash over a sorted list of `path\0contentHash` lines.
|
|
50
|
+
//
|
|
51
|
+
// The sort lives HERE and nowhere else. It used to be applied to the file list
|
|
52
|
+
// as well, which made this one redundant and therefore untestable — and a
|
|
53
|
+
// redundant guard is one that gets removed later by someone who checks that
|
|
54
|
+
// the tests still pass. Sorting where the hash is computed also makes each
|
|
55
|
+
// tree group order-independent on its own, rather than only by inheritance
|
|
56
|
+
// from the walk.
|
|
57
|
+
//
|
|
58
|
+
// The path is part of the input: a file that moved is a change, and a hash of
|
|
59
|
+
// contents alone would call a rename identical.
|
|
60
|
+
// Exported because these two properties — order independence and path
|
|
61
|
+
// sensitivity — are properties of the HASH, not of a build, and a scenario
|
|
62
|
+
// cannot force globby to return files in a hostile order to check them.
|
|
63
|
+
export function combineEntries(entries) {
|
|
64
|
+
const lines = entries
|
|
65
|
+
.map(({ file, hash }) => `${file}\0${hash}`)
|
|
66
|
+
.sort()
|
|
67
|
+
.join('\n')
|
|
68
|
+
return sha256(lines)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
// Everything the build wrote, including what it wrote through a symlink.
|
|
73
|
+
//
|
|
74
|
+
// `followSymbolicLinks: true` is the whole point — see above. `onlyFiles`
|
|
75
|
+
// keeps directory symlinks from being listed as entries in their own right
|
|
76
|
+
// while still descending through them.
|
|
77
|
+
export async function fingerprintOutputs() {
|
|
78
|
+
const outputFolder = runtime.options?.outputFolder
|
|
79
|
+
if (!outputFolder) return null
|
|
80
|
+
|
|
81
|
+
const files = await globby('**/*', {
|
|
82
|
+
cwd: outputFolder,
|
|
83
|
+
followSymbolicLinks: true,
|
|
84
|
+
onlyFiles: true,
|
|
85
|
+
suppressErrors: true,
|
|
86
|
+
dot: true,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const entries = []
|
|
90
|
+
let bytes = 0
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const absolute = path.join(outputFolder, file)
|
|
93
|
+
try {
|
|
94
|
+
const info = await stat(absolute)
|
|
95
|
+
bytes += info.size
|
|
96
|
+
entries.push({ file, hash: await hashFile(absolute), size: info.size })
|
|
97
|
+
} catch { /* vanished mid-walk — a concurrent build, not our business */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Broken out per shared tree, because the parts of an output answer
|
|
101
|
+
// different questions about an upgrade. A preset rendering through an npm
|
|
102
|
+
// dependency (sharp) can move when that dependency does; one shelling out
|
|
103
|
+
// to a host binary (ffmpeg) cannot, and re-rendering it to find that out
|
|
104
|
+
// costs minutes. A caller comparing releases can hash the whole output, or
|
|
105
|
+
// just the groups whose renderer an upgrade could have touched.
|
|
106
|
+
//
|
|
107
|
+
// The trees are found by asking the OUTPUT, not the plugin that made them.
|
|
108
|
+
// A report-only command runs in an onLoaded registered when the engine is
|
|
109
|
+
// imported, which is before every plugin's — so runtime.options.assets is
|
|
110
|
+
// not set yet and reading it grouped nothing at all. The filesystem knows
|
|
111
|
+
// the same fact and knows it in every phase: a top-level entry that is a
|
|
112
|
+
// symlink to a directory is a tree emitted from elsewhere, which is what
|
|
113
|
+
// both assets and resources produce and what `find` refuses to descend
|
|
114
|
+
// into.
|
|
115
|
+
const groups = {}
|
|
116
|
+
for (const name of new Set(entries.map(e => e.file.split('/')[0]))) {
|
|
117
|
+
const top = path.join(outputFolder, name)
|
|
118
|
+
let linked = false
|
|
119
|
+
try { linked = (await lstat(top)).isSymbolicLink() } catch { continue }
|
|
120
|
+
if (!linked) continue
|
|
121
|
+
const prefix = `${name}/`
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.file.startsWith(prefix)) continue
|
|
124
|
+
// `<tree>/<group>/…` — the preset, for an assets tree. A tree
|
|
125
|
+
// holding files directly is reported under its own name.
|
|
126
|
+
const rest = entry.file.slice(prefix.length).split('/')
|
|
127
|
+
const label = rest.length > 1 ? `${name}/${rest[0]}` : name
|
|
128
|
+
const group = (groups[label] ??= { files: [], bytes: 0 })
|
|
129
|
+
group.files.push(entry)
|
|
130
|
+
group.bytes += entry.size
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const trees = groups
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
output: { hash: combineEntries(entries), files: entries.length, bytes },
|
|
137
|
+
trees: Object.fromEntries(
|
|
138
|
+
Object.entries(trees)
|
|
139
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
140
|
+
.map(([name, group]) => [name, {
|
|
141
|
+
hash: combineEntries(group.files), files: group.files.length, bytes: group.bytes,
|
|
142
|
+
}])),
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/instance.js
CHANGED
|
@@ -71,7 +71,7 @@ export function socketPath(workingFolder) {
|
|
|
71
71
|
// ship together, so there is nothing to negotiate and no version to carry.
|
|
72
72
|
//
|
|
73
73
|
// → { type: 'build', config, clear, renderPresets }
|
|
74
|
-
// → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, json }
|
|
74
|
+
// → { type: 'report', config, tool, tools, toolArgs, explain, auditOutput, fingerprint, json }
|
|
75
75
|
// ← { type: 'log', chunk } (zero or more, in order)
|
|
76
76
|
// ← { type: 'done', code }
|
|
77
77
|
// ← { type: 'refused', reason, detail }
|
|
@@ -399,7 +399,20 @@ function refuseStale(socket, movedFile) {
|
|
|
399
399
|
// not put the instance into json mode for everyone.
|
|
400
400
|
async function withRequestOutput(request, run) {
|
|
401
401
|
const prior = {}
|
|
402
|
-
|
|
402
|
+
// `requested` is not a flag the client sent — it is the fact that a client
|
|
403
|
+
// sent anything at all.
|
|
404
|
+
//
|
|
405
|
+
// An instance is always in watch or server mode, so a plugin asking "am I
|
|
406
|
+
// in the dev loop" gets `yes` for a build a PERSON just typed and is
|
|
407
|
+
// waiting on. An expensive check that stands down in the dev loop then
|
|
408
|
+
// stands down forever on a project whose documented model is a watcher
|
|
409
|
+
// always up — it never runs, and says nothing, which is the failure this
|
|
410
|
+
// whole surface exists to remove.
|
|
411
|
+
//
|
|
412
|
+
// Set for the duration of the request and restored with the rest, so a
|
|
413
|
+
// watcher's own cycle before or after is unaffected.
|
|
414
|
+
request = { ...request, requested: true }
|
|
415
|
+
for (const key of ['json', 'tool', 'tools', 'requested']) {
|
|
403
416
|
prior[key] = runtime.options[key]
|
|
404
417
|
if (request[key]) runtime.options[key] = request[key]
|
|
405
418
|
}
|
package/src/report.js
CHANGED
|
@@ -133,6 +133,10 @@ export function resetReport() {
|
|
|
133
133
|
// an import here would close the cycle.
|
|
134
134
|
runtime.resetReport = resetReport
|
|
135
135
|
|
|
136
|
+
// And so it can emit the report AFTER every finalized hook, for the same
|
|
137
|
+
// reason and by the same route. See runtime.finalize().
|
|
138
|
+
runtime.emitReport = emitReport
|
|
139
|
+
|
|
136
140
|
// End of a cycle: stamp it, file it, and wake anyone waiting on it.
|
|
137
141
|
export function finishCycle() {
|
|
138
142
|
if (!runtime.state?.cycle || runtime.state.cycle.finishedAt) return
|
package/src/runtime.js
CHANGED
|
@@ -213,6 +213,23 @@ const runtime = {
|
|
|
213
213
|
async finalize(signal) {
|
|
214
214
|
await this.callHooks(this.hooks.finalize, signal, 'finalize')
|
|
215
215
|
await this.callHooks(this.hooks.finalized, signal, 'finalized')
|
|
216
|
+
|
|
217
|
+
// The report is the LAST thing that happens, after every hook that
|
|
218
|
+
// could still add to it.
|
|
219
|
+
//
|
|
220
|
+
// It used to be emitted from the engine's own onFinalized — which is
|
|
221
|
+
// registered when the engine module is imported, so it ran FIRST among
|
|
222
|
+
// finalized hooks and every plugin's findings landed after the
|
|
223
|
+
// document was already written. A plugin could print a warning to the
|
|
224
|
+
// console and have it absent from --json, with nothing to suggest the
|
|
225
|
+
// two disagreed.
|
|
226
|
+
//
|
|
227
|
+
// That is not a lint bug or a schemas bug; it is one ordering bug that
|
|
228
|
+
// every plugin inherits, which is why it is fixed here rather than in
|
|
229
|
+
// each of them. A finding raised through logger.warn reaches the
|
|
230
|
+
// report because the report is a VIEW of that stream — and a view has
|
|
231
|
+
// to be taken after the writing stops.
|
|
232
|
+
await this.emitReport?.()
|
|
216
233
|
},
|
|
217
234
|
|
|
218
235
|
async sync(operation) {
|
package/src/tools.js
CHANGED
|
@@ -129,5 +129,6 @@ export function toolResultFailed(result) {
|
|
|
129
129
|
// settle and Node drained the loop and left.
|
|
130
130
|
export function isReportOnlyRun() {
|
|
131
131
|
const options = runtime.options ?? {}
|
|
132
|
-
return Boolean(options.explain || options.auditOutput || options.tool || options.tools
|
|
132
|
+
return Boolean(options.explain || options.auditOutput || options.tool || options.tools
|
|
133
|
+
|| options.fingerprint)
|
|
133
134
|
}
|