mikser-io 9.96.0 → 9.97.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.96.0",
3
+ "version": "9.97.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')
@@ -566,6 +601,15 @@ Which check answers which question:
566
601
  A boot operation: it is refused while an instance
567
602
  is running in the same folder.
568
603
 
604
+ Did an upgrade move any bytes?
605
+ --fingerprint hash everything the build wrote, including what it
606
+ wrote THROUGH A SYMLINK — files() emits by
607
+ symlinking and assets links the derivatives tree
608
+ in, so \`find out -type f\` descends into neither.
609
+ One number for the whole output and one per shared
610
+ tree, stable across runs. Take it before and after
611
+ an upgrade and compare.
612
+
569
613
  What did this build do, and cost?
570
614
  --json the whole report as one document on stdout, with
571
615
  every warning carrying a stable code, and per-phase
@@ -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 }
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
  }