@heroiclands/package-build 19.0.0 → 20.2.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 (85) hide show
  1. package/CHANGELOG.md +1100 -0
  2. package/CONTENT.md +264 -33
  3. package/README.md +43 -4
  4. package/bin/content-build.mjs +94 -3
  5. package/config.mjs +9 -1
  6. package/content-config.mjs +99 -19
  7. package/docs/content-format.md +394 -72
  8. package/e2e.mjs +297 -3
  9. package/engine/actor-compiler.mjs +197 -7
  10. package/engine/address-charset.mjs +23 -5
  11. package/engine/base-compiler.mjs +63 -2
  12. package/engine/bundles.mjs +9 -0
  13. package/engine/content-address.mjs +92 -1
  14. package/engine/content-charset.mjs +434 -0
  15. package/engine/content-format.mjs +102 -0
  16. package/engine/content-icons.mjs +388 -0
  17. package/engine/content-index.mjs +11 -8
  18. package/engine/content-links.mjs +37 -21
  19. package/engine/field-reference.mjs +57 -5
  20. package/engine/field-spec.mjs +214 -7
  21. package/engine/folder-notes.mjs +24 -1
  22. package/engine/foreign-catalog.mjs +112 -7
  23. package/engine/foundry-entries.mjs +14 -0
  24. package/engine/frontmatter-lint.mjs +377 -56
  25. package/engine/frontmatter.mjs +11 -11
  26. package/engine/generate.mjs +72 -12
  27. package/engine/helpers.mjs +96 -10
  28. package/engine/index.mjs +9 -0
  29. package/engine/item-compiler.mjs +37 -0
  30. package/engine/journals.mjs +21 -4
  31. package/engine/macros.mjs +8 -0
  32. package/engine/map-notes.mjs +7 -7
  33. package/engine/note-claims.mjs +208 -5
  34. package/engine/note-ids.mjs +25 -1
  35. package/engine/note-vocabulary.mjs +76 -9
  36. package/engine/pack-config.mjs +102 -12
  37. package/engine/pack-router.mjs +0 -0
  38. package/engine/prose-config.mjs +42 -0
  39. package/engine/prose-lint.mjs +126 -0
  40. package/engine/retired-fields.mjs +57 -16
  41. package/engine/runtime-only-fields.mjs +204 -0
  42. package/engine/scenes.mjs +12 -19
  43. package/engine/schema-check.mjs +23 -1
  44. package/engine/schema-extract.mjs +13 -0
  45. package/engine/site-index.mjs +17 -0
  46. package/engine/subtype-registry.mjs +30 -0
  47. package/engine/system-block.mjs +81 -3
  48. package/engine/web-wikilinks.mjs +33 -27
  49. package/engine/wikilink-syntax.mjs +7 -0
  50. package/engine/wikilinks.mjs +74 -16
  51. package/hm3/actors.mjs +63 -13
  52. package/package.json +2 -2
  53. package/sohl/actors.mjs +106 -7
  54. package/sohl/item-fields.mjs +203 -0
  55. package/sohl/note-schemas.mjs +6 -3
  56. package/types/config.d.mts +7 -0
  57. package/types/e2e.d.mts +130 -3
  58. package/types/engine/actor-compiler.d.mts +83 -3
  59. package/types/engine/address-charset.d.mts +22 -4
  60. package/types/engine/base-compiler.d.mts +54 -3
  61. package/types/engine/content-address.d.mts +64 -0
  62. package/types/engine/content-charset.d.mts +127 -0
  63. package/types/engine/content-format.d.mts +9 -0
  64. package/types/engine/content-icons.d.mts +151 -0
  65. package/types/engine/field-spec.d.mts +271 -3
  66. package/types/engine/folder-notes.d.mts +20 -0
  67. package/types/engine/foreign-catalog.d.mts +38 -2
  68. package/types/engine/foundry-entries.d.mts +6 -0
  69. package/types/engine/frontmatter-lint.d.mts +164 -30
  70. package/types/engine/frontmatter.d.mts +11 -11
  71. package/types/engine/generate.d.mts +27 -0
  72. package/types/engine/helpers.d.mts +45 -9
  73. package/types/engine/index.d.mts +3 -0
  74. package/types/engine/map-notes.d.mts +2 -2
  75. package/types/engine/note-claims.d.mts +67 -0
  76. package/types/engine/note-ids.d.mts +14 -0
  77. package/types/engine/pack-config.d.mts +35 -0
  78. package/types/engine/prose-config.d.mts +41 -0
  79. package/types/engine/prose-lint.d.mts +36 -0
  80. package/types/engine/retired-fields.d.mts +29 -13
  81. package/types/engine/runtime-only-fields.d.mts +102 -0
  82. package/types/engine/schema-check.d.mts +10 -1
  83. package/types/engine/subtype-registry.d.mts +21 -0
  84. package/types/engine/system-block.d.mts +28 -2
  85. package/types/sohl/actors.d.mts +3 -3
