@heroiclands/package-build 6.1.0 → 7.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.
@@ -156,6 +156,25 @@ prefix.apply(log, {
156
156
  },
157
157
  });
158
158
 
159
+ /**
160
+ * Report a command's failure.
161
+ *
162
+ * A configuration error carries its own `file:line:column: error: ` locator
163
+ * (#95), and `loglevel`'s `[timestamp] [ERROR]:` prefix occupies exactly the
164
+ * position a parser reads the path from — so a located failure is printed
165
+ * unprefixed, as `emitDiagnostic` prints every other finding. Everything else
166
+ * is ordinary prose and keeps the log line it always had.
167
+ *
168
+ * @param {unknown} err - What was thrown.
169
+ * @returns {void}
170
+ */
171
+ function reportFailure(err) {
172
+ const message = err instanceof Error ? err.message : String(err);
173
+ if (/** @type {{located?: boolean}} */ (err)?.located)
174
+ console.error(message);
175
+ else log.error(message);
176
+ }
177
+
159
178
  const argv = yargs(hideBin(process.argv))
160
179
  .command(packageCommand())
161
180
  .command(depsCommand())
@@ -303,7 +322,7 @@ function docsCommand() {
303
322
  process.stdout.write(page);
304
323
  }
305
324
  } catch (err) {
306
- log.error(err.message);
325
+ reportFailure(err);
307
326
  process.exitCode = 1;
308
327
  }
309
328
  },
@@ -385,7 +404,7 @@ function lintCommand() {
385
404
  );
386
405
  }
387
406
  } catch (err) {
388
- log.error(err.message);
407
+ reportFailure(err);
389
408
  process.exitCode = 1;
390
409
  }
391
410
  },
@@ -466,7 +485,7 @@ function formatCommand() {
466
485
  log.info(`Formatting is clean (${checked} file(s)).`);
467
486
  }
468
487
  } catch (err) {
469
- log.error(err.message);
488
+ reportFailure(err);
470
489
  process.exitCode = 1;
471
490
  }
472
491
  },
@@ -515,7 +534,7 @@ function markdownCommand() {
515
534
  log.info("Markdown is clean.");
516
535
  }
517
536
  } catch (err) {
518
- log.error(err.message);
537
+ reportFailure(err);
519
538
  process.exitCode = 1;
520
539
  }
521
540
  },
@@ -663,7 +682,7 @@ function linksCommand() {
663
682
  );
664
683
  }
665
684
  } catch (err) {
666
- log.error(err.message);
685
+ reportFailure(err);
667
686
  process.exitCode = 1;
668
687
  }
669
688
  },
@@ -739,7 +758,7 @@ function manifestCommand() {
739
758
  });
740
759
  }
741
760
  } catch (err) {
742
- log.error(err.message);
761
+ reportFailure(err);
743
762
  process.exitCode = 1;
744
763
  }
745
764
  },
@@ -868,7 +887,7 @@ function siteCommand() {
868
887
  `landing(s) to ${path.relative(process.cwd(), s.out)}`,
869
888
  );
870
889
  } catch (err) {
871
- log.error(err.message);
890
+ reportFailure(err);
872
891
  process.exitCode = 1;
873
892
  }
874
893
  },
@@ -962,7 +981,7 @@ function reachabilityCommand() {
962
981
  );
963
982
  }
964
983
  } catch (err) {
965
- log.error(err.message);
984
+ reportFailure(err);
966
985
  process.exitCode = 1;
967
986
  }
968
987
  },
@@ -1052,7 +1071,7 @@ function depsCommand() {
1052
1071
  if (count)
1053
1072
  log.info(`Fetched ${count} dependency catalogue(s).`);
1054
1073
  } catch (err) {
1055
- log.error(err.message);
1074
+ reportFailure(err);
1056
1075
  process.exitCode = 1;
1057
1076
  }
1058
1077
  },
@@ -1262,7 +1281,7 @@ function packageCommand() {
1262
1281
  });
1263
1282
  }
1264
1283
  } catch (err) {
1265
- log.error(err.message);
1284
+ reportFailure(err);
1266
1285
  process.exitCode = 1;
1267
1286
  }
1268
1287
  },
