@heroiclands/package-build 8.1.0 → 10.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.
Files changed (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -87,9 +87,7 @@ const LEVEL_SPLITTING_CLI = "3.0.3";
87
87
  */
88
88
  export function compendiumCliVersion() {
89
89
  try {
90
- return createRequire(import.meta.url)(
91
- "@foundryvtt/foundryvtt-cli/package.json",
92
- ).version;
90
+ return createRequire(import.meta.url)("@foundryvtt/foundryvtt-cli/package.json").version;
93
91
  } catch {
94
92
  return undefined;
95
93
  }
@@ -128,8 +126,7 @@ function levelIdOf(entry) {
128
126
  * @returns {string} A phrase naming the entry, never `[object Object]`.
129
127
  */
130
128
  function describeEntry(entry, index) {
131
- if (isInlineLevel(entry))
132
- return `\`levels[${index}]\` is ${describeShape(entry)}`;
129
+ if (isInlineLevel(entry)) return `\`levels[${index}]\` is ${describeShape(entry)}`;
133
130
  return `\`levels[${index}]\` is ${JSON.stringify(entry) ?? String(entry)}`;
134
131
  }
135
132
 
@@ -234,15 +231,11 @@ function describeRemedy(cliVersion) {
234
231
  * @returns {string} One report covering every affected scene.
235
232
  */
236
233
  function describeWholesaleLoss(declaring, cliVersion) {
237
- const inline = declaring.some(([, scene]) =>
238
- declaredLevels(scene).some(isInlineLevel),
239
- );
234
+ const inline = declaring.some(([, scene]) => declaredLevels(scene).some(isInlineLevel));
240
235
  const list = declaring
241
236
  .map(([sceneId, scene]) => {
242
237
  const entries = declaredLevels(scene)
243
- .map((entry) =>
244
- isInlineLevel(entry) ? describeShape(entry) : `"${entry}"`,
245
- )
238
+ .map((entry) => (isInlineLevel(entry) ? describeShape(entry) : `"${entry}"`))
246
239
  .join(", ");
247
240
  return `${nameScene(sceneId, scene)} → ${entries}`;
248
241
  })
@@ -329,9 +322,7 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
329
322
  // No scene in the pack has a Level record: one fact about the compile,
330
323
  // which saying per scene would bury. Scenes declaring nothing are not
331
324
  // covered by it, so they are still judged individually below.
332
- const declaring = scenes.filter(
333
- ([, scene]) => declaredLevels(scene).length,
334
- );
325
+ const declaring = scenes.filter(([, scene]) => declaredLevels(scene).length);
335
326
  const wholesale = levelKeys.size === 0 && declaring.length > 0;
336
327
  if (wholesale) problems.push(describeWholesaleLoss(declaring, cliVersion));
337
328
 
@@ -357,9 +348,7 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
357
348
  `${LEVEL_SPLITTING_CLI} writes.`,
358
349
  );
359
350
  } else if (!id) {
360
- broken.push(
361
- `${where}: ${describeEntry(entry, index)}, not a Level id.`,
362
- );
351
+ broken.push(`${where}: ${describeEntry(entry, index)}, not a Level id.`);
363
352
  } else if (!levelKeys.has(`${sceneId}.${id}`)) {
364
353
  broken.push(
365
354
  `${where} lists level "${id}", but no record exists at ` +
@@ -372,16 +361,13 @@ export function checkSceneLevels(records, { cliVersion } = {}) {
372
361
  // A broken entry is already reported above; only the declaration
373
362
  // itself is judged here, so nothing is reported twice.
374
363
  if (broken.length) problems.push(...broken);
375
- else
376
- problems.push(...checkDeclaredLevels(scene ?? {}, declared, where));
364
+ else problems.push(...checkDeclaredLevels(scene ?? {}, declared, where));
377
365
  }
378
366
 
379
367
  for (const adventure of adventures) {
380
368
  const inline = Array.isArray(adventure?.scenes) ? adventure.scenes : [];
381
369
  for (const scene of inline) {
382
- const levelIds = declaredLevels(scene).map(
383
- (level) => levelIdOf(level) ?? level,
384
- );
370
+ const levelIds = declaredLevels(scene).map((level) => levelIdOf(level) ?? level);
385
371
  const where =
386
372
  `Adventure "${adventure?.name ?? adventure?._id}" scene ` +
387
373
  `"${scene?.name ?? scene?._id}"`;
package/engine/scenes.mjs CHANGED
@@ -51,25 +51,21 @@ import fs from "fs";
51
51
  import path from "path";
52
52
  import log from "loglevel";
53
53
 
54
- import {
55
- walkMarkdownTree,
56
- sohlField,
57
- resolveName,
58
- slugify,
59
- defaultStats,
60
- } from "./helpers.mjs";
54
+ import { walkMarkdownTree, sohlField, resolveName, slugify, defaultStats } from "./helpers.mjs";
61
55
  import { BasePackCompiler } from "./base-compiler.mjs";
62
56
  import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
63
57
  import { compendiumUuid, makeId, packForType } from "./ids.mjs";
64
58
  import { packRouter } from "./pack-router.mjs";
65
59
  import { foundryPackageId } from "./content-package.mjs";
66
60
  import { itemDocEntryId } from "./item-docs.mjs";
61
+ import { behaviorDocId, buildScene, isMapType, regionDocId } from "./map-notes.mjs";
67
62
  import {
68
- behaviorDocId,
69
- buildScene,
70
- isMapType,
71
- regionDocId,
72
- } from "./map-notes.mjs";
63
+ RETIRED_FIELD_ALIASES,
64
+ declaresRetiredAlias,
65
+ locateFrontmatterKey,
66
+ readAliasedField,
67
+ retiredAliasMessage,
68
+ } from "./retired-fields.mjs";
73
69
 
74
70
  /**
75
71
  * Every SoHL action name this build knows about, for the `action:` warning on a
@@ -158,9 +154,7 @@ export class Scenes extends BasePackCompiler {
158
154
  }) {
159
155
  super({ contentBase, dest, folderResolver });
160
156
  if (!companionDests.adventures) {
161
- throw new Error(
162
- "Scenes compiler requires an `adventures` companion destination",
163
- );
157
+ throw new Error("Scenes compiler requires an `adventures` companion destination");
164
158
  }
165
159
  Object.defineProperty(this, "adventureDir", {
166
160
  value: companionDests.adventures,
@@ -188,30 +182,21 @@ export class Scenes extends BasePackCompiler {
188
182
  #collect() {
189
183
  const maps = [];
190
184
  const effectsByAddress = new Map();
191
- for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
192
- this.contentBase,
193
- )) {
185
+ for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(this.contentBase)) {
194
186
  // No retired-field test: this pass's own walk — the shared compile
195
187
  // loop — is where a note still declaring `package:` (#56) or
196
188
  // `draft:` (#69) is reported, once. Repeating either check here
197
189
  // would double the diagnostic or throw past it. A refused note is
198
190
  // indexed and then never compiled, so it reaches no document.
199
191
  if (!fm || !fm.id) continue;
200
- if (
201
- fm.shortcode &&
202
- Array.isArray(fm.effects) &&
203
- fm.effects.length
204
- ) {
192
+ if (fm.shortcode && Array.isArray(fm.effects) && fm.effects.length) {
205
193
  effectsByAddress.set(`${fm.type}-${fm.shortcode}`, {
206
194
  id: fm.id,
207
195
  type: fm.type,
208
196
  // Where the owning item landed, so a region behaviour's
209
197
  // effect reference addresses the right pack when a
210
198
  // repository ships several of one type (#1566).
211
- pack: packRouter().resolveOrNull(
212
- fm,
213
- packForType(fm.type).docType,
214
- ),
199
+ pack: packRouter().resolveOrNull(fm, packForType(fm.type).docType),
215
200
  effects: fm.effects,
216
201
  });
217
202
  }
@@ -239,17 +224,13 @@ export class Scenes extends BasePackCompiler {
239
224
  throw new Error(`Map note missing shortcode: ${absPath}`);
240
225
  }
241
226
  if (index.has(fm.shortcode)) {
242
- throw new Error(
243
- `Two map notes share the shortcode "${fm.shortcode}"`,
244
- );
227
+ throw new Error(`Two map notes share the shortcode "${fm.shortcode}"`);
245
228
  }
246
229
  const regions = new Map();
247
230
  for (const [key, spec] of Object.entries(fm.sohl?.regions ?? {})) {
248
231
  const id = regionDocId(fm.id, key, spec?._id);
249
232
  const behaviors = new Map();
250
- for (const [bKey, bSpec] of Object.entries(
251
- spec?.behaviors ?? {},
252
- )) {
233
+ for (const [bKey, bSpec] of Object.entries(spec?.behaviors ?? {})) {
253
234
  behaviors.set(bKey, behaviorDocId(id, bKey, bSpec?._id));
254
235
  }
255
236
  regions.set(key, { id, behaviors });
@@ -283,9 +264,7 @@ export class Scenes extends BasePackCompiler {
283
264
  const mapKey = addr.map ?? selfShortcode;
284
265
  const target = index.get(mapKey);
285
266
  if (!target) {
286
- throw new Error(
287
- `${label}: no map note has the shortcode "${mapKey}"`,
288
- );
267
+ throw new Error(`${label}: no map note has the shortcode "${mapKey}"`);
289
268
  }
290
269
  const region = target.regions.get(addr.region);
291
270
  if (!region) {
@@ -305,9 +284,7 @@ export class Scenes extends BasePackCompiler {
305
284
  resolveBehaviorRef: (addr, label) => {
306
285
  const { target, region } = lookupRegion(addr, label);
307
286
  if (!addr.behavior) {
308
- throw new Error(
309
- `${label}: a behaviour reference is {map, region, behavior}`,
310
- );
287
+ throw new Error(`${label}: a behaviour reference is {map, region, behavior}`);
311
288
  }
312
289
  const behaviorId = region.behaviors.get(addr.behavior);
313
290
  if (!behaviorId) {
@@ -317,8 +294,7 @@ export class Scenes extends BasePackCompiler {
317
294
  );
318
295
  }
319
296
  return (
320
- `Scene.${target.sceneId}.Region.${region.id}` +
321
- `.RegionBehavior.${behaviorId}`
297
+ `Scene.${target.sceneId}.Region.${region.id}` + `.RegionBehavior.${behaviorId}`
322
298
  );
323
299
  },
324
300
  resolveEffectRef: (addr, label) => {
@@ -409,6 +385,17 @@ export class Scenes extends BasePackCompiler {
409
385
  // pass has to read the other's output.
410
386
  const entryId = hasBody ? itemDocEntryId(fm.id) : undefined;
411
387
  const folder = this.folderResolver(sohlField(fm, "folder", null));
388
+ // The retired spelling of the background art, reported where an author
389
+ // meets it soonest — every consumer runs the compile, and not every
390
+ // one runs the lint (#142). Located by reading the note back, which is
391
+ // what the other retired-field reports do: this is the one path that
392
+ // needs the position, so it is paid for only here.
393
+ if (declaresRetiredAlias(fm, "img")) {
394
+ this.noteWarn(
395
+ retiredAliasMessage(RETIRED_FIELD_ALIASES.img, "img"),
396
+ locateFrontmatterKey(this.currentNote?.absPath, RETIRED_FIELD_ALIASES.img),
397
+ );
398
+ }
412
399
  const warnings = [];
413
400
  const scene = buildScene(fm, {
414
401
  packageId: foundryPackageId(),
@@ -420,8 +407,7 @@ export class Scenes extends BasePackCompiler {
420
407
  // default JournalEntry pack, not in whichever Scene pack the map
421
408
  // itself was routed to (#1566).
422
409
  journalPack: packRouter().defaultOf("JournalEntry"),
423
- pageIds:
424
- hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
410
+ pageIds: hasBody ? this.#pageIds(markdown, entryId, name) : new Map(),
425
411
  knownActions: this.knownActions,
426
412
  warnings,
427
413
  ...this.#resolvers(this.index, this.effectsByAddress, fm.shortcode),
@@ -455,7 +441,7 @@ export class Scenes extends BasePackCompiler {
455
441
  this.places.set(placeKey, {
456
442
  key: placeKey,
457
443
  name: sohlField(fm, "placeName", null) || name,
458
- img: sohlField(fm, "image", null),
444
+ img: readAliasedField(fm, "img") ?? null,
459
445
  pinned: false,
460
446
  scenes: [],
461
447
  journal: [],
@@ -489,9 +475,7 @@ export class Scenes extends BasePackCompiler {
489
475
 
490
476
  /** @inheritdoc */
491
477
  reportCompiled(stats) {
492
- log.info(
493
- `Compiled ${stats.compiled} scene(s) and ${this.adventureCount} adventure(s)`,
494
- );
478
+ log.info(`Compiled ${stats.compiled} scene(s) and ${this.adventureCount} adventure(s)`);
495
479
  }
496
480
 
497
481
  /**
@@ -32,10 +32,20 @@
32
32
  * documents' `system` keys against `defineSchema()` **by hand**, which is how
33
33
  * the next one would have to be found too.
34
34
  *
35
- * **The emitted half needs no compilation and no parsing.** A builder *is* its
35
+ * **The declared half of the emission is statically known.** A builder *is* its
36
36
  * field list — {@link module:engine/field-spec} makes `buildFromFields` the only
37
- * statement of the mapping — so every `system` path a type can emit is
38
- * `field.to`, known statically. Nothing here compiles a document to find out.
37
+ * statement of the mapping — so every `system` path a *declared field* emits is
38
+ * `field.to`. {@link compareFields} needs no compilation to read that.
39
+ *
40
+ * **The rest of the emission is only observable.** A compiler writes keys of
41
+ * its own alongside the declared fields — `shortcode`, `actionDefs`, `notes`,
42
+ * `docHtml`, and since #126 `archetype` — and those appear in neither set
43
+ * {@link compareFields} compares, so nothing compared them at all (#155). They
44
+ * cannot be listed here without the list going stale the next time a compiler
45
+ * grows a key, so they are read off the `system` object the compiler produced:
46
+ * {@link compareEmittedSystem} takes the assembled block and asks what the
47
+ * schema does not declare. That is the same question, against the same schema,
48
+ * with the emitted set *observed* rather than derived.
39
49
  *
40
50
  * **The declared half is the consumer's, and arrives as data.** `defineSchema()`
41
51
  * lives in the target system's `src/`, so the system publishes its field sets as
@@ -56,6 +66,8 @@ import fs from "node:fs";
56
66
  import path from "node:path";
57
67
 
58
68
  import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
69
+ import { loadPackConfig } from "./pack-config.mjs";
70
+ import { systemData, systemDataPaths, undeclaredPaths } from "./system-block.mjs";
59
71
 
60
72
  /**
61
73
  * The artifact version this module reads.
@@ -239,6 +251,186 @@ export function compareFields({
239
251
  return { undeclared, unemitted, skipped };
240
252
  }
241
253
 
254
+ /**
255
+ * One `system` key a compiled document carries that its subtype does not
256
+ * declare.
257
+ *
258
+ * @typedef {object} EmissionFinding
259
+ * @property {string} type - The content type whose note produced the document.
260
+ * @property {string} subtype - The document subtype it compiled into.
261
+ * @property {string} documentType - `Item`, `Actor`, …
262
+ * @property {string} field - The undeclared path, dotted.
263
+ * @property {string} systemVersion - The version checked against.
264
+ * @property {"builder"|"compiler"} origin - What wrote it. See
265
+ * {@link emittedUndeclaredMessage}.
266
+ */
267
+
268
+ /**
269
+ * Whether a value is a mapping this walk descends into. Arrays are leaves: a
270
+ * schema declares an `ArrayField` as one path and says nothing about its
271
+ * indices.
272
+ *
273
+ * @param {unknown} value - The emitted value.
274
+ * @returns {boolean} Whether to walk into it.
275
+ */
276
+ function isMapping(value) {
277
+ return value != null && typeof value === "object" && !Array.isArray(value);
278
+ }
279
+
280
+ /**
281
+ * Every declared path that has at least one declared path beneath it.
282
+ *
283
+ * The artifact does not describe every subtree it declares. A discriminated
284
+ * `TypedSchemaField` — SoHL's `strikeMode` — is published as the single path
285
+ * `strikeMode`, because its sub-schema is chosen by a discriminator at runtime
286
+ * and there is no one field list to publish; the compiled document stores it
287
+ * flat as `{ type, name, … }` all the same. Walking into it against a schema
288
+ * that says nothing about its contents reported all ten of a combat technique's
289
+ * stored keys as undeclared — ten findings, every one wrong, about a document
290
+ * that is correct.
291
+ *
292
+ * So a container is descended into only where the artifact actually enumerates
293
+ * something beneath it. That is the same stance {@link declaredFields} takes on
294
+ * a subtype the artifact does not name: what is not described is not checked,
295
+ * rather than guessed at.
296
+ *
297
+ * @param {ReadonlySet<string>} declared - Every path the subtype declares.
298
+ * @returns {Set<string>} The paths whose subtree the artifact describes.
299
+ */
300
+ function enumeratedContainers(declared) {
301
+ const out = new Set();
302
+ for (const declaredPath of declared) {
303
+ const parts = declaredPath.split(".");
304
+ for (let i = 1; i < parts.length; i++) out.add(parts.slice(0, i).join("."));
305
+ }
306
+ return out;
307
+ }
308
+
309
+ /**
310
+ * The paths in a compiled `system` block that the subtype does not declare.
311
+ *
312
+ * {@link module:engine/system-block.undeclaredPaths} answers this for *authored*
313
+ * data, where every path came from a human and every container is one a human
314
+ * typed. An emitted block is different in one way that matters: it contains
315
+ * whole subtrees the artifact declares as a single path and describes no
316
+ * further — see {@link enumeratedContainers} — so the walk stops there instead
317
+ * of reporting their contents.
318
+ *
319
+ * Reported at the shallowest undeclared path, as its sibling is: everything
320
+ * beneath an undeclared container is undeclared by construction, and listing it
321
+ * all buries the one key that is the problem.
322
+ *
323
+ * @param {Record<string, unknown>} data - The emitted `system` block.
324
+ * @param {ReadonlySet<string>} declared - Every path the subtype declares.
325
+ * @param {ReadonlySet<string>} enumerated - From {@link enumeratedContainers}.
326
+ * @param {string} [prefix] - Internal: the path so far.
327
+ * @returns {string[]} The undeclared paths, shallowest-first.
328
+ */
329
+ function undeclaredEmittedPaths(data, declared, enumerated, prefix = "") {
330
+ const out = [];
331
+ if (!isMapping(data)) return out;
332
+ for (const [key, value] of Object.entries(data)) {
333
+ const emittedPath = prefix ? `${prefix}.${key}` : key;
334
+ if (!declared.has(emittedPath)) {
335
+ out.push(emittedPath);
336
+ continue;
337
+ }
338
+ if (!isMapping(value) || !Object.keys(value).length) continue;
339
+ if (!enumerated.has(emittedPath)) continue;
340
+ out.push(
341
+ ...undeclaredEmittedPaths(
342
+ /** @type {Record<string, unknown>} */ (value),
343
+ declared,
344
+ enumerated,
345
+ emittedPath,
346
+ ),
347
+ );
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /**
353
+ * What a **compiled document** carries in `system`, against what the receiving
354
+ * subtype declares (#155).
355
+ *
356
+ * The third of the three checks, and the only one whose emitted set is
357
+ * *observed*. {@link compareFields} reads the `itemBuilders` declarations and
358
+ * {@link checkAuthoredSystemData} reads a note's `<system>.system`; between them
359
+ * they miss every key a compiler writes on its own initiative, which is not a
360
+ * residue — it is `shortcode`, `actionDefs`, `notes`, `docHtml` and `archetype`.
361
+ *
362
+ * **The keys come from the object the compiler built, after a JSON round trip.**
363
+ * That is exactly what the pack file receives, so a key whose value is
364
+ * `undefined` — dropped by `JSON.stringify`, never written, nothing for Foundry
365
+ * to discard — is correctly not a finding. Reading the assembled block is also
366
+ * the only derivation that cannot go stale: a compiler that grows a key is
367
+ * checked on the next build without anyone remembering to add it to a list.
368
+ *
369
+ * **Authored paths are left alone.** A note's own `<system>.system` is reported
370
+ * by {@link checkAuthoredSystemData}, which can point at the line the author
371
+ * wrote; reporting it again here would be the same defect twice, once without a
372
+ * position.
373
+ *
374
+ * **A subtree the artifact does not describe is not checked**, rather than
375
+ * reported wholesale — see {@link enumeratedContainers} for the
376
+ * `TypedSchemaField` case that makes the distinction load-bearing.
377
+ *
378
+ * @param {object} opts
379
+ * @param {object} opts.system - The `system` block the compiler assembled.
380
+ * @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
381
+ * @param {string} opts.documentType - `Item`, `Actor`, …
382
+ * @param {string} opts.subtype - The document subtype being emitted.
383
+ * @param {string} opts.type - The content type whose note produced it, for the
384
+ * message.
385
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
386
+ * declaration, which decides a finding's {@link EmissionFinding.origin}.
387
+ * @param {ReadonlySet<string>} [opts.authored] - Paths the note authored, left
388
+ * to the note-side check.
389
+ * @returns {EmissionFinding[]} One per undeclared path, shallowest-first.
390
+ */
391
+ export function compareEmittedSystem({
392
+ system,
393
+ artifact,
394
+ documentType,
395
+ subtype,
396
+ type,
397
+ fields,
398
+ authored = new Set(),
399
+ }) {
400
+ if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
401
+ throw new Error(
402
+ `package-build: schema artifact version ${artifact?.version ?? "(absent)"}, ` +
403
+ `expected ${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
404
+ `shape would report confidently in both directions, so the check ` +
405
+ `stops rather than resolving anyway.`,
406
+ );
407
+ }
408
+
409
+ const declared = declaredFields(artifact, documentType, subtype);
410
+ // Not a finding, for the same reason `compareFields` skips one: a subtype
411
+ // the artifact says nothing about is a routing question (#79), not a field
412
+ // one, and guessing at it would report every key on the document.
413
+ if (!declared) return [];
414
+
415
+ // What the pack file receives. `undefined` is gone after this, which is the
416
+ // point: a key that never reaches the JSON is not an emitted key.
417
+ const emitted = JSON.parse(JSON.stringify(system ?? {}));
418
+ const byField = emittedFields(fields ?? []);
419
+
420
+ return undeclaredEmittedPaths(emitted, declared.all, enumeratedContainers(declared.all))
421
+ .filter((field) => !authored.has(field))
422
+ .map((field) => ({
423
+ type,
424
+ subtype,
425
+ documentType,
426
+ field,
427
+ systemVersion: artifact.systemVersion,
428
+ origin: /** @type {"builder"|"compiler"} */ (
429
+ byField.has(field) ? "builder" : "compiler"
430
+ ),
431
+ }));
432
+ }
433
+
242
434
  /**
243
435
  * The published schema this build should check itself against, or `null`.
244
436
  *
@@ -277,10 +469,7 @@ export function resolveSchemaArtifact(config) {
277
469
  });
278
470
 
279
471
  // The system checking itself, against the schema its own build published.
280
- if (
281
- config.packageKind === "systems" &&
282
- config.foundryPackage === systemId
283
- ) {
472
+ if (config.packageKind === "systems" && config.foundryPackage === systemId) {
284
473
  const own = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
285
474
  return fs.existsSync(own) ? read(own) : null;
286
475
  }
@@ -330,3 +519,155 @@ export function unemittedMessage(finding) {
330
519
  `field's initial value rather than an authored one`
331
520
  );
332
521
  }
522
+
523
+ /**
524
+ * What an author is told about an emitted key the target system does not
525
+ * define — in the terms of whoever can actually fix it.
526
+ *
527
+ * The failure is identical in both cases and the remedies are not, which is
528
+ * why the two are told apart at all:
529
+ *
530
+ * | origin | who writes it | what fixes it |
531
+ * | --- | --- | --- |
532
+ * | `builder` | a `fields:` entry in this repository's `itemBuilders` | change the field's `to`, or get the system to declare it |
533
+ * | `compiler` | this package, on every document of the subtype | **nothing here** — the system must declare it, or this package must be pinned to a build that does not write it |
534
+ *
535
+ * A `compiler` finding is the one worth spelling out, because the obvious first
536
+ * move — go and look for the field in `itemBuilders` — leads nowhere: there is
537
+ * no declaration to correct. It means the build is running ahead of the system
538
+ * it compiles for, and the version named in the message is what says so.
539
+ *
540
+ * @param {EmissionFinding} finding - One entry from
541
+ * {@link compareEmittedSystem}.
542
+ * @returns {string} The message.
543
+ */
544
+ export function emittedUndeclaredMessage(finding) {
545
+ if (finding.origin === "builder") return undeclaredMessage(finding);
546
+ return (
547
+ `the compiler writes \`system.${finding.field}\` into every ` +
548
+ `${finding.documentType} of subtype "${finding.subtype}", and no field ` +
549
+ `declaration names it — ${finding.documentType} subtype ` +
550
+ `"${finding.subtype}" does not define it at ${finding.systemVersion}, and ` +
551
+ `Foundry discards an unknown \`system\` key when the document is ` +
552
+ `constructed, without a warning, so the value is lost at load while the ` +
553
+ `build reports success. No \`itemBuilders\` change fixes this: declare ` +
554
+ `the field in the receiving system, or hold this package at a build that ` +
555
+ `does not write it`
556
+ );
557
+ }
558
+
559
+ /** One resolved schema artifact per resolved configuration. */
560
+ const artifacts = new WeakMap();
561
+
562
+ /**
563
+ * {@link resolveSchemaArtifact}, read once per configuration.
564
+ *
565
+ * The per-note check below runs thousands of times in a build and the artifact
566
+ * never changes inside one, so reading and parsing it per note would be a
567
+ * megabyte of JSON per hundred documents for an answer that is already known.
568
+ *
569
+ * @param {object} config - The resolved build configuration.
570
+ * @returns {{artifact: SchemaArtifact, source: string}|null} The schema.
571
+ */
572
+ function schemaFor(config) {
573
+ if (!artifacts.has(config)) artifacts.set(config, resolveSchemaArtifact(config));
574
+ return artifacts.get(config);
575
+ }
576
+
577
+ /**
578
+ * What a note authors under `<system>.system`, against what the receiving
579
+ * subtype declares (#58).
580
+ *
581
+ * The **note-side** half of the check `compareFields` performs on the
582
+ * declarations. A field list is checked once for the whole build because it is
583
+ * the same for every document; an authored `system` block is a property of one
584
+ * note, so it is checked where that note is compiled and reported against that
585
+ * note's file.
586
+ *
587
+ * It is the same failure either way, and the reason both halves exist: Foundry
588
+ * discards an unknown `system` key at construction and says nothing, so a
589
+ * mistyped path is lost at load while the build reports success.
590
+ *
591
+ * **Silent where there is nothing to check against.** A module pinning a system
592
+ * version released before the artifact existed, or a subtype the artifact does
593
+ * not name, produces no findings — the same stance `compareFields` takes, where
594
+ * an unknown subtype is a routing question rather than a field one. The
595
+ * whole-build check in `content-build lint` is where a missing artifact is said
596
+ * out loud, once, instead of per note.
597
+ *
598
+ * @param {object} fm - The note's frontmatter.
599
+ * @param {object} opts
600
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
601
+ * @param {string} opts.documentType - `Item`, `Actor`, …
602
+ * @param {string} opts.subType - The document subtype the note compiles into.
603
+ * @param {object} [opts.config] - The resolved build configuration.
604
+ * @returns {{path: string, message: string}[]} One finding per undeclared path,
605
+ * shallowest-first.
606
+ */
607
+ export function checkAuthoredSystemData(
608
+ fm,
609
+ { block, documentType, subType, config = loadPackConfig() },
610
+ ) {
611
+ const data = systemData(fm, block);
612
+ if (!Object.keys(data).length) return [];
613
+
614
+ const schema = schemaFor(config);
615
+ if (!schema) return [];
616
+ const declared = declaredFields(schema.artifact, documentType, subType);
617
+ if (!declared) return [];
618
+
619
+ return undeclaredPaths(data, declared.all).map((path) => ({
620
+ path,
621
+ message:
622
+ `\`${block}.system.${path}\` is not a field ${documentType} subtype ` +
623
+ `"${subType}" declares at ${schema.artifact.systemVersion} — Foundry ` +
624
+ `discards an unknown \`system\` key when the document is constructed, ` +
625
+ `without a warning, so the value is lost at load while the build ` +
626
+ `reports success`,
627
+ }));
628
+ }
629
+
630
+ /**
631
+ * The `system` block a compiler just assembled, against what the receiving
632
+ * subtype declares (#155).
633
+ *
634
+ * The build-time face of {@link compareEmittedSystem}: it resolves the schema
635
+ * the way every other check here does — the system's own committed artifact, or
636
+ * the cached one from the release a module pins — and attaches the message a
637
+ * reader sees.
638
+ *
639
+ * **Silent where there is nothing to check against**, exactly as its two
640
+ * siblings are: a module pinning a system version released before the artifact
641
+ * existed, or a subtype the artifact does not name, produces no findings.
642
+ * `content-build lint` is where a missing artifact is said out loud, once.
643
+ *
644
+ * @param {object} system - The `system` block the compiler produced.
645
+ * @param {object} opts
646
+ * @param {object} opts.fm - The note's frontmatter, for the authored paths this
647
+ * check leaves to {@link checkAuthoredSystemData}.
648
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
649
+ * @param {string} opts.documentType - `Item`, `Actor`, …
650
+ * @param {string} opts.subType - The document subtype the note compiles into.
651
+ * @param {string} opts.type - The note's content type, for the message.
652
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
653
+ * declaration, which decides each finding's origin.
654
+ * @param {object} [opts.config] - The resolved build configuration.
655
+ * @returns {(EmissionFinding & {message: string})[]} One per undeclared path.
656
+ */
657
+ export function checkEmittedSystemData(
658
+ system,
659
+ { fm, block, documentType, subType, type, fields, config = loadPackConfig() },
660
+ ) {
661
+ const schema = schemaFor(config);
662
+ if (!schema) return [];
663
+
664
+ return compareEmittedSystem({
665
+ system,
666
+ artifact: schema.artifact,
667
+ documentType,
668
+ subtype: subType,
669
+ type,
670
+ fields,
671
+ authored: new Set(systemDataPaths(systemData(fm, block))),
672
+ }).map((finding) => ({ ...finding, message: emittedUndeclaredMessage(finding) }));
673
+ }