@heroiclands/package-build 20.0.0 → 20.2.1

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.
@@ -121,24 +121,152 @@ export const UNIVERSAL_KEYS = Object.freeze(
121
121
  * The system blocks a build checks, and what each accepts beyond the shared
122
122
  * vocabulary.
123
123
  *
124
- * One entry, because one system is what every existing tree declares — and the
125
- * default is a *declaration*, not a hard-coded assumption: a build that ships
126
- * content for two systems passes both, and each block is then checked against
127
- * its own vocabulary rather than against the other's (#58). A block nothing
128
- * declares is not checked, because nothing can say what it may carry, and
129
- * inventing a rule for it would report a correct tree red.
130
- *
131
- * `fieldVocabulary` says the note type's own declared field names are keys of
132
- * this block. True for `sohl` and untrue in general: those names come from the
133
- * `itemBuilders` registry that this system declares, and a second system's
134
- * notes write a second system's fields.
135
- *
136
- * @type {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>}
124
+ * One entry, because one system is what every existing tree declares — and it
125
+ * is a *fallback*, not the rule. {@link systemBlocksFor} derives the map from
126
+ * the configuration, which is what makes the block a package actually ships for
127
+ * the block that gets checked; this is what a caller holding no configuration
128
+ * gets, which in practice is a unit test.
129
+ *
130
+ * A block nothing declares is not checked, because nothing can say what it may
131
+ * carry, and inventing a rule for it would report a correct tree red.
132
+ *
133
+ * Three ways a block may state its vocabulary, and a spec declares at most one:
134
+ *
135
+ * - `known` — an explicit list of keys, for a caller stating them outright.
136
+ * - `fieldVocabulary` — the note type's own declared field names, as the
137
+ * caller's `schemas` state them, are keys of this block. That holds for the
138
+ * **one** system a single-registry tree ships for, where `schemas` *is* that
139
+ * system's vocabulary, and in general holds for no other.
140
+ * - `fields` — type → that system's own declared fields, from the registry the
141
+ * system declares. What a second system's block is checked against, because a
142
+ * second system's notes write a second system's fields and the note-type
143
+ * schemas describe somebody else's.
144
+ *
145
+ * @type {Readonly<Record<string, SystemBlockSpec>>}
137
146
  */
138
147
  export const DEFAULT_SYSTEM_BLOCKS = Object.freeze({
139
148
  sohl: Object.freeze({ fieldVocabulary: true }),
140
149
  });
141
150
 
151
+ /**
152
+ * What one system block accepts beyond the keys every block carries.
153
+ *
154
+ * @typedef {object} SystemBlockSpec
155
+ * @property {readonly string[]} [known] - Keys stated outright.
156
+ * @property {boolean} [fieldVocabulary] - Whether the note type's declared field
157
+ * names, as the caller's `schemas` state them, are keys of this block.
158
+ * @property {Readonly<Record<string, readonly object[]>>} [fields] - Type → this
159
+ * system's own declared fields. A type it does not name is a type this system
160
+ * says nothing about, and its block is left unchecked on such a note rather
161
+ * than reported wholesale.
162
+ */
163
+
164
+ /**
165
+ * Every system a configuration says its tree carries (#58).
166
+ *
167
+ * **Which systems a package ships for is already declared**, in three places
168
+ * that answer different questions, so this reads all three rather than asking a
169
+ * new one:
170
+ *
171
+ * - `systems:` (#48) declares them without requiring one, which is how a
172
+ * package ships for several;
173
+ * - a **pack's** `system:` is the same statement made per pack, and it is the
174
+ * one some trees make: `harn-ensemble` declares an `actors-sohl` and an
175
+ * `actors-hm3` and nothing else about either system. It is already
176
+ * authoritative elsewhere — `eligibleFor` fails a note for want of the block
177
+ * a pack's `system:` names — so a lint that did not read it would refuse a
178
+ * note at compile for a block it never checked;
179
+ * - `stats.systemId` is the package-wide answer where there is one, and it has
180
+ * already absorbed every way of spelling that: a system package is its own
181
+ * system, and a module takes `requiresSystem`, its lone `systems:` entry, or
182
+ * its lone system relationship.
183
+ *
184
+ * A package naming a system in none of them is system-agnostic on purpose — its
185
+ * packs are core document types carrying no system data — so it carries no
186
+ * system block and naming one would invent it.
187
+ *
188
+ * @param {object} [config] - A resolved configuration from `defineConfig`.
189
+ * @returns {string[]} The system ids, deduplicated, in declared order.
190
+ */
191
+ export function declaredSystems(config) {
192
+ const out = [];
193
+ for (const system of Object.keys(config?.systems ?? {})) {
194
+ if (!out.includes(system)) out.push(system);
195
+ }
196
+ for (const pack of config?.packs ?? []) {
197
+ const system = pack?.system;
198
+ if (typeof system === "string" && system && !out.includes(system)) out.push(system);
199
+ }
200
+ if (out.length) return out;
201
+ const packageWide = config?.stats?.systemId;
202
+ return typeof packageWide === "string" && packageWide ? [packageWide] : [];
203
+ }
204
+
205
+ /**
206
+ * The system blocks a configuration says its tree carries, and what each
207
+ * accepts (#58).
208
+ *
209
+ * The lint checks the blocks its caller names, and for as long as there was one
210
+ * system the only caller named none — so every tree took the `sohl:` of
211
+ * {@link DEFAULT_SYSTEM_BLOCKS}, a constant, in a module whose whole discipline
212
+ * is that it states no vocabulary of its own. That is wrong in both directions
213
+ * the moment a second system exists, and the second direction is the worse:
214
+ *
215
+ * - a package shipping for `hm3` had its `hm3:` block **never looked at**, so
216
+ * every key in it was discarded at compile without a word — the silent-drop
217
+ * family this check exists to close;
218
+ * - and the block that *was* checked was named after a system that package does
219
+ * not ship for, so the one finding it could make was about nothing.
220
+ *
221
+ * **A block's vocabulary has two sources, and a system may have both.**
222
+ *
223
+ * - The **note schemas** the caller hands in as `schemas`. Those belong to one
224
+ * system — the CLI imports `sohl/note-schemas.mjs` — and `schemaSystem` is the
225
+ * caller naming which, because only the caller knows. It is the only source
226
+ * that reaches a type no item registry declares, which is to say `being`: the
227
+ * 2,512 notes `harn-ensemble` is made of, and the reason this is not an
228
+ * optional refinement.
229
+ * - The system's **own registry**, `itemFieldsBySystem`, keyed by system and
230
+ * until now read by nothing. This is what a *second* system's block is held
231
+ * to, since the note schemas describe its neighbour.
232
+ *
233
+ * A system with neither is left out: nothing can state what its block may
234
+ * carry, and holding it to an empty vocabulary would report every key in a
235
+ * correct tree. **That is a check that does not run**, which is
236
+ * indistinguishable from one that passed, so the caller says it out loud —
237
+ * {@link declaredSystems} is the other half of that comparison. `harn-ensemble`
238
+ * is the tree it names: two systems, and an `itemBuilders` registry for
239
+ * neither, so its `hm3:` block is unchecked until it declares one.
240
+ *
241
+ * An earlier draft of this took the note schemas for a system's vocabulary only
242
+ * where the package declared **one** system, on the reasoning that with several
243
+ * there is nothing to say which one they describe. There is: the caller, which
244
+ * chose them. The guess cost `harn-ensemble` its whole `sohl:` check — two
245
+ * systems declared, so the fallback never fired — which is the coverage this
246
+ * change exists to widen rather than narrow.
247
+ *
248
+ * @param {object} [config] - A resolved configuration from `defineConfig`.
249
+ * @param {object} [options] - Options.
250
+ * @param {string} [options.schemaSystem] - The system whose vocabulary the
251
+ * caller's `schemas` state. There are two systems, not an open set, so this is
252
+ * one word from the caller rather than a mechanism.
253
+ * @returns {Readonly<Record<string, SystemBlockSpec>>} The blocks to check, in
254
+ * declared order. A system nothing states the vocabulary of is absent.
255
+ */
256
+ export function systemBlocksFor(config, { schemaSystem } = {}) {
257
+ const byName = config?.itemFieldsBySystem ?? {};
258
+ /** @type {Record<string, SystemBlockSpec>} */
259
+ const blocks = {};
260
+ for (const system of declaredSystems(config)) {
261
+ /** @type {SystemBlockSpec} */
262
+ const spec = {};
263
+ if (system === schemaSystem) spec.fieldVocabulary = true;
264
+ if (byName[system]) spec.fields = byName[system];
265
+ if (Object.keys(spec).length) blocks[system] = Object.freeze(spec);
266
+ }
267
+ return Object.freeze(blocks);
268
+ }
269
+
142
270
  /**
143
271
  * Edit distance, capped — enough to answer "did you mean".
144
272
  *
@@ -631,6 +759,27 @@ const ART_FIELDS = Object.freeze([
631
759
  * @returns {Set<string>} The in-block keys that are not the note-level field of
632
760
  * the same name.
633
761
  */
762
+ /**
763
+ * The keys a field declaration is authored at **inside a system block**.
764
+ *
765
+ * The first segment of each field's in-block key: `impact.die` is authored as
766
+ * `impact`, and a field whose shared source moved under `data:` is authored at
767
+ * the `legacyKey` it declares rather than at its dotted name (#305). Keying on
768
+ * the name instead would report `sohl.species` as a property no `being` has,
769
+ * against exactly the notes the sweep has not reached yet.
770
+ *
771
+ * Written once and read twice: the note type's own declaration answers for the
772
+ * system whose vocabulary the caller's `schemas` are, and a second system's
773
+ * registry answers for its block (#58). Two derivations of one thing would be
774
+ * free to disagree about which position a note authors.
775
+ *
776
+ * @param {readonly object[]|null|undefined} schema - A type's declarations.
777
+ * @returns {Set<string>} The in-block keys.
778
+ */
779
+ function inBlockKeys(schema) {
780
+ return new Set(authoredFields(schema ?? []).map((f) => legacyKeyOf(f).split(".")[0]));
781
+ }
782
+
634
783
  function collidingBlockKeys(schema) {
635
784
  const keys = new Set();
636
785
  if (!Array.isArray(schema)) return keys;
@@ -778,9 +927,11 @@ function checkEmbeddedShortcodes(note, blockName) {
778
927
  * Supplied by the caller like `schemas`, so this module states no list of
779
928
  * iconless types of its own; absent it, an inert `img:` goes unreported
780
929
  * rather than every note's being (#349).
781
- * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
782
- * The system blocks to check, and what each accepts. See
783
- * {@link DEFAULT_SYSTEM_BLOCKS}.
930
+ * @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
931
+ * The system blocks to check, and what each accepts. Supplied by the caller
932
+ * for the same reason `schemas` is — a build derives them from its
933
+ * configuration through {@link systemBlocksFor}, and this module states no
934
+ * system name of its own. See {@link DEFAULT_SYSTEM_BLOCKS} for the fallback.
784
935
  * @param {readonly string[]} [opts.packs] - The pack names this package
785
936
  * declares, for a `data:` field whose map is keyed by pack. Supplied by the
786
937
  * caller like `schemas` and `vocabulary`, and absent it no claim is made
@@ -1169,15 +1320,8 @@ export function lintNote(
1169
1320
  }
1170
1321
 
1171
1322
  const fields = authoredFields(schema);
1172
- /**
1173
- * First segment of the key each field is authored at **inside the block** —
1174
- * `impact.die` is authored as `impact`, and a field whose shared source
1175
- * moved under `data:` is authored at the `legacyKey` it declares rather
1176
- * than at its dotted name (#305). Keying this on the name would report
1177
- * `sohl.species` as a property no `being` has, against exactly the notes
1178
- * the sweep has not reached yet.
1179
- */
1180
- const declared = new Set(fields.map((f) => legacyKeyOf(f).split(".")[0]));
1323
+ /** The keys this type's own declaration is authored at inside a block. */
1324
+ const declared = inBlockKeys(schema);
1181
1325
 
1182
1326
  // The retired spelling of a field this type declares → what to write now.
1183
1327
  // Built from the type's own vocabulary, so a renamed field is retired
@@ -1209,16 +1353,33 @@ export function lintNote(
1209
1353
  // Every declared system's block, each against its own vocabulary (#58). A
1210
1354
  // block carries the shared keys any system's does — `system`, `type`,
1211
1355
  // `img`, `effects`, `flags`, `pack` — plus whatever that system declares:
1212
- // for `sohl`, the note type's own field names, which are still the position
1213
- // the corpus authors them at until #126 moves them.
1356
+ // the note type's own field names for the system those schemas describe,
1357
+ // and a second system's own registry for its block. Which systems arrive
1358
+ // here is the configuration's answer, not this module's; see
1359
+ // {@link systemBlocksFor}.
1214
1360
  for (const [blockName, spec] of Object.entries(systems ?? {})) {
1215
1361
  // Two embedded items denoting one entity (#228). Per block, because
1216
1362
  // `items` is a block key and a second system's actor carries its own.
1363
+ // Before the `continue` below, because it is a statement about the
1364
+ // block's *shape* and holds whether or not this system declares a
1365
+ // vocabulary for the note's type.
1217
1366
  findings.push(...checkEmbeddedShortcodes(note, blockName));
1367
+ // A block is checked only where its system speaks about this type. A
1368
+ // type a system's registry does not name is a type it says nothing
1369
+ // about — SoHL's `mysticalability` is not an HM3 type at all — and
1370
+ // holding the block to an empty vocabulary would report every key in
1371
+ // it, which is the correct tree reported red.
1372
+ //
1373
+ // `fieldVocabulary` reaches types no registry declares, `being` above
1374
+ // all, so a spec carrying it always speaks. Only a spec whose *sole*
1375
+ // statement is `fields` can fall silent here.
1376
+ const own = spec?.fields;
1377
+ if (own && !own[type] && !spec?.fieldVocabulary && !spec?.known) continue;
1218
1378
  const accepted = new Set([
1219
1379
  ...UNIVERSAL_KEYS,
1220
1380
  ...(spec?.known ?? []),
1221
1381
  ...(spec?.fieldVocabulary ? declared : []),
1382
+ ...(own ? inBlockKeys(own[type]) : []),
1222
1383
  ]);
1223
1384
  for (const key of unknownBlockKeys(fm, blockName, { known: accepted })) {
1224
1385
  // Reported above, with what to write instead — a retired spelling
@@ -1347,8 +1508,9 @@ export function lintNote(
1347
1508
  * @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
1348
1509
  * it declares (#128); see {@link lintNote}.
1349
1510
  * @param {boolean} [opts.references=true] - Whether to check references.
1350
- * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
1351
- * The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
1511
+ * @param {Readonly<Record<string, SystemBlockSpec>>} [opts.systems]
1512
+ * The system blocks to check; see {@link lintNote} and
1513
+ * {@link systemBlocksFor}.
1352
1514
  * @param {readonly string[]} [opts.packs] - The declared pack names; see
1353
1515
  * {@link lintNote}.
1354
1516
  * @param {(type: string) => {document: string|null, art: readonly string[]}|null} [opts.emittedArt]
@@ -497,8 +497,11 @@ async function generatePack(
497
497
  // not contain, for a repository that authors beings without
498
498
  // holding the items they are assembled from. Cache-only: a cold
499
499
  // cache throws naming `content-build deps fetch` rather than
500
- // downloading inside a compile.
501
- foreignSourceDirs: foreignItemCatalogDirs(config),
500
+ // downloading inside a compile. Scoped to this pack's system for the
501
+ // reason the local half is (#58): both halves answer one lookup, so a
502
+ // dependency shipping two systems' items would otherwise supply the
503
+ // wrong vocabulary's document for an address that exists in both.
504
+ foreignSourceDirs: foreignItemCatalogDirs(config, system ?? null),
502
505
  // The bundles pass resolves each Adventure's members against the output
503
506
  // of every pass that produces one. Stated from the configured pack list
504
507
  // for the same reason `itemsSourceDirs` is (#1508), and scoped to this
@@ -31,6 +31,7 @@ import path from "path";
31
31
  import yaml from "yaml";
32
32
  import unidecode from "unidecode";
33
33
  import markdownit from "markdown-it";
34
+ import { iconPlugin } from "./content-icons.mjs";
34
35
  import log from "loglevel";
35
36
 
36
37
  import { loadPackConfig } from "./pack-config.mjs";
@@ -68,7 +69,15 @@ export {
68
69
  parseValueDesc,
69
70
  } from "./frontmatter.mjs";
70
71
 
71
- export const md = markdownit({ html: true });
72
+ /**
73
+ * The markdown renderer every surface shares.
74
+ *
75
+ * `html: true` is long-standing and load-bearing — notes carry raw blocks — and
76
+ * it is also why {@link module:engine/content-icons} exists rather than an
77
+ * instruction to write `<i class="fa-solid …">` by hand: that would render on
78
+ * the two HTML surfaces and be silently dropped by the third (#378).
79
+ */
80
+ export const md = markdownit({ html: true }).use(iconPlugin());
72
81
 
73
82
  /**
74
83
  * Parses a markdown file with YAML frontmatter.
package/engine/index.mjs CHANGED
@@ -116,6 +116,12 @@ export * as siteBuild from "./site-build.mjs";
116
116
  /** Address rules every content tree is linted against: shape, uniqueness, alias. */
117
117
  export * as contentLint from "./content-lint.mjs";
118
118
 
119
+ /** The charset authored content is held to, so a book can choose its face. */
120
+ export * as contentCharset from "./content-charset.mjs";
121
+
122
+ /** Naming an interface icon in a note, without drawing one there. */
123
+ export * as contentIcons from "./content-icons.mjs";
124
+
119
125
  /** Resolving every link in a tree, and the ones that land nowhere. */
120
126
  export * as contentLinks from "./content-links.mjs";
121
127
 
@@ -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,