mikser-io 10.10.0 → 10.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.10.0",
3
+ "version": "10.11.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -835,7 +835,17 @@ The full version, with what each code means: docs/diagnostics.md`)
835
835
  // creates for itself. By `import` every onLoaded has run and the
836
836
  // registry is complete. Nothing is imported, because this exits first,
837
837
  // the same way --explain and --audit-output do.
838
- if (runtime.options.tools || runtime.options.tool) {
838
+ //
839
+ // --audit-output is here for the same reason, found the same way. It
840
+ // asks which trees hold outputs, and a plugin cannot answer until its
841
+ // own onLoaded has run — the assets plugin resolves the folder its
842
+ // derivatives live in there, because `--assets` is a plugin option
843
+ // and stage two of the parse is what makes it readable. Dispatched
844
+ // from the engine's onLoaded it ran ahead of all of them, so the one
845
+ // check that reports unclaimed files could not see the one tree whose
846
+ // files reach the site through a symlink it does not follow. It
847
+ // reported `0 orphaned` over any amount of debris.
848
+ if (runtime.options.tools || runtime.options.tool || runtime.options.auditOutput) {
839
849
  const code = await runReportOnly()
840
850
  if (code !== null) process.exit(code)
841
851
  }
@@ -877,14 +887,16 @@ The full version, with what each code means: docs/diagnostics.md`)
877
887
  // one implementation, so a forwarded --audit-output cannot disagree with a
878
888
  // local one about what it checked.
879
889
  //
880
- // --tools and --tool are NOT among them: they are dispatched at `import`
881
- // instead, because the tool registry is not complete until every
882
- // plugin's onLoaded has run and this hook runs ahead of all of them.
890
+ // --tools, --tool and --audit-output are NOT among them: they are
891
+ // dispatched at `import` instead, because what they read is not
892
+ // complete until every plugin's onLoaded has run and this hook runs
893
+ // ahead of all of them — the tool registry for the first two, the set
894
+ // of trees that hold outputs for the third.
883
895
  // The import dispatch existed already and was documented as
884
896
  // load-bearing, but this call reached the same code first and exited,
885
897
  // so it never ran — and every tool a plugin registers was missing from
886
898
  // the CLI while the listing looked healthy, just short.
887
- if (!runtime.options.tools && !runtime.options.tool) {
899
+ if (!runtime.options.tools && !runtime.options.tool && !runtime.options.auditOutput) {
888
900
  const code = await runReportOnly()
889
901
  if (code !== null) process.exit(code)
890
902
  }
package/src/manifest.js CHANGED
@@ -1102,7 +1102,7 @@ export function createManifest(db) {
1102
1102
  // Walk the output folder against recorded snapshots, returning
1103
1103
  // a diff describing missing / mismatched / orphaned /
1104
1104
  // unverifiable. Backs `mikser --audit-output`. Pure: no mutations.
1105
- async auditOutput({ outputFolder } = {}) {
1105
+ async auditOutput({ outputFolder, roots } = {}) {
1106
1106
  outputFolder = outputFolder || runtime.options.outputFolder
1107
1107
  const missing = []
1108
1108
  const mismatched = []
@@ -1112,16 +1112,12 @@ export function createManifest(db) {
1112
1112
  const snap = rowToSnap(row)
1113
1113
  if (!snap.destination) continue
1114
1114
  const filePath = resolveOutputPath(snap.destination, outputFolder)
1115
- // Orphan detection compares against a globby walk of
1116
- // outputFolder, so `claimed` has to hold exactly the relative
1117
- // form that walk produces. A destination resolving outside
1118
- // outputFolder can never appear in it and is not claimable;
1119
- // one inside it must be claimed by its relative path, not by
1120
- // the raw string with its leading slashes stripped.
1121
- const relative = path.relative(outputFolder, filePath)
1122
- if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
1123
- claimed.add(relative)
1124
- }
1115
+ // Claimed by ABSOLUTE path, so the set does not depend on the
1116
+ // relative form any particular walk produces. It was keyed on
1117
+ // a path relative to outputFolder, which worked only while
1118
+ // that was the one tree walked — and quietly claimed nothing
1119
+ // for every destination outside it.
1120
+ claimed.add(path.resolve(filePath))
1125
1121
  if (!existsSync(filePath)) {
1126
1122
  missing.push({ id: snap.id, destination: snap.destination })
1127
1123
  continue
@@ -1150,15 +1146,47 @@ export function createManifest(db) {
1150
1146
  }
1151
1147
  }
1152
1148
  const { globby } = await import('globby')
1153
- const onDisk = await globby('**/*', {
1154
- cwd: outputFolder,
1155
- onlyFiles: true,
1156
- followSymbolicLinks: false,
1157
- })
1149
+ // Every tree mikser writes into, not only the output folder.
1150
+ //
1151
+ // Preset derivatives live at the working-folder root and reach
1152
+ // the site through a symlink, which the walk does not follow — so
1153
+ // a file nothing claims there was invisible to this check, which
1154
+ // is the one place the concept of an orphan exists. Plugins
1155
+ // declare their own trees on `runtime.options.auditRoots`,
1156
+ // carrying their own ignore patterns, so nothing here has to know
1157
+ // what a preset is or that a `.md5` beside a derivative is
1158
+ // bookkeeping rather than output.
1159
+ const declared = [{ path: outputFolder }, ...(roots ?? runtime.options.auditRoots ?? [])]
1160
+ const walked = []
1161
+ for (const root of declared) {
1162
+ const resolved = path.resolve(root.path ?? root)
1163
+ // A root inside one already walked would report every file in
1164
+ // it twice.
1165
+ const nested = walked.some(({ resolved: seen }) =>
1166
+ resolved === seen || resolved.startsWith(seen + path.sep))
1167
+ if (nested) continue
1168
+ walked.push({ resolved, ignore: root.ignore ?? [] })
1169
+ }
1158
1170
  const orphaned = []
1159
- for (const rel of onDisk) {
1160
- if (claimed.has(rel)) continue
1161
- orphaned.push({ path: rel })
1171
+ for (const { resolved, ignore } of walked) {
1172
+ const onDisk = await globby('**/*', {
1173
+ cwd: resolved,
1174
+ onlyFiles: true,
1175
+ followSymbolicLinks: false,
1176
+ ignore,
1177
+ })
1178
+ for (const rel of onDisk) {
1179
+ if (claimed.has(path.join(resolved, rel))) continue
1180
+ // Relative to the output folder for the output folder, as
1181
+ // before; relative to the working folder for anything
1182
+ // else, because `web/media/x.jpg` alone does not say which
1183
+ // tree it is in.
1184
+ const isOutput = resolved === path.resolve(outputFolder)
1185
+ const display = isOutput
1186
+ ? rel
1187
+ : path.relative(runtime.options.workingFolder ?? resolved, path.join(resolved, rel))
1188
+ orphaned.push({ path: display, root: resolved })
1189
+ }
1162
1190
  }
1163
1191
  // Reported alongside, not as a mismatch: two entities claiming one
1164
1192
  // destination usually produces NO mismatch at all, because each
@@ -418,11 +418,12 @@ export function assets(options = {}) {
418
418
  }
419
419
 
420
420
  async function forgetPresetMarkers(entity) {
421
- for (const marker of markersByDestination.get(entity.destination) ?? []) {
421
+ const destination = path.resolve(entity.destination)
422
+ for (const marker of markersByDestination.get(destination) ?? []) {
422
423
  await rm(marker, { force: true })
423
424
  checksumMap.delete(marker)
424
425
  }
425
- markersByDestination.delete(entity.destination)
426
+ markersByDestination.delete(destination)
426
427
  }
427
428
 
428
429
  async function renderPresets(entities, { rendererChanged = new Set() } = {}) {
@@ -504,6 +505,7 @@ export function assets(options = {}) {
504
505
  runtime.engine ??= {}
505
506
  runtime.engine.assets = { explainMissing }
506
507
 
508
+
507
509
  // `??`, not `||`: an option commander did not see is undefined, and
508
510
  // falling through to the config is the point. `||` would also fall
509
511
  // through for an intentional empty string, which is a different
@@ -515,6 +517,28 @@ export function assets(options = {}) {
515
517
 
516
518
  runtime.options.assets = runtime.options.assets ?? options.assetsFolder ?? 'assets'
517
519
  runtime.options.assetsFolder = path.join(runtime.options.workingFolder, runtime.options.assets)
520
+ // This tree holds outputs too, and --audit-output could not see it:
521
+ // it walks the output folder, and derivatives reach the site through
522
+ // a symlink the walk does not follow. Declared rather than hardcoded
523
+ // in the manifest, with the ignore that only this plugin can state —
524
+ // a `.md5` beside a derivative is bookkeeping, and without excluding
525
+ // them every derivative on the site would report one orphan.
526
+ //
527
+ // `auditIgnore` is the escape hatch for a preset that writes MORE
528
+ // than one file. The engine records the one destination it handed
529
+ // over, so a poster frame written beside a video is genuinely
530
+ // unclaimed and genuinely reported — correct, and a permanent
531
+ // non-zero exit for a legitimate preset if there were no way to say
532
+ // "these are expected". Stated in config, next to the preset that
533
+ // produces them, rather than inferred.
534
+ const auditRoot = {
535
+ path: runtime.options.assetsFolder,
536
+ ignore: ['**/*.md5', ...(options.auditIgnore ?? [])],
537
+ }
538
+ runtime.options.auditRoots = [
539
+ ...(runtime.options.auditRoots ?? []).filter(root => root.path !== auditRoot.path),
540
+ auditRoot,
541
+ ]
518
542
  logger.debug('Assets folder: %s', runtime.options.assetsFolder)
519
543
 
520
544
  // --clear means "throw away what was derived and build it again", and
@@ -702,27 +726,22 @@ export function assets(options = {}) {
702
726
 
703
727
  checksumMap.clear()
704
728
  markersByDestination.clear()
705
- // Scoped to the preset folders rather than the whole assets tree: the
706
- // assets folder also carries what the files plugin symlinks into it,
707
- // and those have no markers and never will. Unscoped, every one of
708
- // them would read as an interrupted render on every cycle.
709
- const presetFolders = Object.keys(runtime.state.assets.presets).map(name => `${name}/**/*`)
710
- const files = presetFolders.length
711
- ? await globby(presetFolders, { cwd: runtime.options.assetsFolder, onlyFiles: true })
712
- : []
713
- const derivatives = new Set()
714
- for (let file of files) {
715
- const absolute = path.join(runtime.options.assetsFolder, file)
716
- if (!file.endsWith('.md5')) {
717
- derivatives.add(absolute)
718
- continue
719
- }
720
- checksumMap.add(absolute)
729
+ const checksumFiles = await globby('**/*.md5', { cwd: runtime.options.assetsFolder })
730
+ for (let checksumFile of checksumFiles) {
731
+ const marker = path.join(runtime.options.assetsFolder, checksumFile)
732
+ checksumMap.add(marker)
721
733
  // `<destination>.<revision>.md5` — the same shape the orphan
722
734
  // sweep strips to get back to the derivative.
723
- const destination = absolute.replace(/\.[^.]+\.md5$/, '')
735
+ //
736
+ // Resolved on both sides of the comparison, because the other
737
+ // side is a manifest destination and these two are built by
738
+ // different subsystems. It is a no-op while assetsFolder is
739
+ // derived from workingFolder (see onLoaded), which is why no test
740
+ // can tell it apart — normalization at the boundary, not a
741
+ // transform anything depends on.
742
+ const destination = path.resolve(marker.replace(/\.[^.]+\.md5$/, ''))
724
743
  if (!markersByDestination.has(destination)) markersByDestination.set(destination, [])
725
- markersByDestination.get(destination).push(absolute)
744
+ markersByDestination.get(destination).push(marker)
726
745
  }
727
746
 
728
747
  const entitiesToRender = new Map()
@@ -745,28 +764,63 @@ export function assets(options = {}) {
745
764
  // Empty on any healthy build, and the catalog walk is behind that
746
765
  // emptiness: this costs one Set lookup per cycle until something has
747
766
  // actually gone wrong.
748
- const unmarked = new Set()
749
- for (const derivative of derivatives) {
750
- if (!markersByDestination.has(derivative)) unmarked.add(derivative)
767
+ // Asked of the MANIFEST, not of the folder.
768
+ //
769
+ // The first version walked every file under each preset folder and
770
+ // called anything without a marker an interrupted render. But only
771
+ // the destination the engine hands the preset gets a marker, so a
772
+ // preset that legitimately writes MORE than one file — a poster frame
773
+ // beside a video — left permanent evidence of a failure that never
774
+ // happened. It warned on every build for ever, said the derivative
775
+ // was "being re-derived", and re-derived nothing, because no entity's
776
+ // destination ever matched the extra file. A warning that cries wolf
777
+ // on every build is worse than the silence it replaced, and it
778
+ // teaches people to ignore the one that matters.
779
+ //
780
+ // A file nothing claims is an ORPHAN, which is a different fault with
781
+ // its own report. This asks only about outputs the manifest recorded,
782
+ // which is also what makes the recovery possible: a snapshot carries
783
+ // the entity id, so there is nothing to reverse-map and no catalog to
784
+ // walk.
785
+ const presetRoots = Object.keys(presets)
786
+ .map(name => path.resolve(runtime.options.assetsFolder, name) + path.sep)
787
+ const unfinished = new Map()
788
+ for (const snapshot of runtime.manifest?.all?.() ?? []) {
789
+ if (!snapshot.destination) continue
790
+ const destination = path.resolve(snapshot.destination)
791
+ if (!presetRoots.some(root => destination.startsWith(root))) continue
792
+ if (markersByDestination.has(destination)) continue
793
+ // Gone entirely is a different fault, and the engine's
794
+ // missing-output path already has it. Reporting it here as well
795
+ // would name the wrong cause.
796
+ if (outputMissing(snapshot.destination)) continue
797
+ unfinished.set(snapshot.id, destination)
751
798
  }
752
- if (unmarked.size) {
753
- for await (const candidate of iterateEntities({ collection: { $ne: collection } })) {
754
- const candidatePresets = await getEntityPresets(candidate)
755
- const damaged = candidatePresets.filter(name => presets[name]
756
- && unmarked.has(presetDestination(candidate, presets[name])))
757
- if (!damaged.length) continue
758
- assetsMap[candidate.id] ??= candidatePresets
799
+ if (unfinished.size) {
800
+ const damaged = []
801
+ for (const [id, destination] of unfinished) {
802
+ const entity = await findEntity({ id })
803
+ // Source gone: the orphan sweep removes the derivative at
804
+ // finalize. Nothing to re-derive and nothing to say.
805
+ if (!entity) continue
806
+ assetsMap[entity.id] ??= await getEntityPresets(entity)
759
807
  // Forced past the reuse check as well as scheduled: the file
760
808
  // on disk is exactly what must not be trusted, and
761
- // isPresetRendered has no way to tell half-written bytes from
809
+ // isPresetRendered cannot tell half-written bytes from
762
810
  // finished ones.
763
- presetMoved.add(candidate.id)
764
- entitiesToRender.set(candidate.id, candidate)
811
+ presetMoved.add(entity.id)
812
+ entitiesToRender.set(entity.id, entity)
813
+ damaged.push(destination)
814
+ }
815
+ // Only when something is actually being re-derived. The warning
816
+ // claimed the action rather than reporting it, so a build that
817
+ // scheduled nothing still announced a recovery.
818
+ if (damaged.length) {
819
+ useLogger().warn({ code: 'preset-unfinished', derivatives: damaged },
820
+ 'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
821
+ + 'A preset render did not finish — the file left behind is not trustworthy.',
822
+ damaged.length)
765
823
  }
766
- useLogger().warn({ code: 'preset-unfinished', derivatives: [...unmarked], sources: entitiesToRender.size },
767
- 'Assets: %d derivative(s) have no completed-render marker and are being re-derived. '
768
- + 'A preset render did not finish — the file left behind is not trustworthy.',
769
- unmarked.size)
770
824
  }
771
825
 
772
826
  // --render-presets [name]: re-derive though nothing moved.