@@ -135,7 +135,14 @@ function ownVersion() {
135
135
  */
136
136
  function die(err) {
137
137
  const message = err instanceof Error ? err.message : String(err);
138
- console.error(`package-build: ${message}`);
138
+ // A located diagnostic already starts with `file:line:column:`, which is
139
+ // exactly the position a parser reads the path from — prefixing it would
140
+ // yield a filename no editor can open (#95).
141
+ console.error(
142
+ /** @type {{located?: boolean}} */ (err)?.located ? message : (
143
+ `package-build: ${message}`
144
+ ),
145
+ );
139
146
  process.exit(1);
140
147
  }
141
148
 
package/config.mjs CHANGED
@@ -59,7 +59,11 @@
59
59
  */
60
60
 
61
61
  import path from "node:path";
62
- import { loadPackConfig } from "./engine/pack-config.mjs";
62
+ import {
63
+ loadPackConfig,
64
+ locateConfigError,
65
+ packConfigPath,
66
+ } from "./engine/pack-config.mjs";
63
67
 
64
68
  /** Keys the reserved section may declare. */
65
69
  const SECTION_KEYS = [
@@ -145,12 +149,21 @@ const ARTIFACT_OF_KIND = Object.freeze({
145
149
  });
146
150
 
147
151
  /**
152
+ * Reject a configured value, naming the key it was written under.
153
+ *
154
+ * The dotted path rides on the error as `field` as well as appearing in the
155
+ * message, so {@link loadPackageBuildConfig} — the half that knows which file
156
+ * was read — can resolve it to a line and column (#95). This half stays pure.
157
+ *
148
158
  * @param {string} where - Dotted path of the offending key.
149
159
  * @param {string} problem - What is wrong with it.
150
160
  * @returns {never}
151
161
  */
152
162
  function fail(where, problem) {
153
- throw new TypeError(`package-build config: \`${where}\` ${problem}.`);
163
+ throw Object.assign(
164
+ new TypeError(`package-build config: \`${where}\` ${problem}.`),
165
+ { field: where },
166
+ );
154
167
  }
155
168
 
156
169
  /**
@@ -842,5 +855,14 @@ export function resolvePackageBuildConfig(shared) {
842
855
  * declares something malformed.
843
856
  */
844
857
  export function loadPackageBuildConfig() {
845
- return resolvePackageBuildConfig(loadPackConfig());
858
+ const shared = loadPackConfig();
859
+ try {
860
+ return resolvePackageBuildConfig(shared);
861
+ } catch (err) {
862
+ // The pure half names the offending key and nothing else; this half
863
+ // knows the file it was read from, so the position is attached here
864
+ // (#95) — the same boundary `configFromData` is for the rest of the
865
+ // configuration.
866
+ throw locateConfigError(err, packConfigPath());
867
+ }
846
868
  }
@@ -584,9 +584,12 @@ const CONFIG_KEYS = [
584
584
  "site",
585
585
  "compatibility",
586
586
  "relationships",
587
+ "systems",
588
+ "requiresSystem",
587
589
  "packageBuild",
588
590
  "publish",
589
591
  ];
592
+ const SYSTEM_KEYS = ["manifest", "compatibility"];
590
593
  const COMPATIBILITY_KEYS = ["minimum", "verified"];
591
594
  const DOCS_KEYS = ["itemFields"];
592
595
  const SITE_KEYS = [
@@ -626,7 +629,24 @@ const PACK_KEYS = [
626
629
  "system",
627
630
  ];
628
631
  const PATH_KEYS = Object.keys(DEFAULT_PATHS);
629
- const STATS_KEYS = ["systemId", "systemVersion", "lastModifiedBy"];
632
+ const STATS_KEYS = ["lastModifiedBy"];
633
+
634
+ /**
635
+ * How the loader hands {@link defineConfig} the system version it resolved.
636
+ *
637
+ * A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
638
+ * configuration (#48), but the value still has to reach here from the loader —
639
+ * which is the half that may do I/O, and which reads a system package's version
640
+ * out of the adjacent `package.json`. A string key would be a second spelling of
641
+ * the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
642
+ * symbol key cannot be written in YAML at all and does not appear in
643
+ * `Object.keys`, so the refusal has no back door.
644
+ *
645
+ * @type {symbol}
646
+ */
647
+ export const DERIVED_SYSTEM_VERSION = Symbol.for(
648
+ "package-build.derivedSystemVersion",
649
+ );
630
650
  const PUBLISH_KEYS = ["site", "manifests", "address"];
631
651
  const MANIFEST_KEYS = ["publish", "consume"];
632
652
  const ADDRESS_KEYS = ["prefix", "landing"];
@@ -637,12 +657,25 @@ function isPlainObject(value) {
637
657
  }
638
658
 
639
659
  /**
640
- * @param {string} field
641
- * @param {string} problem
660
+ * Reject a configured value, naming the key it was written under.
661
+ *
662
+ * The dotted path is carried on the error as `field` as well as spelled into
663
+ * the message, because the message alone is a good description and a bad
664
+ * locator: the loader that read the file can resolve that path to a line and
665
+ * column, and does (`locateConfigError` in `engine/pack-config.mjs`, #95).
666
+ * Attaching it here rather than formatting here is what keeps this module
667
+ * free of I/O — it is the leaf an `.mjs` configuration imports, so it may not
668
+ * reach for the file it is validating.
669
+ *
670
+ * @param {string} field - Dotted path of the offending key.
671
+ * @param {string} problem - What is wrong with it.
642
672
  * @returns {never}
643
673
  */
644
674
  function fail(field, problem) {
645
- throw new TypeError(`package-build config: \`${field}\` ${problem}.`);
675
+ throw Object.assign(
676
+ new TypeError(`package-build config: \`${field}\` ${problem}.`),
677
+ { field },
678
+ );
646
679
  }
647
680
 
648
681
  /**
@@ -869,28 +902,42 @@ function normalizePaths(value, rootDir) {
869
902
  * @param {unknown} value
870
903
  * @returns {Readonly<StatsSpec>}
871
904
  */
872
- function normalizeStats(value) {
905
+ function normalizeStats(value, derived) {
873
906
  if (!isPlainObject(value)) fail("stats", "must be an object");
874
907
  const input = /** @type {Record<string, unknown>} */ (value);
908
+
909
+ // **`systemId` and `systemVersion` are derived, and authoring a derived
910
+ // value is an error rather than an override (#48).** `systems:` is the
911
+ // single source: it says which systems this package stamps against, and
912
+ // `requiresSystem` — or a lone declared system — says which one the
913
+ // package-wide block takes. A system package answers for itself.
914
+ //
915
+ // Refused rather than ignored, because the two would silently disagree.
916
+ // That is exactly how `stats.systemVersion` came to sit at `0.6.0` for four
917
+ // releases: a transcribed copy is free to drift from what it copied, and
918
+ // nothing reads a stamped `_stats` until something migrates on it.
919
+ for (const key of ["systemId", "systemVersion"]) {
920
+ if (input[key] === undefined) continue;
921
+ fail(
922
+ `stats.${key}`,
923
+ `is derived and may not be authored. ` +
924
+ (key === "systemId" ?
925
+ `A system package is its own system; a module takes it ` +
926
+ `from \`requiresSystem\`, or from \`systems:\` when it ` +
927
+ `declares exactly one. `
928
+ : `It is the \`compatibility.verified\` of the system in ` +
929
+ `\`systems:\`, or a system package's own \`package.json\` ` +
930
+ `version. `) +
931
+ `Remove the key`,
932
+ );
933
+ }
875
934
  rejectUnknownKeys(input, STATS_KEYS, "stats.");
876
935
 
877
936
  return Object.freeze({
878
- // Optional: a package whose packs are not all for one system declares
879
- // the system per pack instead (`packs[].system`), and a package that
880
- // ships only system-agnostic documents declares none at all.
881
- systemId:
882
- input.systemId === undefined || input.systemId === null ?
883
- null
884
- : requireNonEmptyString(input.systemId, "stats.systemId"),
885
- // Optional for the same reason as `systemId`: a system-agnostic module
886
- // is not built against a system, so it has no version of one to stamp.
887
- systemVersion:
888
- input.systemVersion === undefined || input.systemVersion === null ?
889
- null
890
- : requireNonEmptyString(
891
- input.systemVersion,
892
- "stats.systemVersion",
893
- ),
937
+ // Per pack where the packs differ see `statsForPack` and this is
938
+ // the package-wide answer for everything that has no pack in hand.
939
+ systemId: derived.systemId,
940
+ systemVersion: derived.systemVersion,
894
941
  lastModifiedBy: requireNonEmptyString(
895
942
  input.lastModifiedBy,
896
943
  "stats.lastModifiedBy",
@@ -1184,6 +1231,98 @@ function normalizeCompatibility(value, where, requireMinimum = true) {
1184
1231
  * @param {unknown} value - The `relationships` block, or `undefined`.
1185
1232
  * @returns {Readonly<Relationships>} It, frozen; `{}` when absent.
1186
1233
  */
1234
+ /**
1235
+ * The systems this package can stamp content against — declaration only (#48).
1236
+ *
1237
+ * **Declaring is not requiring, and that separation is the whole point.** The
1238
+ * only place to state a system version used to be `relationships.systems`, and
1239
+ * that list is a *restriction*: Foundry's `supportsSystem` drops a module from
1240
+ * any world whose system it does not name. So a module shipping content for two
1241
+ * systems — `harn-ensemble` ships an HM3 pack, a SoHL pack and a system-neutral
1242
+ * journals pack — had to choose between naming its systems and remaining
1243
+ * loadable, and choosing the second meant stamping no system version at all on
1244
+ * content that certainly has one.
1245
+ *
1246
+ * Naming a system here restricts nothing. {@link normalizeRequiresSystem} is
1247
+ * what restricts, and it is separate and optional.
1248
+ *
1249
+ * Each entry carries the same `compatibility` shape a relationship does, and
1250
+ * `verified` is what a pack stamps: `_stats.systemVersion` records what the
1251
+ * content was *built against*, not the floor it tolerates.
1252
+ *
1253
+ * @param {unknown} value - The declared `systems:` mapping.
1254
+ * @returns {Readonly<Record<string, Readonly<object>>>} Frozen; `{}` when absent.
1255
+ */
1256
+ function normalizeSystems(value) {
1257
+ if (value === undefined || value === null) return Object.freeze({});
1258
+ if (!isPlainObject(value))
1259
+ fail("systems", "must be a mapping of id to spec");
1260
+ const input = /** @type {Record<string, unknown>} */ (value);
1261
+
1262
+ const out = {};
1263
+ for (const [id, entry] of Object.entries(input)) {
1264
+ const at = `systems.${id}`;
1265
+ if (!id) fail("systems", "declares an empty system id");
1266
+ if (!isPlainObject(entry)) fail(at, "must be a mapping");
1267
+ const spec = /** @type {Record<string, unknown>} */ (entry);
1268
+ rejectUnknownKeys(spec, SYSTEM_KEYS, `${at}.`);
1269
+
1270
+ const compatibility = spec.compatibility;
1271
+ if (!isPlainObject(compatibility)) {
1272
+ fail(`${at}.compatibility`, "must be a mapping");
1273
+ }
1274
+ const compat = /** @type {Record<string, unknown>} */ (compatibility);
1275
+ rejectUnknownKeys(compat, COMPATIBILITY_KEYS, `${at}.compatibility.`);
1276
+ // `verified` is required because it is the value a pack stamps. A
1277
+ // declaration that cannot answer "which version was this built
1278
+ // against" is the gap this block exists to close.
1279
+ const verified = requireNonEmptyString(
1280
+ compat.verified,
1281
+ `${at}.compatibility.verified`,
1282
+ );
1283
+
1284
+ out[id] = Object.freeze({
1285
+ manifest:
1286
+ spec.manifest === undefined || spec.manifest === null ?
1287
+ null
1288
+ : requireNonEmptyString(spec.manifest, `${at}.manifest`),
1289
+ compatibility: Object.freeze({
1290
+ minimum:
1291
+ compat.minimum === undefined || compat.minimum === null ?
1292
+ null
1293
+ : requireNonEmptyString(
1294
+ compat.minimum,
1295
+ `${at}.compatibility.minimum`,
1296
+ ),
1297
+ verified,
1298
+ }),
1299
+ });
1300
+ }
1301
+ return Object.freeze(out);
1302
+ }
1303
+
1304
+ /**
1305
+ * The one system this package refuses to load without, or `null` (#48).
1306
+ *
1307
+ * The gate half of the split. Naming a system here emits
1308
+ * `relationships.systems` for it, which is what Foundry's `supportsSystem`
1309
+ * reads — so the package becomes unavailable under any other system. Omitted,
1310
+ * no relationship is emitted and the package loads anywhere, each pack stamping
1311
+ * whatever its own `system:` names.
1312
+ *
1313
+ * It reuses the {@link normalizeSystems} entry rather than restating the
1314
+ * compatibility: `stats.systemVersion` froze at `0.6.0` for four releases
1315
+ * because a transcription was free to disagree with what it copied, and a
1316
+ * second transcription invites the same.
1317
+ *
1318
+ * @param {unknown} value - The declared `requiresSystem:`.
1319
+ * @returns {string|null} The system id, or `null`.
1320
+ */
1321
+ function normalizeRequiresSystem(value) {
1322
+ if (value === undefined || value === null) return null;
1323
+ return requireNonEmptyString(value, "requiresSystem");
1324
+ }
1325
+
1187
1326
  function normalizeRelationships(value) {
1188
1327
  if (value === undefined) return Object.freeze({});
1189
1328
  if (!isPlainObject(value)) fail("relationships", "must be a mapping");
@@ -1541,6 +1680,54 @@ export function defineConfig(config) {
1541
1680
  seen.add(name);
1542
1681
  }
1543
1682
 
1683
+ // ── systems: declaring, and requiring, are separate decisions (#48) ──────
1684
+ const systems = normalizeSystems(input.systems);
1685
+ const requiresSystem = normalizeRequiresSystem(input.requiresSystem);
1686
+ const declaredSystems = new Set(Object.keys(systems));
1687
+ /** `relationships.systems`, for the derivations that still consult it. */
1688
+ const relationshipSystems = /** @type {{id?: string}[]} */ (
1689
+ (isPlainObject(input.relationships) ?
1690
+ input.relationships.systems
1691
+ : null) ?? []
1692
+ );
1693
+
1694
+ // A name that resolves to nothing is a build error rather than a
1695
+ // fall-through, in the spirit the rest of this file already follows: a pack
1696
+ // stamping a system nobody declared would stamp `undefined`, which is the
1697
+ // plausible lie #43 was about.
1698
+ if (requiresSystem !== null && !declaredSystems.has(requiresSystem)) {
1699
+ fail(
1700
+ "requiresSystem",
1701
+ `names \`${requiresSystem}\`, which \`systems:\` does not declare` +
1702
+ (declaredSystems.size ?
1703
+ `. Declared: ${[...declaredSystems].join(", ")}`
1704
+ : ` — the \`systems:\` block is empty or absent`),
1705
+ );
1706
+ }
1707
+ for (const pack of packs.flatMap((p) => [p, ...p.companions])) {
1708
+ if (!pack.system) continue;
1709
+ if (declaredSystems.size && !declaredSystems.has(pack.system)) {
1710
+ fail(
1711
+ `packs.${pack.name}.system`,
1712
+ `names \`${pack.system}\`, which \`systems:\` does not ` +
1713
+ `declare. Declared: ${[...declaredSystems].join(", ")}`,
1714
+ );
1715
+ }
1716
+ // With a gate set, a pack for any other system could never be seen:
1717
+ // Foundry drops the whole package under a system `requiresSystem` does
1718
+ // not name, so the pack would ship and be unreachable.
1719
+ if (requiresSystem !== null && pack.system !== requiresSystem) {
1720
+ fail(
1721
+ `packs.${pack.name}.system`,
1722
+ `names \`${pack.system}\` while \`requiresSystem\` is ` +
1723
+ `\`${requiresSystem}\`, so this pack could never be seen — ` +
1724
+ `Foundry hides the whole package from any world whose ` +
1725
+ `system \`requiresSystem\` does not name. Drop ` +
1726
+ `\`requiresSystem\`, or correct the pack`,
1727
+ );
1728
+ }
1729
+ }
1730
+
1544
1731
  // Several packs of one document type are allowed — editorial grouping of
1545
1732
  // same-type documents is ordinary Foundry practice, and collapsing such a
1546
1733
  // layout breaks every stored compendium UUID (#1566). What is not allowed
@@ -1592,7 +1779,50 @@ export function defineConfig(config) {
1592
1779
  // one place `systems/sohl` (or `modules/sohl-thalorna`) is spelled.
1593
1780
  assetRoot: `${packageKind}/${foundryPackage}/assets`,
1594
1781
  paths: normalizePaths(input.paths, rootDir),
1595
- stats: normalizeStats(input.stats),
1782
+ // The package-wide system, derived (#48). A **system** package is its
1783
+ // own system, which is true by construction and needs no declaration. A
1784
+ // **module** takes the one it requires, or the one system it declares
1785
+ // when there is exactly one; with several and no gate there is no
1786
+ // package-wide answer, and each pack carries its own.
1787
+ stats: normalizeStats(input.stats, {
1788
+ systemId:
1789
+ packageKind === "systems" ? foundryPackage
1790
+ : requiresSystem ? requiresSystem
1791
+ : Object.keys(systems).length === 1 ? Object.keys(systems)[0]
1792
+ // A lone `relationships.systems` entry is a declaration of
1793
+ // the system as much as a gate, so it still answers. That
1794
+ // matters because the relationship carries `itemCatalog`
1795
+ // too — a separate concern the split does not replace — so
1796
+ // a repository using it would otherwise have to restate its
1797
+ // compatibility under `systems:` purely to keep stamping,
1798
+ // which is the duplication this whole change exists to
1799
+ // remove. Several entries have no single answer and get
1800
+ // none.
1801
+ : relationshipSystems.length === 1 ?
1802
+ (relationshipSystems[0]?.id ?? null)
1803
+ : null,
1804
+ // Derived here where the answer is pure data — the `verified` of
1805
+ // whichever system the package-wide block takes — and supplied by
1806
+ // the loader otherwise. The loader is the half that may do I/O, and
1807
+ // the two cases needing it are a *system* package (its own
1808
+ // `package.json` version) and a module still deriving from
1809
+ // `relationships.systems`.
1810
+ systemVersion:
1811
+ (() => {
1812
+ const id =
1813
+ requiresSystem ??
1814
+ (Object.keys(systems).length === 1 ?
1815
+ Object.keys(systems)[0]
1816
+ : null);
1817
+ return id ?
1818
+ (systems[id]?.compatibility?.verified ?? null)
1819
+ : null;
1820
+ })() ??
1821
+ (isPlainObject(input.stats) ?
1822
+ input.stats[DERIVED_SYSTEM_VERSION]
1823
+ : null) ??
1824
+ null,
1825
+ }),
1596
1826
  itemBuilders,
1597
1827
  itemArt,
1598
1828
  itemFields,
@@ -1616,6 +1846,8 @@ export function defineConfig(config) {
1616
1846
  "compatibility",
1617
1847
  ),
1618
1848
  relationships: normalizeRelationships(input.relationships),
1849
+ systems,
1850
+ requiresSystem,
1619
1851
  packageBuild: normalizePackageBuild(input.packageBuild),
1620
1852
  publish: normalizePublish(input.publish),
1621
1853
  });
@@ -73,6 +73,7 @@ import {
73
73
  convertNoteWikilinks,
74
74
  collectContentDocs,
75
75
  expandNoteTables,
76
+ statsForPack,
76
77
  } from "./helpers.mjs";
77
78
  import { emitDiagnostic } from "./diagnostics.mjs";
78
79
  import { assertNoDeclaredPackage } from "./note-package.mjs";
@@ -237,6 +238,7 @@ export class BasePackCompiler {
237
238
  dest,
238
239
  folderResolver = () => null,
239
240
  packName,
241
+ packSystem = null,
240
242
  docType,
241
243
  router,
242
244
  routingReporter = false,
@@ -262,11 +264,34 @@ export class BasePackCompiler {
262
264
  writable: false,
263
265
  });
264
266
  this.packName = packName;
267
+ this.packSystem = packSystem;
265
268
  this.docType = docType;
266
269
  this.router = router;
267
270
  this.routingReporter = routingReporter;
268
271
  }
269
272
 
273
+ /**
274
+ * The `_stats` block every entry this pass emits is stamped with (#48).
275
+ *
276
+ * Per pack rather than per package, because a module may ship the same
277
+ * content for two systems — `harn-ensemble` has an `actors-hm3` pack and an
278
+ * `actors-sohl` pack — and those documents were built against different
279
+ * system versions. A single global block stamped both identically.
280
+ *
281
+ * Memoised on the instance: one pass, one pack, one system, so the block is
282
+ * constant for the life of the compiler. The previous module-level memo
283
+ * could not be, because it was shared across passes for different packs.
284
+ *
285
+ * @returns {object} The block, built once per compiler.
286
+ */
287
+ get stats() {
288
+ this.#stats ??= statsForPack(this.packSystem);
289
+ return this.#stats;
290
+ }
291
+
292
+ /** @type {object|undefined} */
293
+ #stats;
294
+
270
295
  /**
271
296
  * Whether this pass's pack is the one a claimed note belongs in.
272
297
  *