@@ -12,8 +12,9 @@
12
12
  */
13
13
 
14
14
  /**
15
- * **Which note types a configuration compiles at all** — and the finding for a
16
- * note whose type nothing claims (#146).
15
+ * **Which note types a configuration compiles at all** — the finding for a note
16
+ * whose type nothing claims (#146), and the one for a note that loses a
17
+ * document while the rest of it compiles (#152).
17
18
  *
18
19
  * Every compile pass answers one question about a note: _is this mine?_ A note
19
20
  * every pass answers "no" to is skipped as quietly as the thousands that
@@ -50,6 +51,27 @@
50
51
  * who invented a word. Collapsing the two would send `harn-ensemble` to correct
51
52
  * five perfectly good notes.
52
53
  *
54
+ * ## The partial case is a third condition, and it was invisible (#152)
55
+ *
56
+ * The table above asks whether a note is compiled *at all*, and a note that
57
+ * compiles one of its two documents answers yes. But a note produces more than
58
+ * one document as a matter of course — an item note an Item and the
59
+ * JournalEntry its prose becomes, a map note a Scene and a JournalEntry, an
60
+ * actor note an Actor and a JournalEntry since #337 — so a configuration
61
+ * missing a pack for *one* of them dropped that document while the rest of the
62
+ * note compiled into a pack that does exist. The build succeeded and shipped
63
+ * half of what was written.
64
+ *
65
+ * | condition | what it means | whose fix |
66
+ * | --- | --- | --- |
67
+ * | some documents have a pack, one does not | this note compiles, and one of its documents is lost | configuration |
68
+ *
69
+ * {@link documentClassesFor} is the question this needs and the type-level
70
+ * table could not answer: not "is anything claiming this note" but "which
71
+ * documents does this note produce", asked per note, because documentation is
72
+ * per note — a doc-carrying note with an empty body produces no JournalEntry at
73
+ * all, and `Journals.skipNote` is where that is decided.
74
+ *
53
75
  * ## The claim table mirrors `selects`, and a test holds them together
54
76
  *
55
77
  * Which note types a pass claims is stated by that pass's `selects`, and the
@@ -61,7 +83,7 @@
61
83
  * @module
62
84
  */
63
85
 
64
- import { assertSuppliedCorpus } from "./helpers.mjs";
86
+ import { assertSuppliedCorpus, parseMarkdownFile } from "./helpers.mjs";
65
87
  // The record accessors only: this module is imported by the content index, so
66
88
  // importing the index back would close a cycle (#243).
67
89
  import { authoredFrontmatter, isNoteRecord, noteFile } from "./index-records.mjs";
@@ -252,6 +274,115 @@ export function noteTypesClaimedBy(docType, sources) {
252
274
  }
253
275
  }
254
276
 
