@heroiclands/package-build 22.20.0 → 22.21.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.
@@ -97,6 +97,8 @@ import {
97
97
  // question, asked the way `checkPlace` and `checkCalendarNote` are.
98
98
  import { checkBeingAge } from "./being-age.mjs";
99
99
  import { checkSocialTies } from "./social-ties.mjs";
100
+ import { checkStandings } from "./standings.mjs";
101
+ import { STANDING_BODY_TYPES } from "./standing-terms.mjs";
100
102
  import { checkDatedOffices } from "./office-holders.mjs";
101
103
  import { checkCalendarChoice } from "./calendar-choice.mjs";
102
104
  import { checkCultureChoice } from "./culture-choice.mjs";
@@ -128,12 +130,20 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
128
130
  * @typedef {object} DataFieldSpec
129
131
  * @property {string} name - The key under `data:`, dotted for a nested one
130
132
  * (`charges.value`).
131
- * @property {"string"|"number"|"boolean"|"list"|"map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
133
+ * @property {"string"|"number"|"boolean"|"list"|"map"|"list-or-map"|"scalar-or-map"|"address"|"shortcode"} [kind] -
132
134
  * The value's shape, for the lint. Absent means no claim is made about the
133
135
  * value — which is the honest answer wherever the specification's stated
134
136
  * shape and the shape notes are authored in today disagree.
135
137
  * @property {"address"|"shortcode"} [entryKind] - Type of each list or pack-map value.
136
- * @property {"address"|"shortcode"} [keyKind] - Type of each map key.
138
+ * @property {"address"|"shortcode"} [keyKind] - Type of each map key. On a
139
+ * `list-or-map` field it describes the map form alone, and `entryKind` the
140
+ * list form alone: reading both of one value would check a map's entries as
141
+ * if they were its keys.
142
+ * @property {boolean} [standings] - The map's entries are standings held in the
143
+ * body each is keyed by, so a row names the two together — see
144
+ * {@link module:engine/standings}.
145
+ * @property {boolean} [roster] - The map names posts, each with a description,
146
+ * so each takes a row labelled and anchored by the post's own name.
137
147
  * @property {"subType"} [keySelector] - An alternative `subType:<skill-subtype>` key.
138
148
  * @property {readonly string[]} [accepts] - Allowed Address types, separate from `ref`.
139
149
  * @property {string} [shape] - Human-readable shape, for a finding and for
@@ -214,6 +224,29 @@ const LINK = Object.freeze({ shape: "an Address", kind: "address" });
214
224
  /** A list of Addresses. */
215
225
  const LINKS = Object.freeze({ shape: "list of Addresses", kind: "list", entryKind: "address" });
216
226
 
227
+ /**
228
+ * A being's memberships, and the standing it holds in each.
229
+ *
230
+ * A map keyed by the body's Address, whose entry is that membership's `rank`
231
+ * and `office` — the pair a flat list of bodies cannot state, because it has
232
+ * nowhere to say which body a standing is held in.
233
+ *
234
+ * **`list-or-map` is the sweep's shape**, and it says which half each
235
+ * declaration describes: `entryKind` the list's entries, `keyKind` the map's
236
+ * keys. A tree still writing the list form keeps the check it had, and the map
237
+ * form earns the two the body can answer — see {@link module:engine/standings}.
238
+ */
239
+ const STANDINGS = Object.freeze({
240
+ shape: "a standing map keyed by Address, or a list of Addresses",
241
+ kind: "list-or-map",
242
+ entryKind: "address",
243
+ keyKind: "address",
244
+ standings: true,
245
+ ref: "affiliation",
246
+ accepts: STANDING_BODY_TYPES,
247
+ check: checkStandings,
248
+ });
249
+
217
250
  /**
218
251
  * A single Address, or one per pack.
219
252
  *
@@ -725,7 +758,8 @@ export const NOTE_VOCABULARY = Object.freeze({
725
758
  ref: "lore",
726
759
  accepts: ["lore"],
727
760
  describe:
728
- "Lore concerning this being, such as the standing it holds or the law it lives under.",
761
+ "Lore concerning this being — the law it lives under, the customs it is " +
762
+ "subject to, the traditions it was raised in.",
729
763
  },
730
764
  {
731
765
  name: "culture",
@@ -744,10 +778,11 @@ export const NOTE_VOCABULARY = Object.freeze({
744
778
  },
745
779
  {
746
780
  name: "affiliations",
747
- ...LINKS,
748
- ref: "affiliation",
749
- accepts: ["affiliation"],
750
- describe: "Affiliations the being belongs to — traditions, polities, and the rest.",
781
+ ...STANDINGS,
782
+ describe:
783
+ "Bodies the being belongs to, keyed by Address, each entry holding the " +
784
+ "standing it holds there — `rank`, a level on that body's own ladder, " +
785
+ "and `office`, a post that body names.",
751
786
  },
752
787
  {
753
788
  name: "socialTies",
@@ -901,6 +936,10 @@ export const NOTE_VOCABULARY = Object.freeze({
901
936
  {
902
937
  name: "governance.offices",
903
938
  ...ANY,
939
+ // A map of named posts: each takes a row of its own, labelled
940
+ // by the post and anchored by it, so the description a body
941
+ // declares is a destination a being's `office` can link to.
942
+ roster: true,
904
943
  check: checkDatedOffices,
905
944
  describe: "Named offices, each with a description and optional dated holders.",
906
945
  },
@@ -8,6 +8,7 @@ import { completeAddress, ownDocumentSystem, renderAddress } from "./address.mjs
8
8
  import { NOTE_SYSTEM } from "./systems.mjs";
9
9
  import { JOURNAL_TYPES } from "./ids.mjs";
10
10
  import { hasDocEntry } from "./item-docs.mjs";
11
+ import { standingsDigest } from "./standings.mjs";
11
12
 
12
13
  /**
13
14
  * Names and published destinations for reference presentation.
@@ -21,7 +22,18 @@ export function buildReferenceTargets(records, published = new Map()) {
21
22
  const targets = new Map();
22
23
  for (const record of records) {
23
24
  if (!record.package || !record.type || !record.shortcode) continue;
24
- const value = { name: record.name?.full ?? record.name, subType: record.subType };
25
+ // `subType` and the standings a body confers both ride along for the
26
+ // same reason: a caller resolving a reference often needs to know what
27
+ // it found, not only where it is. A rank is a number on the *being*
28
+ // and what that number is called is declared on the *body*, so the
29
+ // ladder travels with the reference rather than being looked up a
30
+ // second way.
31
+ const standings = standingsDigest(record);
32
+ const value = {
33
+ name: record.name?.full ?? record.name,
34
+ subType: record.subType,
35
+ ...(standings ? { standings } : {}),
36
+ };
25
37
  const systems = new Set([ownDocumentSystem(record.type)]);
26
38
  if (JOURNAL_TYPES.has(record.type) || hasDocEntry(record.type)) systems.add(NOTE_SYSTEM);
27
39
  for (const system of systems) {
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import { buildReferenceTargets } from "./reference-targets.mjs";
53
+ import { standingsDigest } from "./standings.mjs";
53
54
 
54
55
  import { resolveShortcodeReference } from "./shortcode-references.mjs";
55
56
 
@@ -218,11 +219,16 @@ export function buildSiteIndex(
218
219
  // needs to know what it found, not only where it is: an infobox groups
219
220
  // a being's skills by the family each skill note declares, and that
220
221
  // fact lives on the target rather than on the reference.
222
+ // The rungs and posts a body confers, carried for the same reason
223
+ // `subType` is: a being states a rank as a number, and what that
224
+ // number is called is declared on the body.
225
+ const standings = standingsDigest(e.fm);
221
226
  const value = {
222
227
  url: e.url,
223
228
  name: e.name,
224
229
  draft: isDraftNote(e.fm),
225
230
  ...(e.fm.subType ? { subType: e.fm.subType } : {}),
231
+ ...(standings ? { standings } : {}),
226
232
  };
227
233
 
228
234
  const shortcode = e.fm.shortcode;
@@ -343,7 +349,13 @@ export function resolveInfoboxRef(siteIndex, ref, hint) {
343
349
  hint,
344
350
  );
345
351
  return found ?
346
- { name: found.name, url: found.url, subType: found.subType, address: found.address }
352
+ {
353
+ name: found.name,
354
+ url: found.url,
355
+ subType: found.subType,
356
+ address: found.address,
357
+ ...(found.standings ? { standings: found.standings } : {}),
358
+ }
347
359
  : undefined;
348
360
  }
349
361
  const context = {
@@ -357,6 +369,13 @@ export function resolveInfoboxRef(siteIndex, ref, hint) {
357
369
  const found =
358
370
  siteIndex.referenceTargets?.get(renderAddress(tuple)) ??
359
371
  siteIndex.index?.get(renderAddress(tuple));
360
- if (found) return { name: found.name, url: found.url, subType: found.subType, address: tuple };
372
+ if (found)
373
+ return {
374
+ name: found.name,
375
+ url: found.url,
376
+ subType: found.subType,
377
+ address: tuple,
378
+ ...(found.standings ? { standings: found.standings } : {}),
379
+ };
361
380
  return undefined;
362
381
  }
@@ -0,0 +1,19 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * The note type a standing is held in.
5
+ *
6
+ * A standing is always held in a body: the entry is keyed by that body's
7
+ * Address, which is what lets the rung and the post be checked against what
8
+ * the body itself declares.
9
+ */
10
+ export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
11
+
12
+ /**
13
+ * The two keys one standing carries.
14
+ *
15
+ * `rank` is the level on the body's own `governance.ranks` ladder, as a number.
16
+ * `office` is a key of its `governance.offices` map, as a string. Both are
17
+ * optional — a membership with neither is the ordinary shape.
18
+ */
19
+ export const STANDING_KEYS = Object.freeze(["rank", "office"]);
@@ -0,0 +1,455 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * **A being's standing, held in the body that confers it.**
16
+ *
17
+ * `data.affiliations` is a map keyed by the affiliation's Address, and each
18
+ * entry carries the standing the being holds *in that body*:
19
+ *
20
+ * ```yaml
21
+ * data:
22
+ * affiliations:
23
+ * vrystwldtrbs: { rank: 5, office: War Chief }
24
+ * greenwardens: { rank: 1 }
25
+ * ```
26
+ *
27
+ * The key is what makes the pair expressible. A being in two bodies holds a
28
+ * standing in each, two bodies may name a rung the same word, and both facts
29
+ * are about one membership rather than about the being at large.
30
+ *
31
+ * **`rank` is a number, not a link.** The body declares `{level, title,
32
+ * description}` for every rung of its `governance.ranks`, so a number indexes
33
+ * into that ladder and the body stays the single source for what its level 5 is
34
+ * called. **`office` is a string**, matched against the keys of the body's
35
+ * `governance.offices` map: there are far more offices in a tree than bodies,
36
+ * most of them held by one body alone, and an Address would make writing the
37
+ * fact wait on authoring a page about the post.
38
+ *
39
+ * Both are resolved against the one affiliation the entry is keyed by, which is
40
+ * what lets each be checked — a rung the body does not confer and an office it
41
+ * does not hold are both findings naming the file, line and column.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
47
+ import { AddressEntries } from "./address-values.mjs";
48
+ import { slugify } from "./content-slug.mjs";
49
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
50
+ import { STANDING_BODY_TYPES, STANDING_KEYS } from "./standing-terms.mjs";
51
+
52
+ export { STANDING_BODY_TYPES, STANDING_KEYS };
53
+
54
+ /** Whether a value is a plain mapping. */
55
+ function mapping(value) {
56
+ return value !== null && typeof value === "object" && !Array.isArray(value);
57
+ }
58
+
59
+ /**
60
+ * The standings a being authored, in one shape whichever shape it wrote.
61
+ *
62
+ * The list form names bodies and no standings; the map form names both. Three
63
+ * spellings reach here and every reader downstream takes the same entries, so
64
+ * none of them asks which it was handed:
65
+ *
66
+ * - an **{@link AddressEntries}** — the map after the note boundary decoded its
67
+ * keys into typed Addresses, which is the shape every compile sees;
68
+ * - a **plain map**, which is the shape a checker reading raw YAML sees;
69
+ * - a **list**, which names bodies and says nothing about standing in them.
70
+ *
71
+ * An emptied map arrives from the property editor as `[]` and means what `{}`
72
+ * means — this being authors no membership — so both read as no entries.
73
+ *
74
+ * Each entry carries `body` for a message and `sourceKey` for a position: the
75
+ * authored key is what a reader has to find in the file, and the decoded
76
+ * Address is what a finding should name.
77
+ *
78
+ * @param {unknown} value - The authored `data.affiliations`.
79
+ * @returns {{form: "absent"|"list"|"map"|"malformed",
80
+ * entries: Array<{body: unknown, sourceKey: string|number, standing: unknown}>}}
81
+ * The form it was written in, and its entries.
82
+ */
83
+ export function readStandings(value) {
84
+ if (value === undefined || value === null) return { form: "absent", entries: [] };
85
+ if (value instanceof AddressEntries) {
86
+ return {
87
+ form: "map",
88
+ entries: value.entries.map((entry) => ({
89
+ body: entry.target,
90
+ sourceKey: entry.sourceKey,
91
+ standing: entry.value,
92
+ })),
93
+ };
94
+ }
95
+ if (Array.isArray(value)) {
96
+ // An empty list is an emptied map, not a membership stated as a list.
97
+ if (value.length === 0) return { form: "map", entries: [] };
98
+ return {
99
+ form: "list",
100
+ entries: value.map((body, position) => ({
101
+ body,
102
+ sourceKey: position,
103
+ standing: undefined,
104
+ })),
105
+ };
106
+ }
107
+ if (!mapping(value) || isAddressTuple(value)) return { form: "malformed", entries: [] };
108
+ return {
109
+ form: "map",
110
+ entries: Object.entries(value).map(([body, standing]) => ({
111
+ body,
112
+ sourceKey: body,
113
+ standing,
114
+ })),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The rungs an affiliation's ladder declares, as level → title.
120
+ *
121
+ * A rung states its own `level`, so the ladder is read by that number rather
122
+ * than by its position in the list: a body may write its rungs in any order,
123
+ * and several start at zero.
124
+ *
125
+ * @param {unknown} ranks - An affiliation's `data.governance.ranks`.
126
+ * @returns {Map<number, string>} Level → the title that level is called.
127
+ */
128
+ export function rankLadder(ranks) {
129
+ const ladder = new Map();
130
+ if (!Array.isArray(ranks)) return ladder;
131
+ for (const rung of ranks) {
132
+ if (!mapping(rung)) continue;
133
+ const level = Number(rung.level);
134
+ if (!Number.isFinite(level)) continue;
135
+ if (!ladder.has(level)) ladder.set(level, typeof rung.title === "string" ? rung.title : "");
136
+ }
137
+ return ladder;
138
+ }
139
+
140
+ /**
141
+ * The offices an affiliation names, as key → description.
142
+ *
143
+ * An office is authored either as a bare description or as a `{description,
144
+ * holders}` map, and a reader of the post's name wants the same string from
145
+ * both.
146
+ *
147
+ * @param {unknown} offices - An affiliation's `data.governance.offices`.
148
+ * @returns {Map<string, string>} Office key → its description.
149
+ */
150
+ export function officeRoster(offices) {
151
+ const roster = new Map();
152
+ if (!mapping(offices)) return roster;
153
+ for (const [office, value] of Object.entries(offices)) {
154
+ if (typeof value === "string") roster.set(office, value);
155
+ else if (mapping(value))
156
+ roster.set(office, typeof value.description === "string" ? value.description : "");
157
+ else roster.set(office, "");
158
+ }
159
+ return roster;
160
+ }
161
+
162
+ /**
163
+ * The anchor an office's row is addressed by.
164
+ *
165
+ * Declared once, because the row that carries the anchor and the link that
166
+ * reaches it are drawn by different passes: two spellings of the same
167
+ * derivation is a link into a page that offers no such destination.
168
+ *
169
+ * An office whose name reduces to nothing URL-safe has no anchor, and the
170
+ * caller is the one that can say what to do without one.
171
+ *
172
+ * @param {string} office - The office's key, as the body wrote it.
173
+ * @returns {string} The anchor, or `""` where the name carries nothing.
174
+ */
175
+ export function officeAnchor(office) {
176
+ const slug = slugify(office);
177
+ return slug ? `office-${slug}` : "";
178
+ }
179
+
180
+ /**
181
+ * What an affiliation lends to a reference to it.
182
+ *
183
+ * A reference's value answers what was found as well as where it is — the same
184
+ * reason a target's `subType` travels with it. A standing is read on the
185
+ * *being*, and what it is called is declared on the *body*, so the ladder and
186
+ * the office roster ride along to the one place that needs them.
187
+ *
188
+ * Nothing rides for a note that declares neither.
189
+ *
190
+ * @param {object} fm - The affiliation note's frontmatter.
191
+ * @returns {{ranks?: Record<string, string>, offices?: Record<string, string>}|undefined}
192
+ * What a reference carries, or nothing.
193
+ */
194
+ export function standingsDigest(fm) {
195
+ const governance = fm?.data?.governance;
196
+ if (!mapping(governance)) return undefined;
197
+ const ladder = rankLadder(governance.ranks);
198
+ const roster = officeRoster(governance.offices);
199
+ if (!ladder.size && !roster.size) return undefined;
200
+ return {
201
+ ...(ladder.size ? { ranks: Object.fromEntries(ladder) } : {}),
202
+ ...(roster.size ? { offices: Object.fromEntries(roster) } : {}),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * How a standing reads beside the body that confers it.
208
+ *
209
+ * The office leads, because it is the more particular of the two: a reader
210
+ * meeting "War Chief" has been told the rung as well. The body closes the
211
+ * phrase, which is the whole point of keying the entry by it — a standing with
212
+ * no body named is the lossy form.
213
+ *
214
+ * No article is supplied. One body's name reads with a "the" in front of it and
215
+ * the next does not, and inventing one is this build editing the corpus.
216
+ *
217
+ * @param {object} standing - `{rank, office}`, either or both absent.
218
+ * @param {string} body - What the body is called.
219
+ * @param {{ranks?: Record<string, string>}} [digest] - The body's ladder.
220
+ * @returns {string} The phrase, or the body's name where no standing is held.
221
+ */
222
+ export function standingPhrase(standing, body, digest) {
223
+ const parts = [];
224
+ if (typeof standing?.office === "string" && standing.office.trim())
225
+ parts.push(standing.office.trim());
226
+ const level = Number(standing?.rank);
227
+ if (Number.isFinite(level)) {
228
+ const title = digest?.ranks?.[String(level)];
229
+ parts.push(title ? title : `Rank ${level}`);
230
+ }
231
+ return parts.length ? `${parts.join(", ")}, of ${body}` : body;
232
+ }
233
+
234
+ /**
235
+ * Validate a being's memberships and the standing it holds in each.
236
+ *
237
+ * Four questions, and the last two are what keying the entry by its body buys:
238
+ *
239
+ * 1. the value is a map keyed by Address (or the list this window still takes);
240
+ * 2. every key names an affiliation that resolves, once;
241
+ * 3. a `rank` is a level the named body's `governance.ranks` declares;
242
+ * 4. an `office` is a key of that body's `governance.offices`.
243
+ *
244
+ * Both of the last two are read from the named body's own frontmatter as the
245
+ * lint runs, so a renamed rung or a renamed post fails on every being pointing
246
+ * at it rather than on a list somebody has to remember to edit.
247
+ *
248
+ * @param {object} note - The note.
249
+ * @param {{index?: object}} [options] - The lint's options.
250
+ * @returns {object[]} Located findings.
251
+ */
252
+ export function checkStandings(note, { index } = {}) {
253
+ const value = note.fm?.data?.affiliations;
254
+ const { form, entries } = readStandings(value);
255
+ if (form === "absent") return [];
256
+ if (form === "malformed")
257
+ return [
258
+ {
259
+ ...position(note, ["data", "affiliations"]),
260
+ message:
261
+ "`data.affiliations` must be a map keyed by an affiliation Address, " +
262
+ "each entry holding that body's standing",
263
+ },
264
+ ];
265
+
266
+ const findings = [];
267
+ const seen = new Map();
268
+ // The list form names bodies and has nowhere to put a standing. Its entries
269
+ // are still held to being affiliations that resolve, so a tree part-way
270
+ // through the sweep keeps the check it had.
271
+ const isKey = form === "map";
272
+ for (const { body, sourceKey, standing } of entries) {
273
+ const path = ["data", "affiliations", sourceKey];
274
+ const resolved = resolveBody(note, index, body);
275
+ findings.push(...bodyFindings(note, body, path, isKey, resolved));
276
+ if (resolved.address) {
277
+ if (seen.has(resolved.address)) {
278
+ findings.push({
279
+ ...position(note, path, isKey),
280
+ message:
281
+ `\`data.affiliations\` names ${resolved.address} twice, as ` +
282
+ `${JSON.stringify(String(seen.get(resolved.address)))} and ` +
283
+ `${JSON.stringify(String(sourceKey))}; one membership per body`,
284
+ });
285
+ continue;
286
+ }
287
+ seen.set(resolved.address, sourceKey);
288
+ }
289
+ if (!isKey) continue;
290
+ if (standing === undefined || standing === null) continue;
291
+ if (Array.isArray(standing) && standing.length === 0) continue;
292
+ const named = resolved.address ?? String(body);
293
+ if (!mapping(standing)) {
294
+ findings.push({
295
+ ...position(note, path),
296
+ message:
297
+ `\`data.affiliations\` entry for ${named} must hold that body's standing ` +
298
+ "as a map of `rank` and `office`, but reads " +
299
+ JSON.stringify(standing),
300
+ });
301
+ continue;
302
+ }
303
+ for (const key of Object.keys(standing)) {
304
+ if (STANDING_KEYS.includes(key)) continue;
305
+ findings.push({
306
+ ...position(note, [...path, key], true),
307
+ message:
308
+ `\`data.affiliations\` entry for ${named} has unknown key \`${key}\`; a ` +
309
+ `standing holds ${STANDING_KEYS.map((name) => `\`${name}\``).join(" and ")}`,
310
+ });
311
+ }
312
+ findings.push(...rankFindings(note, standing, named, path, resolved));
313
+ findings.push(...officeFindings(note, standing, named, path, resolved));
314
+ }
315
+ return findings;
316
+ }
317
+
318
+ /**
319
+ * Where a finding about one authored key sits.
320
+ *
321
+ * @param {object} note - The note.
322
+ * @param {Array<string|number>} path - The frontmatter path.
323
+ * @param {boolean} [key] - Whether the key itself is at fault.
324
+ * @returns {object} The located, error-severity half of a finding.
325
+ */
326
+ function position(note, path, key) {
327
+ return {
328
+ file: note.file,
329
+ ...positionOfFrontmatterPath(note.raw ?? "", path, key ? { key: true } : undefined),
330
+ severity: "error",
331
+ };
332
+ }
333
+
334
+ /**
335
+ * The affiliation one key or entry names, and what that note declares.
336
+ *
337
+ * @param {object} note - The citing note.
338
+ * @param {object|undefined} index - The lint's address index.
339
+ * @param {unknown} body - The authored Address.
340
+ * @returns {{address?: string, type?: string, reason?: string, fm?: object, missing?: boolean}}
341
+ * What it names.
342
+ */
343
+ function resolveBody(note, index, body) {
344
+ const tuple = parseAddress(
345
+ body,
346
+ {
347
+ package: index?.contentPackage ?? note.fm?.package,
348
+ system: "note",
349
+ type: "affiliation",
350
+ types: index?.types,
351
+ packages: index?.packages,
352
+ },
353
+ { declared: true },
354
+ );
355
+ if (tuple.reason) return { reason: tuple.reason };
356
+ if (!acceptsType(tuple, STANDING_BODY_TYPES)) return { type: tuple.type };
357
+ const address = renderAddress(tuple);
358
+ if (!index?.addressHit) return { address };
359
+ const target = index.addressHit(address);
360
+ if (!target) return { address, missing: true };
361
+ return { address, fm: target.fm ?? target };
362
+ }
363
+
364
+ /** Findings about the body an entry names. */
365
+ function bodyFindings(note, body, path, isKey, found) {
366
+ const at = position(note, path, isKey);
367
+ const written = isAddressTuple(body) ? renderAddress(body) : String(body);
368
+ const label = `\`data.affiliations\`${isKey ? " key" : ""} ${JSON.stringify(written)}`;
369
+ if (found.reason)
370
+ return [{ ...at, message: `${label} is not a complete Address (${found.reason})` }];
371
+ if (found.type)
372
+ return [{ ...at, message: `\`data.affiliations\` accepts affiliation, not ${found.type}` }];
373
+ if (found.missing)
374
+ return [
375
+ {
376
+ ...at,
377
+ message:
378
+ `\`data.affiliations\` names ${found.address}, which does not resolve ` +
379
+ "in this package or its declared dependencies",
380
+ },
381
+ ];
382
+ return [];
383
+ }
384
+
385
+ /** Whether a `rank` is a rung the named body confers. */
386
+ function rankFindings(note, standing, body, path, resolved) {
387
+ if (standing.rank === undefined || standing.rank === null) return [];
388
+ const at = position(note, [...path, "rank"]);
389
+ const level = typeof standing.rank === "number" ? standing.rank : Number(standing.rank);
390
+ if (typeof standing.rank !== "number" || !Number.isInteger(level))
391
+ return [
392
+ {
393
+ ...at,
394
+ message:
395
+ `\`data.affiliations\` entry for ${body}: \`rank\` must be the level on that body's own ` +
396
+ `ladder, as a whole number, but reads ${JSON.stringify(standing.rank)}`,
397
+ },
398
+ ];
399
+ if (!resolved.fm) return [];
400
+ const ladder = rankLadder(resolved.fm.data?.governance?.ranks);
401
+ if (ladder.has(level)) return [];
402
+ if (!ladder.size)
403
+ return [
404
+ {
405
+ ...at,
406
+ message:
407
+ `\`data.affiliations\` entry for ${body} states a rung, and that body ` +
408
+ "declares no `data.governance.ranks` ladder to hold one",
409
+ },
410
+ ];
411
+ const levels = [...ladder.keys()].sort((a, b) => a - b);
412
+ return [
413
+ {
414
+ ...at,
415
+ message:
416
+ `\`data.affiliations\` entry for ${body}: rank ${level} is not a level ` +
417
+ `${resolved.address} confers; its ladder runs ${levels.join(", ")}`,
418
+ },
419
+ ];
420
+ }
421
+
422
+ /** Whether an `office` is a post the named body names. */
423
+ function officeFindings(note, standing, body, path, resolved) {
424
+ if (standing.office === undefined || standing.office === null) return [];
425
+ const at = position(note, [...path, "office"]);
426
+ if (typeof standing.office !== "string" || !standing.office.trim())
427
+ return [
428
+ {
429
+ ...at,
430
+ message:
431
+ `\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
432
+ `\`governance.offices\`, but reads ${JSON.stringify(standing.office)}`,
433
+ },
434
+ ];
435
+ if (!resolved.fm) return [];
436
+ const roster = officeRoster(resolved.fm.data?.governance?.offices);
437
+ if (roster.has(standing.office)) return [];
438
+ if (!roster.size)
439
+ return [
440
+ {
441
+ ...at,
442
+ message:
443
+ `\`data.affiliations\` entry for ${body} names an office, and that body ` +
444
+ `${resolved.address} declares no \`data.governance.offices\` to hold one`,
445
+ },
446
+ ];
447
+ return [
448
+ {
449
+ ...at,
450
+ message:
451
+ `\`data.affiliations\` entry for ${body}: office ${JSON.stringify(standing.office)} is not ` +
452
+ `an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
453
+ },
454
+ ];
455
+ }