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 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
@@ -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` everything re-renders with unchanged inputs,
389
- which makes it a full sweep **`mikser --force` after a package upgrade is
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.96.0",
3
+ "version": "9.99.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
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 everything and reports output-drift:
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
- // After the cycle, and only under --json. stdout has been kept clear
1433
- // for exactly this (the logger writes to stderr under --json), so the
1434
- // document is the only thing on it and can be piped to jq.
1435
- emitReport()
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
- for (const key of ['json', 'tool', 'tools']) {
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
  }