277
+ /**
278
+ * Every Foundry document class {@link noteTypesClaimedBy} answers for.
279
+ *
280
+ * The switch above, read the other way round. It is written out rather than
281
+ * derived because a `switch` cannot be enumerated — and
282
+ * `tests/unclaimed-note-types.test.ts` holds the two together by checking that
283
+ * no class outside this list claims anything, so a row added there and not here
284
+ * fails rather than going quiet.
285
+ *
286
+ * Order is the order a reader meets them in a message, not a precedence.
287
+ *
288
+ * @type {readonly string[]}
289
+ */
290
+ export const CLAIMABLE_DOCUMENT_TYPES = Object.freeze([
291
+ "Item",
292
+ "Actor",
293
+ "JournalEntry",
294
+ "Macro",
295
+ "Scene",
296
+ "Adventure",
297
+ ]);
298
+
299
+ /**
300
+ * Every document class a note of one type compiles into (#152).
301
+ *
302
+ * **A note produces more than one document, and that is the ordinary case.** An
303
+ * item note compiles an Item *and* the JournalEntry its prose becomes; a map
304
+ * note a Scene and a JournalEntry; since #337 an actor note an Actor and a
305
+ * JournalEntry too. {@link claimedNoteTypes} unions over the configured packs
306
+ * and so answers "is this note compiled *at all*", which is #146's question and
307
+ * cannot see a note that compiles one of its two documents and loses the other.
308
+ *
309
+ * Asked of the **claim table** rather than of a list of its own, so the set of
310
+ * documents a type produces and the set of passes that claim it are one
311
+ * statement. A pass that starts claiming a type starts producing its document
312
+ * here, with nothing to remember.
313
+ *
314
+ * **Union across systems, never per system.** A type one system maps and
315
+ * another does not appears once, because the `Item` and `Actor` rows already
316
+ * fold the maps together — so this cannot report a document class a system
317
+ * deliberately declines to produce, which is the silence #79 requires.
318
+ *
319
+ * ## The JournalEntry row is the one that is per *note*
320
+ *
321
+ * Every other row is a property of the type: a `macro` note produces a Macro, a
322
+ * map note a Scene, whatever either says. Documentation is not. `Journals`
323
+ * declines a doc-carrying note whose body is empty — *"an item with no prose
324
+ * gets no doc, and the items pass leaves its description empty rather than
325
+ * pointing at nothing"* — so whether an item note produces a JournalEntry is
326
+ * decided by the note, not by its type.
327
+ *
328
+ * That distinction is the whole difference between a useful finding and a
329
+ * useless one. `sohl-kethira-basic` declares no JournalEntry pack and ships 393
330
+ * notes whose descriptions are *deliberately* empty, under the Fan Material
331
+ * Guidelines its configuration explains at length. A type-level answer would
332
+ * report every one of them for losing a document none of them produces. Asking
333
+ * per note, it reports none, and still reports `harn-ensemble`'s 2,517 beings,
334
+ * whose `{#appearance}` and `{#dossier}` prose is real and is lost.
335
+ *
336
+ * `hasProse` is therefore how the caller answers that, and it is a **thunk** so
337
+ * that the file is read only where the answer could change the outcome. Omitted,
338
+ * the answer is the type's full potential — every document such a note *could*
339
+ * produce — which is what a caller asking about a type rather than a note wants.
340
+ *
341
+ * @param {string} type - The note's declared `type`, current spelling.
342
+ * @param {ClaimSources} [sources] - What to answer from.
343
+ * @param {object} [opts] - Options.
344
+ * @param {(() => boolean)|boolean} [opts.hasProse] - Whether *this note* carries
345
+ * a body. Omitted, the type's potential is reported.
346
+ * @returns {string[]} The document classes, in {@link CLAIMABLE_DOCUMENT_TYPES}
347
+ * order. Empty for a type nothing compiles.
348
+ */
349
+ export function documentClassesFor(type, sources, { hasProse } = {}) {
350
+ const resolved = resolveSources(sources);
351
+ return CLAIMABLE_DOCUMENT_TYPES.filter((docType) => {
352
+ if (!noteTypesClaimedBy(docType, resolved).has(type)) return false;
353
+ if (docType !== "JournalEntry") return true;
354
+ // A type whose *whole* document is the journal always produces one;
355
+ // there is no body condition, because the body is the document.
356
+ if (JOURNAL_TYPES.has(type)) return true;
357
+ if (hasProse === undefined) return true;
358
+ return Boolean(typeof hasProse === "function" ? hasProse() : hasProse);
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Whether a note carries a body at all — the condition `Journals.skipNote`
364
+ * applies, asked from the outside (#152).
365
+ *
366
+ * Read from the file rather than from the index record, because a record
367
+ * carries a note's frontmatter and its derived address and not its prose. The
368
+ * walk that calls this is already reading the same file to locate the `type:`
369
+ * key for a finding's position, so this is the same cost in the same place —
370
+ * and it is called only for a note whose documentation would otherwise be
371
+ * reported as lost.
372
+ *
373
+ * @param {string} absPath - The note's path.
374
+ * @returns {boolean} True when the body has content.
375
+ */
376
+ function noteHasProse(absPath) {
377
+ try {
378
+ return Boolean(parseMarkdownFile(absPath).body);
379
+ } catch {
380
+ // Unreadable here means unreadable for the compile too, which reports
381
+ // it with a message about the file rather than about its documentation.
382
+ return false;
383
+ }
384
+ }
385
+
255
386
  /**
256
387
  * Every note type some pack in a configuration would compile.
257
388
  *
@@ -418,6 +549,44 @@ function specifiedMessage(type) {
418
549
  );
419
550
  }
420
551
 
552
+ /**
553
+ * The **partial** finding: the note compiles, and one of its documents does not
554
+ * (#152).
555
+ *
556
+ * #146's question is "does anything claim this note", and the answer is yes —
557
+ * which is exactly why this went unreported. A note produces more than one
558
+ * document, and a configuration missing a pack for one of them drops that
559
+ * document while the rest of the note compiles into a pack that does exist. The
560
+ * build succeeds, the compendium ships, and the missing half is discoverable
561
+ * only by noticing it is not there.
562
+ *
563
+ * The message names the note, the document class with no pack, and the class
564
+ * that *did* compile — the last because it is what distinguishes this from
565
+ * #146's finding at a glance: the note is not unclaimed, it is half-claimed, and
566
+ * the fix is a pack rather than a `type:`.
567
+ *
568
+ * @param {string} type - The note's declared `type`.
569
+ * @param {readonly string[]} missing - Document classes with no pack.
570
+ * @param {readonly string[]} compiled - Document classes that do have one.
571
+ * @returns {string} The message.
572
+ */
573
+ function partialMessage(type, missing, compiled) {
574
+ // `Item`, `Actor` and `Adventure` take "an". Spelled out rather than left
575
+ // to read as a typo in a message an author meets at the moment they are
576
+ // being told something went wrong.
577
+ const article = (name) => (/^[AEIOU]/.test(name) ? "an" : "a");
578
+ const list = (classes) => classes.map((name) => `${article(name)} ${name}`).join(" and ");
579
+ const names = (classes) => classes.join(" and ");
580
+ return (
581
+ `a note of type "${type}" compiles into ${list(missing)} as well as ` +
582
+ `${list(compiled)}, and \`packs:\` declares no ${names(missing)} pack — ` +
583
+ `so the ${names(missing)} is dropped with no error while the rest of the ` +
584
+ `note compiles. Declare ${list(missing)} pack in ` +
585
+ `package-build.config.yaml, or accept the loss deliberately by not ` +
586
+ `authoring what it would have carried.`
587
+ );
588
+ }
589
+
421
590
  /**
422
591
  * The **authoring** finding: nothing anywhere knows the type.
423
592
  *
@@ -455,9 +624,13 @@ function authoringMessage(type) {
455
624
  */
456
625
  export function unclaimedNoteFindings(config = loadPackConfig(), sources, { records } = {}) {
457
626
  const resolved = resolveSources(sources);
458
- const claimed = claimedNoteTypes(config, resolved);
459
627
  const vocabulary = noteTypeVocabulary(resolved);
460
628
  const findings = [];
629
+ // The document classes this configuration can actually receive a compiled
630
+ // document into. A **prebuilt** pack is not one of them, for the reason
631
+ // {@link claimedNoteTypes} states: its JSON is checked in, it has no pass,
632
+ // and no note is routed into it.
633
+ const configured = new Set((config.packs ?? []).filter((p) => !p.prebuilt).map((p) => p.type));
461
634
 
462
635
  // The corpus this compile derived once (#243), required rather than
463
636
  // derived here: this module is imported *by* the content index, so it
@@ -481,7 +654,37 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
481
654
  // authored one (#78). The rename itself is reported by the frontmatter
482
655
  // lint, which can say what to write instead.
483
656
  const current = currentType(type);
484
- if (claimed.has(current)) continue;
657
+
658
+ // Every document this note produces, against the classes this
659
+ // configuration has a pack for. Three outcomes, and the middle one is
660
+ // #152's — it was invisible while the question was only "is anything
661
+ // claiming this note", because the answer there is yes.
662
+ const produces = documentClassesFor(current, resolved, {
663
+ // Lazy: only a doc-carrying type whose JournalEntry has nowhere to
664
+ // go asks, so a tree with a JournalEntry pack — which is most of
665
+ // them — reads no bodies at all.
666
+ hasProse: () => noteHasProse(absPath),
667
+ });
668
+ const missing = produces.filter((docType) => !configured.has(docType));
669
+ const compiled = produces.filter((docType) => configured.has(docType));
670
+
671
+ // Every document it produces has somewhere to go.
672
+ if (produces.length && !missing.length) continue;
673
+
674
+ // Some do and some do not: the note compiles, and one of its documents
675
+ // is dropped in silence. A type nothing produces at all falls past this
676
+ // to the #146 messages below, where `produces` being empty is itself
677
+ // part of the answer.
678
+ if (compiled.length) {
679
+ findings.push({
680
+ file: absPath,
681
+ ...locateFrontmatterKey(absPath, "type", type),
682
+ severity: /** @type {"error"} */ ("error"),
683
+ type,
684
+ message: partialMessage(type, missing, compiled),
685
+ });
686
+ continue;
687
+ }
485
688
 
486
689
  findings.push({
487
690
  file: absPath,
@@ -44,6 +44,11 @@ import { documentId } from "./content-address.mjs";
44
44
  import { systemOf } from "./document-subtypes.mjs";
45
45
  import { contentPackage } from "./content-package.mjs";
46
46
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
47
+ // The folder id's derivation, taken from the pass that owns it rather than
48
+ // restated here — see the `folder` branch below. `folder-notes.mjs` reaches
49
+ // only `content-address`, `address-charset`, `ids` and `retired-fields`, none
50
+ // of which reach this module, so the direction closes no cycle.
51
+ import { FOLDER_TYPE, folderDocId } from "./folder-notes.mjs";
47
52
 
48
53
  /**
49
54
  * A frontmatter value read as a non-blank string, or `undefined`.
@@ -64,6 +69,20 @@ function text(value) {
64
69
  /**
65
70
  * The document id a note compiles under: its pin, or its address.
66
71
  *
72
+ * **One type hashes its address differently, and that is not an exception to
73
+ * the rule but an application of it.** A `Folder` is a document of its own
74
+ * class, and its id is hashed under the `folder` namespace so that a folder and
75
+ * an item sharing a shortcode cannot derive one id — a collision Foundry would
76
+ * not report, since it keys folders and documents in separate collections
77
+ * (#258). So the answer for a folder comes from
78
+ * {@link module:engine/folder-notes.folderDocId}, the pass that emits those
79
+ * documents, rather than from a second derivation here.
80
+ *
81
+ * That this function ever answered differently was invisible from inside a
82
+ * build — no pass reads a folder's id from here — and surfaced only in the
83
+ * content index, which is read from outside and had no way to be checked
84
+ * against what shipped (#310).
85
+ *
67
86
  * Returns `undefined` for a file with **no address** — no `type`, or no
68
87
  * `shortcode`. Such a file is not an addressable note, so it has no document
69
88
  * and inventing an id for one would file it under nothing. Every caller already
@@ -88,7 +107,12 @@ export function noteDocId(fm, { pkg, maps = KNOWN_DOCUMENT_SUBTYPE_MAPS } = {})
88
107
  const type = text(fm.type);
89
108
  const shortcode = text(fm.shortcode);
90
109
  if (!type || !shortcode) return undefined;
91
- return documentId(pkg ?? contentPackage(), systemOf(type, maps), type, shortcode);
110
+ const owner = pkg ?? contentPackage();
111
+ // Lowercased because `collectFolderNotes` matches the type that way, and
112
+ // the two must answer alike about the same note or the divergence this
113
+ // branch closes reopens under a capitalised `type: Folder`.
114
+ if (type.toLowerCase() === FOLDER_TYPE) return folderDocId(owner, shortcode);
115
+ return documentId(owner, systemOf(type, maps), type, shortcode);
92
116
  }
93
117
 
94
118
  /**
@@ -422,7 +422,14 @@ export const NOTE_VOCABULARY = Object.freeze({
422
422
  // values here would put a second, weaker answer beside the real one.
423
423
  subTypes: null,
424
424
  data: Object.freeze([
425
- { name: "portrait", ...TEXT, describe: "Path to the portrait image." },
425
+ {
426
+ name: "portrait",
427
+ ...TEXT,
428
+ describe:
429
+ "Path to the portrait image. Its first segment says which package owns " +
430
+ "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
431
+ "else is this package's own and is rooted under its assets.",
432
+ },
426
433
  TEMPLATE_PRIORITY,
427
434
  { name: "archetypes", ...LIST, describe: "Archetypal behaviours the being fits." },
428
435
  { name: "occupation", ...TEXT, describe: "What the being does for a living." },
@@ -466,7 +473,14 @@ export const NOTE_VOCABULARY = Object.freeze({
466
473
  vehicle: Object.freeze({
467
474
  subTypes: null,
468
475
  data: Object.freeze([
469
- { name: "portrait", ...TEXT, describe: "Path to the portrait image." },
476
+ {
477
+ name: "portrait",
478
+ ...TEXT,
479
+ describe:
480
+ "Path to the portrait image. Its first segment says which package owns " +
481
+ "the file: `systems/…` and `modules/…` are emitted unchanged, anything " +
482
+ "else is this package's own and is rooted under its assets.",
483
+ },
470
484
  TEMPLATE_PRIORITY,
471
485
  ]),
472
486
  }),
@@ -577,16 +591,31 @@ export const NOTE_VOCABULARY = Object.freeze({
577
591
  ...TEXT,
578
592
  describe: "Roll formula for the delay between contraction and onset.",
579
593
  },
594
+ {
595
+ name: "onsetDurationBase",
596
+ ...NUM,
597
+ describe: "That delay in seconds, stated outright instead of rolled.",
598
+ },
580
599
  {
581
600
  name: "healingCheckDurationFormula",
582
601
  ...TEXT,
583
602
  describe: "Roll formula for the interval between healing checks.",
584
603
  },
604
+ {
605
+ name: "healingCheckDurationBase",
606
+ ...NUM,
607
+ describe: "That interval in seconds, stated outright instead of rolled.",
608
+ },
585
609
  {
586
610
  name: "resolutionDurationFormula",
587
611
  ...TEXT,
588
612
  describe: "Roll formula for the time from onset to resolution.",
589
613
  },
614
+ {
615
+ name: "resolutionDurationBase",
616
+ ...NUM,
617
+ describe: "That time in seconds, stated outright instead of rolled.",
618
+ },
590
619
  ]),
591
620
  }),
592
621
 
@@ -719,7 +748,39 @@ export const NOTE_VOCABULARY = Object.freeze({
719
748
  "shock",
720
749
  "coma",
721
750
  ]),
722
- data: Object.freeze([TEMPLATE_PRIORITY]),
751
+ data: Object.freeze([
752
+ TEMPLATE_PRIORITY,
753
+ {
754
+ name: "healingCheckDurationFormula",
755
+ ...TEXT,
756
+ describe: "Roll formula for the interval between healing checks.",
757
+ },
758
+ {
759
+ name: "healingCheckDurationBase",
760
+ ...NUM,
761
+ describe: "That interval in seconds, stated outright instead of rolled.",
762
+ },
763
+ {
764
+ name: "bloodLossAdvanceDurationFormula",
765
+ ...TEXT,
766
+ describe: "Roll formula for the interval between blood-loss advances.",
767
+ },
768
+ {
769
+ name: "bloodLossAdvanceDurationBase",
770
+ ...NUM,
771
+ describe: "That interval in seconds. Setting it is what makes the wound bleed.",
772
+ },
773
+ {
774
+ name: "courseDurationFormula",
775
+ ...TEXT,
776
+ describe: "Roll formula for the interval between course tests.",
777
+ },
778
+ {
779
+ name: "courseDurationBase",
780
+ ...NUM,
781
+ describe: "That interval in seconds, stated outright instead of rolled.",
782
+ },
783
+ ]),
723
784
  }),
724
785
 
725
786
  weapongear: Object.freeze({
@@ -809,6 +870,7 @@ export const NOTE_VOCABULARY = Object.freeze({
809
870
  "folk",
810
871
  "culture",
811
872
  "bestiary",
873
+ "gathering",
812
874
  ]),
813
875
  // Nothing of its own: a lore note is prose, and what it *is* about is
814
876
  // its subType. The specification declares an empty table for it, and
@@ -881,12 +943,17 @@ export const NOTE_VOCABULARY = Object.freeze({
881
943
  // derived for them, which is precisely what a subType decides (#174).
882
944
  subTypes: Object.freeze(["battlemap", "localmap", "regionalmap"]),
883
945
  data: Object.freeze([
884
- // The specification spells this `img`, matching every other
885
- // note type, while the map compiler reads `image` from
886
- // `sohl:` today. It says outright that one of the two has
887
- // to move; the container takes the specification's name,
888
- // and moving the authored key is the migration's business.
889
- { name: "img", ...TEXT, describe: "Path to the map art." },
946
+ // `img`, as every other note type spells its artwork. A map alone
947
+ // read `image` out of its `sohl:` block; that spelling is retired
948
+ // and gone (#149), so the two names are one again.
949
+ {
950
+ name: "img",
951
+ ...TEXT,
952
+ describe:
953
+ "Path to the map art, owned by whichever package its first segment " +
954
+ "names — `systems/…` and `modules/…` unchanged, anything else this " +
955
+ "package's own.",
956
+ },
890
957
  {
891
958
  name: "dimensions",
892
959
  ...LIST,
@@ -47,14 +47,38 @@
47
47
  * actually needed: every accessor in the engine funnels through here, so
48
48
  * anything that reads configuration throws with the message below.
49
49
  *
50
- * **Located by walking up from this module, not from the working directory.**
51
- * The config file sits at the root of the repository that installed the
52
- * toolchain, so climbing out of
53
- * `node_modules/@heroiclands/package-build/engine/` lands on it either way.
54
- * Resolving it against `process.cwd()` instead would make the build read a
55
- * different tree depending on where it was launched from, which is the very
56
- * property #1508 removed. `PACKAGE_BUILD_CONFIG` names the file explicitly when
57
- * a consumer keeps it somewhere else.
50
+ * **Located by walking up from the working directory, and from this module only
51
+ * when that finds nothing.** The config file sits at the root of the repository
52
+ * being built, and a build is run inside that repository — so the walk from
53
+ * `process.cwd()` finds it from the root, from `packages/`, from anywhere
54
+ * below. Climbing from this module instead finds the same file too, right up
55
+ * until the installed package is not the one the caller is standing in: a git
56
+ * worktree nested under its parent checkout with no `node_modules` of its own
57
+ * resolves `@heroiclands/package-build` out of the *parent's*, because Node's
58
+ * resolution walks parent directories. `import.meta.dirname` is then inside the
59
+ * parent, the walk lands on the parent's configuration, and the build compiles
60
+ * the parent's content tree into the parent's `build/` and exits 0 — saying so
61
+ * only in paths that are easy to read past (#364).
62
+ *
63
+ * That failure is undetectable on exactly the work most likely to provoke it.
64
+ * The usual tell is a zero diff where a change was expected; an
65
+ * output-preserving sweep *expects* zero differences, so the tell is gone and a
66
+ * wrong-tree build produces confident evidence for a tree nobody touched. Order
67
+ * of resolution is the fix, because reviewer vigilance cannot be.
68
+ *
69
+ * The module walk stays, as the fallback for an invocation from outside any
70
+ * repository, and is never *preferred*: a configuration found above the
71
+ * installed package rather than above the working directory is not the one a
72
+ * caller meant. When both walks find one and they disagree, the ignored one is
73
+ * named in a warning rather than passed over — that disagreement is also the
74
+ * only cheap signal that this tree is building on another checkout's
75
+ * `node_modules`. `PACKAGE_BUILD_CONFIG` names the file explicitly and skips
76
+ * both walks, which is why it was the workaround.
77
+ *
78
+ * What #1508 removed stays removed. The property it bought was not "resolve
79
+ * from the module"; it was that a build reads one tree however it was launched,
80
+ * and an upward walk from the working directory keeps that — every directory
81
+ * inside a repository resolves that repository's single configuration.
58
82
  *
59
83
  * **Loaded synchronously.** A YAML config is parsed synchronously as a matter
60
84
  * of course; an `.mjs` one is loaded with `require` rather than `await import`,
@@ -81,7 +105,12 @@ import { createRequire } from "node:module";
81
105
  import YAML from "yaml";
82
106
 
83
107
  import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
84
- import { formatDiagnostic, positionOfYamlPath, yamlKeyPath } from "./diagnostics.mjs";
108
+ import {
109
+ emitDiagnostic,
110
+ formatDiagnostic,
111
+ positionOfYamlPath,
112
+ yamlKeyPath,
113
+ } from "./diagnostics.mjs";
85
114
 
86
115
  /** The stem every consuming repository declares its build under. */
87
116
  export const CONFIG_BASENAME = "package-build.config";
@@ -144,6 +173,41 @@ export function findConfigFile(from) {
144
173
  }
145
174
  }
146
175
 
176
+ /**
177
+ * Which configuration file a build launched here should read, and what each
178
+ * walk found.
179
+ *
180
+ * Kept separate from {@link loadPackConfig} because the *choice* is worth being
181
+ * able to ask about without loading anything: the two walks disagreeing is the
182
+ * observable form of #364, and a caller that wants to report it — or a test
183
+ * that wants to describe it — should not have to reproduce the resolution and
184
+ * risk disagreeing with the loader about it. It performs I/O, and is named for
185
+ * it, like the {@link findConfigFile} it calls twice.
186
+ *
187
+ * `PACKAGE_BUILD_CONFIG` is deliberately not consulted here. An explicit name
188
+ * is not a search result: {@link loadPackConfig} short-circuits on it before it
189
+ * ever asks, so there is no walk to report and nothing to disagree with.
190
+ *
191
+ * @param {object} [from] - Where to walk up from; both default to the real
192
+ * thing, and are parameters only so a caller can describe a tree it is not
193
+ * standing in.
194
+ * @param {string} [from.cwd] - The directory the build was launched in.
195
+ * @param {string} [from.moduleDir] - The directory this module sits in.
196
+ * @returns {{path: string|undefined, fromCwd: string|undefined, fromModule: string|undefined}}
197
+ * The file to read, and each walk's own answer — the same file in an ordinary
198
+ * build, different ones in a worktree resolving the toolchain out of its
199
+ * parent checkout.
200
+ * @throws {Error} As {@link findConfigFile}, when one directory holds more than
201
+ * one configuration.
202
+ */
203
+ export function resolveConfigFile({ cwd = process.cwd(), moduleDir = import.meta.dirname } = {}) {
204
+ const fromCwd = findConfigFile(cwd);
205
+ const fromModule = findConfigFile(moduleDir);
206
+ // `??`, not `||`: the module walk is a fallback for finding *nothing*, never
207
+ // a tie-break between two answers.
208
+ return { path: fromCwd ?? fromModule, fromCwd, fromModule };
209
+ }
210
+
147
211
  const require = createRequire(import.meta.url);
148
212
 
149
213
  /**
@@ -607,20 +671,46 @@ export function loadPackConfig() {
607
671
  if (loaded) return loaded;
608
672
 
609
673
  const explicit = process.env.PACKAGE_BUILD_CONFIG;
610
- const configPath = explicit ? path.resolve(explicit) : findConfigFile(import.meta.dirname);
674
+ const found = explicit ? undefined : resolveConfigFile();
675
+ const configPath = explicit ? path.resolve(explicit) : found.path;
611
676
 
612
677
  if (!configPath || !fs.existsSync(configPath)) {
613
678
  throw new Error(
614
679
  explicit ?
615
680
  `package-build: PACKAGE_BUILD_CONFIG names ${configPath}, ` +
616
681
  `which does not exist.`
682
+ // Both origins, because either walk could have found one and
683
+ // naming only the module's would send a reader looking inside
684
+ // `node_modules/` for a file that belongs in their own root.
617
685
  : `package-build: no ${CONFIG_FILENAMES.join(" or ")} found at ` +
618
- `or above ${import.meta.dirname}. A consuming repository ` +
619
- `declares its build in one file at its root; set ` +
686
+ `or above ${process.cwd()}, nor at or above ` +
687
+ `${import.meta.dirname}. A consuming repository declares ` +
688
+ `its build in one file at its root; set ` +
620
689
  `PACKAGE_BUILD_CONFIG to name it elsewhere.`,
621
690
  );
622
691
  }
623
692
 
693
+ // Two different files, one of which is about to be ignored. Said out loud
694
+ // because the alternative is what #364 was: a build that reads the parent
695
+ // checkout's configuration, compiles the parent's tree, and reports it only
696
+ // in absolute paths nobody rereads. A warning rather than an error — the
697
+ // shape is legitimate, and the working directory's answer is the right one
698
+ // — but never silence.
699
+ if (found?.fromCwd && found.fromModule && found.fromCwd !== found.fromModule) {
700
+ emitDiagnostic({
701
+ severity: "warning",
702
+ message:
703
+ `package-build: reading ${found.fromCwd}, the configuration ` +
704
+ `above this working directory. The installed ` +
705
+ `@heroiclands/package-build sits under a different ` +
706
+ `repository, whose own ${found.fromModule} is being ignored — ` +
707
+ `usually because this tree has no \`node_modules\` of its own ` +
708
+ `and resolved the toolchain out of a parent checkout. Run ` +
709
+ `\`npm ci\` here, or set PACKAGE_BUILD_CONFIG, to say which ` +
710
+ `tree is meant.`,
711
+ });
712
+ }
713
+
624
714
  loaded =
625
715
  configPath.endsWith(".mjs") ?
626
716
  loadCodeConfig(configPath)
Binary file
@@ -134,6 +134,48 @@ export function sharedPrettierOptionsFor(file) {
134
134
  return /\.md$/i.test(file) ? { ...PRETTIER_BASE, ...PRETTIER_MARKDOWN } : { ...PRETTIER_BASE };
135
135
  }
136
136
 
137
+ /**
138
+ * Where a resolved Prettier configuration disagrees with the shared one.
139
+ *
140
+ * The runner resolves each file's options as *either* the consumer's own config
141
+ * or {@link sharedPrettierOptionsFor}, never a merge. That is what bare Prettier
142
+ * does and it is the contract — but it means the conventions this package exists
143
+ * to publish hold by convention alone, and they lapse in two opposite directions
144
+ * (#133). A consumer that declares any config of its own gets whatever that
145
+ * config says: spread {@link PRETTIER_BASE} without the markdown override and
146
+ * every note reindents at 4, the reindentation the override was added to prevent
147
+ * (#76); write a partial `.prettierrc` such as `{"tabWidth": 2}` and
148
+ * `printWidth`, `trailingComma`, `experimentalTernaries` and the rest fall back
149
+ * to Prettier's own defaults. A consumer that declares *nothing* formats one way
150
+ * under this command and another under a bare `npx prettier`.
151
+ *
152
+ * This is the comparison that makes either absence visible. It is a **report,
153
+ * not a merge**: what a consumer declared still wins, and a deliberate local
154
+ * choice stays possible — it stops being silent, and nothing here fails a build
155
+ * over it.
156
+ *
157
+ * Every shared value is a primitive, so `!==` is the whole comparison. An option
158
+ * holding an object would need a deeper one, and the shared set has none —
159
+ * `overrides` is not compared, because `resolveConfig` has already applied and
160
+ * removed it by the time a configuration reaches this.
161
+ *
162
+ * @param {object|null|undefined} resolved - What `prettier.resolveConfig`
163
+ * returned for `file`, with the consumer's own `overrides` already applied.
164
+ * `null` — no configuration at all — reports every shared key as absent.
165
+ * @param {string} file - Path the options were resolved for. Decides whether
166
+ * {@link PRETTIER_MARKDOWN} is part of what is expected.
167
+ * @returns {Array<{key: string, shared: unknown, local: unknown}>} One entry per
168
+ * shared key the resolved configuration does not carry the value of, in the
169
+ * order {@link PRETTIER_BASE} declares them. `local` is `undefined` where the
170
+ * key is absent entirely, which is not the same finding as a key set to
171
+ * something else and is reported differently.
172
+ */
173
+ export function sharedPrettierDivergence(resolved, file) {
174
+ return Object.entries(sharedPrettierOptionsFor(file))
175
+ .filter(([key, shared]) => resolved?.[key] !== shared)
176
+ .map(([key, shared]) => ({ key, shared, local: resolved?.[key] }));
177
+ }
178
+
137
179
  /**
138
180
  * The markdownlint rules — the structural checks Prettier cannot make.
139
181
  *