@heroiclands/package-build 23.6.1 → 23.7.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.
@@ -53,7 +53,8 @@ import { parseAddress, renderAddress } from "./address.mjs";
53
53
  import { glossaryLanguage } from "./glossary-language.mjs";
54
54
  import { isNoteRecord } from "./index-records.mjs";
55
55
  import { parseWordClass, WORD_CLASS_BASES } from "./word-classes.mjs";
56
- import { isLanguageRecord, lineageChain, pieceGlosses } from "./word-rows.mjs";
56
+ import { citationLookup, isLanguageRecord, lineageChain, pieceGlosses } from "./word-rows.mjs";
57
+ import { glossSenses } from "./word-terms.mjs";
57
58
 
58
59
  /** Every column of the `dictionary` relation, in order. */
59
60
  export const DICTIONARY_COLUMNS = Object.freeze([
@@ -481,9 +482,10 @@ export function dictionaryAndTermRows(records, all = records) {
481
482
  return ETYMOLOGY_KEYS.some((key) => out[key] !== null) ? out : null;
482
483
  };
483
484
 
485
+ const citedBy = citationLookup(byKey.values());
484
486
  const dictionary = [];
485
487
  for (const { language, name, found, marks } of wordRows.values()) {
486
- const { word, depth } = found;
488
+ const { word, depth, statedIn } = found;
487
489
  const nameSort =
488
490
  marked(marks, "name_sort") ??
489
491
  (typeof word.name_sort === "string" && word.name_sort ? word.name_sort : name);
@@ -498,17 +500,8 @@ export function dictionaryAndTermRows(records, all = records) {
498
500
  (typeof word.pronunciation === "string" ? word.pronunciation : null),
499
501
  class: typeof word.class === "string" ? word.class : null,
500
502
  etymology: etymologyOf(found),
501
- senses:
502
- typeof word.gloss === "string" && word.gloss.trim() ?
503
- [{ text: word.gloss, bodies: [] }]
504
- : [],
505
- usage: [
506
- ...new Set(
507
- (Array.isArray(word.attested) ? word.attested : []).filter(
508
- (value) => typeof value === "string" && citable(value),
509
- ),
510
- ),
511
- ].sort(compare),
503
+ senses: glossSenses(word.gloss).map((text) => ({ text, bodies: [] })),
504
+ usage: [...new Set(citedBy(statedIn, word.form).filter(citable))].sort(compare),
512
505
  common: marks.some((one) => one.entry.common === true),
513
506
  depth,
514
507
  });
@@ -62,6 +62,7 @@ export const DERIVED_KEYS = Object.freeze([
62
62
  "documents",
63
63
  "asset",
64
64
  "resolvedDates",
65
+ "citations",
65
66
  ]);
66
67
 
67
68
  /**
@@ -0,0 +1,613 @@
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
+ * What a language note's register, tongues, scope, sounds and cognates mean,
16
+ * beyond their shape.
17
+ *
18
+ * The inner-key check holds every entry to its declared keys, kinds and closed
19
+ * lists. This check owns the rest, each finding located at the key at fault:
20
+ *
21
+ * - no skill of another subType states these keys;
22
+ * - a tongue tag is lowercase letters and digits, optionally closing on
23
+ * `:<tongue>`, and stands once; a tongue's `language` names a language note;
24
+ * - every tag a register row or a letter row names is declared in
25
+ * `data.tongues`, `translation` apart: `exonym:vylari` is declared by
26
+ * `exonym:<tongue>`;
27
+ * - an edge stands once in `data.sounds.edges`;
28
+ * - a sound stands once in each mutation of `data.sounds.mutations`;
29
+ * - a cluster stands in one group of `data.sounds.clusters.groups`, and a
30
+ * language stating groups states no `innerCluster` edge;
31
+ * - every vowel `data.sounds.syllables.harmony.partners` keys is a vowel of
32
+ * the harmony, and every partner one of its `front` vowels;
33
+ * - every Address in `data.scope` resolves, and every type it names is a note
34
+ * type;
35
+ * - every language a cognate's `forms` names is a language note;
36
+ * - a register row states a `tongue` or a `kind`;
37
+ * - a sound-change set's name stands once in the note, a step's label once
38
+ * in its set, and its `from` and `to` name language notes;
39
+ * - a frame's classes are bases of the master word classes;
40
+ * - a calling-name closing states a `form` or `doubled: true`, not both;
41
+ * - a form stands once in `data.older`;
42
+ * - the mutation `data.negation` names is one of `data.sounds.mutations`;
43
+ * - in `data.naming`, a class of name has one shape, which states its
44
+ * syllables, its consonants per vowel or both, each range's `min` no
45
+ * higher than its `max`; an edge stands once for each class of name; a kind
46
+ * of name has one set of draws; and a held-from row takes `elements` only
47
+ * where it takes an `element`, and is clipped or given a genitive only
48
+ * where it takes a name.
49
+ *
50
+ * @module
51
+ */
52
+
53
+ import { pathLabel as label } from "./data-keys.mjs";
54
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
55
+ import { addressKeyOf } from "./culture-reach.mjs";
56
+ import { AddressEntries } from "./address-values.mjs";
57
+ import { isAddressTuple, renderAddress } from "./address.mjs";
58
+ import {
59
+ ANY_NAME,
60
+ HELD_NAMES,
61
+ HELD_TAKES,
62
+ LANGUAGE_DATA_KEYS,
63
+ RESERVED_TONGUE,
64
+ SCOPE_KINDS,
65
+ TONGUE_PLACEHOLDER,
66
+ } from "./language-terms.mjs";
67
+ import { LANGUAGE_SUBTYPE } from "./word-terms.mjs";
68
+ import { WORD_CLASS_BASES } from "./word-classes.mjs";
69
+
70
+ /** The bases a frame's `class` names. */
71
+ const CLASS_BASES = new Set(WORD_CLASS_BASES.map((item) => item.token));
72
+
73
+ /** Whether a value is a plain map. */
74
+ const isMap = (value) =>
75
+ Boolean(value) &&
76
+ typeof value === "object" &&
77
+ !Array.isArray(value) &&
78
+ !isAddressTuple(value) &&
79
+ !(value instanceof AddressEntries);
80
+
81
+ /** A list value, or an empty one. */
82
+ const listOf = (value) => (Array.isArray(value) ? value : []);
83
+
84
+ /** The shape of a declared tongue tag. */
85
+ const TAG_PATTERN = new RegExp(`^[a-z][a-z0-9]*(?::${TONGUE_PLACEHOLDER})?$`);
86
+
87
+ /** The shape of a tongue tag a row names: a tag, or a tag and a tongue. */
88
+ const USED_TAG_PATTERN = /^([a-z][a-z0-9]*)(?::([a-z][a-z0-9]*))?$/;
89
+
90
+ /** The type a bare shortcode in each scope list defaults to. */
91
+ const SCOPE_DEFAULT_TYPES = Object.freeze({
92
+ folders: "folder",
93
+ cultures: "lore",
94
+ lore: "lore",
95
+ parents: "place",
96
+ });
97
+
98
+ /**
99
+ * Whether a tag a row names is one `data.tongues` declares.
100
+ *
101
+ * @param {string} tag - The tag as the row writes it.
102
+ * @param {Set<string>} declared - The declared tags.
103
+ * @returns {boolean}
104
+ */
105
+ export function tongueDeclared(tag, declared) {
106
+ if (tag === RESERVED_TONGUE) return true;
107
+ const match = USED_TAG_PATTERN.exec(tag);
108
+ if (!match) return false;
109
+ return match[2] === undefined ?
110
+ declared.has(tag)
111
+ : declared.has(`${match[1]}:${TONGUE_PLACEHOLDER}`);
112
+ }
113
+
114
+ /**
115
+ * The entries of a cognate's `forms`, whatever form it reached the check in:
116
+ * `[written key, value, Address]`.
117
+ *
118
+ * @param {unknown} forms - The `forms` value.
119
+ * @returns {Array<[string, unknown, unknown]>}
120
+ */
121
+ function formEntries(forms) {
122
+ if (forms instanceof AddressEntries)
123
+ return forms.entries.map((entry) => [entry.sourceKey, entry.value, entry.target]);
124
+ if (isMap(forms)) return Object.entries(forms).map(([key, value]) => [key, value, key]);
125
+ return [];
126
+ }
127
+
128
+ /**
129
+ * Check a `skill` note's language data against what it means.
130
+ *
131
+ * @param {object} note - A `skill` note: `file`, `raw` and parsed `fm`.
132
+ * @param {object} [context]
133
+ * @param {object} [context.index] - The index, for whether an Address
134
+ * resolves. Without one, only what the note says of itself is checked.
135
+ * @param {readonly string[]} [context.types] - The note types a scope may name.
136
+ * @returns {object[]} The findings.
137
+ */
138
+ export function checkLanguageData(note, { index, types } = {}) {
139
+ const fm = note?.fm ?? {};
140
+ const data = isMap(fm.data) ? fm.data : {};
141
+ const raw = note.raw ?? "";
142
+ const finding = (path, severity, message, key = false) => ({
143
+ file: note.file,
144
+ ...positionOfFrontmatterPath(raw, path, { key }),
145
+ severity,
146
+ message,
147
+ });
148
+ const stated = (key) => data[key] !== undefined && data[key] !== null;
149
+
150
+ if (String(fm.subType ?? "").toLowerCase() !== LANGUAGE_SUBTYPE)
151
+ return LANGUAGE_DATA_KEYS.filter(stated).map((key) =>
152
+ finding(
153
+ ["data", key],
154
+ "error",
155
+ `\`data.${key}\` describes a language, and this note's \`subType\` is not ` +
156
+ `\`${LANGUAGE_SUBTYPE}\``,
157
+ true,
158
+ ),
159
+ );
160
+
161
+ const findings = [];
162
+
163
+ /**
164
+ * The note an Address names, or a finding where it names none.
165
+ *
166
+ * @param {unknown} value - The Address as the note holds it.
167
+ * @param {Array<string|number>} path - Where it sits.
168
+ * @param {string|undefined} type - The type a bare shortcode defaults to.
169
+ * @param {boolean} [key] - Whether the Address is a map key.
170
+ * @returns {{target?: object, key?: string, finding?: object}}
171
+ */
172
+ function resolve(value, path, type, key = false) {
173
+ if (!index?.addressHit || value === undefined || value === null) return {};
174
+ const canonical = addressKeyOf(value, index, type);
175
+ if (!canonical)
176
+ return {
177
+ finding: finding(
178
+ path,
179
+ "error",
180
+ `\`${label(path)}\` is not an Address: ${JSON.stringify(
181
+ isAddressTuple(value) ? renderAddress(value) : value,
182
+ )}`,
183
+ key,
184
+ ),
185
+ };
186
+ const target = index.addressHit(canonical);
187
+ if (!target)
188
+ return {
189
+ finding: finding(
190
+ path,
191
+ "error",
192
+ `\`${label(path)}\` names ${canonical}, which does not resolve in this ` +
193
+ "package or its declared dependencies",
194
+ key,
195
+ ),
196
+ };
197
+ return { target, key: canonical };
198
+ }
199
+
200
+ /** As {@link resolve}, and the target must be a language note. */
201
+ function language(value, path, key = false) {
202
+ const result = resolve(value, path, "skill", key);
203
+ if (result.finding) return [result.finding];
204
+ if (!result.target) return [];
205
+ const target = result.target.fm ?? result.target;
206
+ if (target.type === "skill" && target.subType === LANGUAGE_SUBTYPE) return [];
207
+ return [
208
+ finding(
209
+ path,
210
+ "error",
211
+ `\`${label(path)}\` names ${result.key}, which is not a skill whose subType is ` +
212
+ "language",
213
+ key,
214
+ ),
215
+ ];
216
+ }
217
+
218
+ // ── Tongues ──────────────────────────────────────────────────────────────
219
+ const declared = new Set();
220
+ const tagAt = new Map();
221
+ listOf(data.tongues).forEach((tongue, i) => {
222
+ if (!isMap(tongue)) return;
223
+ const at = (key) => ["data", "tongues", i, key];
224
+ if (typeof tongue.tag === "string") {
225
+ if (!TAG_PATTERN.test(tongue.tag))
226
+ findings.push(
227
+ finding(
228
+ at("tag"),
229
+ "error",
230
+ `the tag \`${tongue.tag}\` is not lowercase letters and digits, optionally ` +
231
+ `closing on \`:${TONGUE_PLACEHOLDER}\``,
232
+ ),
233
+ );
234
+ else if (tagAt.has(tongue.tag))
235
+ findings.push(
236
+ finding(
237
+ at("tag"),
238
+ "error",
239
+ `the tag \`${tongue.tag}\` stands twice, first at ` +
240
+ `\`data.tongues[${tagAt.get(tongue.tag)}]\``,
241
+ ),
242
+ );
243
+ else {
244
+ tagAt.set(tongue.tag, i);
245
+ declared.add(tongue.tag);
246
+ }
247
+ }
248
+ findings.push(...language(tongue.language, at("language")));
249
+ });
250
+
251
+ /**
252
+ * A finding for each tag a row names that `data.tongues` does not declare.
253
+ *
254
+ * @param {unknown[]} tags - The tags the row names.
255
+ * @param {(j: number) => Array<string|number>} pathOf - Where the `j`th sits.
256
+ */
257
+ const undeclared = (tags, pathOf) => {
258
+ tags.forEach((tag, j) => {
259
+ if (typeof tag !== "string" || tongueDeclared(tag, declared)) return;
260
+ findings.push(
261
+ finding(
262
+ pathOf(j),
263
+ "error",
264
+ `the tongue \`${tag}\` is not declared in \`data.tongues\``,
265
+ ),
266
+ );
267
+ });
268
+ };
269
+ listOf(data.register).forEach((row, i) => {
270
+ if (!isMap(row)) return;
271
+ if (row.tongue == null && row.kind == null)
272
+ findings.push(
273
+ finding(
274
+ ["data", "register", i],
275
+ "error",
276
+ `\`data.register[${i}]\` states neither a \`tongue\` nor a \`kind\``,
277
+ ),
278
+ );
279
+ if (typeof row.tongue !== "string") return;
280
+ undeclared([row.tongue], () => ["data", "register", i, "tongue"]);
281
+ });
282
+
283
+ // ── Sounds ───────────────────────────────────────────────────────────────
284
+ const sounds = isMap(data.sounds) ? data.sounds : {};
285
+ listOf(sounds.letters).forEach((row, i) => {
286
+ if (!isMap(row) || row.tongue === undefined || row.tongue === null) return;
287
+ const path = ["data", "sounds", "letters", i, "tongue"];
288
+ if (Array.isArray(row.tongue)) undeclared(row.tongue, (j) => [...path, j]);
289
+ else undeclared([row.tongue], () => path);
290
+ });
291
+ const edgeAt = new Map();
292
+ listOf(sounds.edges).forEach((row, i) => {
293
+ if (!isMap(row) || typeof row.edge !== "string") return;
294
+ if (edgeAt.has(row.edge))
295
+ findings.push(
296
+ finding(
297
+ ["data", "sounds", "edges", i, "edge"],
298
+ "error",
299
+ `the edge \`${row.edge}\` stands twice, first at ` +
300
+ `\`data.sounds.edges[${edgeAt.get(row.edge)}]\``,
301
+ ),
302
+ );
303
+ else edgeAt.set(row.edge, i);
304
+ });
305
+
306
+ if (isMap(sounds.clusters) && Array.isArray(sounds.clusters.groups))
307
+ listOf(sounds.edges).forEach((row, i) => {
308
+ if (!isMap(row) || row.edge !== "innerCluster") return;
309
+ findings.push(
310
+ finding(
311
+ ["data", "sounds", "edges", i, "edge"],
312
+ "error",
313
+ "`data.sounds.clusters.groups` states the clusters inside a word, so no " +
314
+ "`innerCluster` edge does",
315
+ ),
316
+ );
317
+ });
318
+ const clusterAt = new Map();
319
+ listOf(sounds.clusters?.groups).forEach((group, i) => {
320
+ if (!isMap(group)) return;
321
+ listOf(group.clusters).forEach((cluster, j) => {
322
+ if (typeof cluster !== "string") return;
323
+ if (clusterAt.has(cluster))
324
+ findings.push(
325
+ finding(
326
+ ["data", "sounds", "clusters", "groups", i, "clusters", j],
327
+ "error",
328
+ `the cluster \`${cluster}\` stands twice, first in ` +
329
+ `\`data.sounds.clusters.groups[${clusterAt.get(cluster)}]\``,
330
+ ),
331
+ );
332
+ else clusterAt.set(cluster, i);
333
+ });
334
+ });
335
+
336
+ const mutationAt = new Map();
337
+ listOf(sounds.mutations).forEach((row, i) => {
338
+ if (!isMap(row) || typeof row.mutation !== "string" || typeof row.sound !== "string")
339
+ return;
340
+ const key = JSON.stringify([row.mutation, row.sound]);
341
+ if (mutationAt.has(key))
342
+ findings.push(
343
+ finding(
344
+ ["data", "sounds", "mutations", i, "sound"],
345
+ "error",
346
+ `the sound \`${row.sound}\` stands twice in the mutation \`${row.mutation}\`, ` +
347
+ `first at \`data.sounds.mutations[${mutationAt.get(key)}]\``,
348
+ ),
349
+ );
350
+ else mutationAt.set(key, i);
351
+ });
352
+
353
+ const harmony = isMap(sounds.syllables?.harmony) ? sounds.syllables.harmony : {};
354
+ if (isMap(harmony.partners)) {
355
+ const front = new Set(listOf(harmony.front));
356
+ const vowels = new Set([...listOf(harmony.back), ...front, ...listOf(harmony.neutral)]);
357
+ for (const [vowel, partner] of Object.entries(harmony.partners)) {
358
+ const path = ["data", "sounds", "syllables", "harmony", "partners", vowel];
359
+ if (!vowels.has(vowel))
360
+ findings.push(
361
+ finding(
362
+ path,
363
+ "error",
364
+ `\`${vowel}\` is not a vowel of \`data.sounds.syllables.harmony\``,
365
+ true,
366
+ ),
367
+ );
368
+ else if (typeof partner === "string" && !front.has(partner))
369
+ findings.push(
370
+ finding(
371
+ path,
372
+ "error",
373
+ `the partner \`${partner}\` of \`${vowel}\` is not one of the harmony's ` +
374
+ "`front` vowels",
375
+ ),
376
+ );
377
+ }
378
+ }
379
+
380
+ listOf(sounds.frames).forEach((row, i) => {
381
+ if (!isMap(row)) return;
382
+ listOf(row.class).forEach((base, j) => {
383
+ if (typeof base !== "string" || CLASS_BASES.has(base)) return;
384
+ findings.push(
385
+ finding(
386
+ ["data", "sounds", "frames", i, "class", j],
387
+ "error",
388
+ `\`${base}\` is not a base of the master word classes`,
389
+ ),
390
+ );
391
+ });
392
+ });
393
+
394
+ // ── Sound changes ────────────────────────────────────────────────────────
395
+ const setAt = new Map();
396
+ listOf(data.soundChanges).forEach((set, i) => {
397
+ if (!isMap(set)) return;
398
+ const at = (...rest) => ["data", "soundChanges", i, ...rest];
399
+ if (typeof set.name === "string") {
400
+ if (setAt.has(set.name))
401
+ findings.push(
402
+ finding(
403
+ at("name"),
404
+ "error",
405
+ `the set \`${set.name}\` stands twice, first at \`data.soundChanges[${setAt.get(set.name)}]\``,
406
+ ),
407
+ );
408
+ else setAt.set(set.name, i);
409
+ }
410
+ findings.push(...language(set.from, at("from")), ...language(set.to, at("to")));
411
+ const stepAt = new Map();
412
+ listOf(set.steps).forEach((step, j) => {
413
+ if (!isMap(step) || typeof step.step !== "string") return;
414
+ if (stepAt.has(step.step))
415
+ findings.push(
416
+ finding(
417
+ at("steps", j, "step"),
418
+ "error",
419
+ `the step \`${step.step}\` stands twice in its set, first at ` +
420
+ `\`data.soundChanges[${i}].steps[${stepAt.get(step.step)}]\``,
421
+ ),
422
+ );
423
+ else stepAt.set(step.step, j);
424
+ });
425
+ });
426
+
427
+ // ── Calling names ────────────────────────────────────────────────────────
428
+ const calling = isMap(data.callingNames) ? data.callingNames : {};
429
+ listOf(calling.closings).forEach((row, i) => {
430
+ if (!isMap(row)) return;
431
+ const form = row.form != null;
432
+ const doubled = row.doubled === true;
433
+ if (form === doubled)
434
+ findings.push(
435
+ finding(
436
+ ["data", "callingNames", "closings", i],
437
+ "error",
438
+ `\`data.callingNames.closings[${i}]\` states ${form ? "both" : "neither"} a ` +
439
+ "`form` and `doubled: true`; it states one",
440
+ ),
441
+ );
442
+ });
443
+
444
+ // ── Older forms and negation ─────────────────────────────────────────────
445
+ const olderAt = new Map();
446
+ listOf(data.older).forEach((row, i) => {
447
+ if (!isMap(row) || typeof row.form !== "string") return;
448
+ if (olderAt.has(row.form))
449
+ findings.push(
450
+ finding(
451
+ ["data", "older", i, "form"],
452
+ "error",
453
+ `the form \`${row.form}\` stands twice, first at \`data.older[${olderAt.get(row.form)}]\``,
454
+ ),
455
+ );
456
+ else olderAt.set(row.form, i);
457
+ });
458
+ const negation = isMap(data.negation) ? data.negation : {};
459
+ if (typeof negation.mutation === "string") {
460
+ const named = new Set(
461
+ listOf(sounds.mutations)
462
+ .filter((row) => isMap(row) && typeof row.mutation === "string")
463
+ .map((row) => row.mutation),
464
+ );
465
+ if (!named.has(negation.mutation))
466
+ findings.push(
467
+ finding(
468
+ ["data", "negation", "mutation"],
469
+ "error",
470
+ `the mutation \`${negation.mutation}\` is not one of \`data.sounds.mutations\``,
471
+ ),
472
+ );
473
+ }
474
+
475
+ // ── Naming ───────────────────────────────────────────────────────────────
476
+ const naming = isMap(data.naming) ? data.naming : {};
477
+ /**
478
+ * A finding at the second of two entries sharing a key.
479
+ *
480
+ * @param {string} list - The list under `data.naming`.
481
+ * @param {(row: object) => string|undefined} keyOf - The entry's key.
482
+ * @param {(row: object, first: number) => [Array<string|number>, string]} report
483
+ * - Where the second sits, relative to its entry, and what is said.
484
+ */
485
+ const once = (list, keyOf, report) => {
486
+ const seen = new Map();
487
+ listOf(naming[list]).forEach((row, i) => {
488
+ if (!isMap(row)) return;
489
+ const key = keyOf(row);
490
+ if (key === undefined) return;
491
+ if (seen.has(key)) {
492
+ const [rest, message] = report(row, seen.get(key));
493
+ findings.push(finding(["data", "naming", list, i, ...rest], "error", message));
494
+ } else seen.set(key, i);
495
+ });
496
+ };
497
+ once(
498
+ "shapes",
499
+ (row) => (typeof row.of === "string" ? row.of : undefined),
500
+ (row, first) => [
501
+ ["of"],
502
+ `the class of name \`${row.of}\` has its shape stated twice, first at ` +
503
+ `\`data.naming.shapes[${first}]\``,
504
+ ],
505
+ );
506
+ once(
507
+ "edges",
508
+ (row) =>
509
+ typeof row.edge === "string" ?
510
+ JSON.stringify([row.edge, row.of ?? ANY_NAME])
511
+ : undefined,
512
+ (row, first) => [
513
+ ["edge"],
514
+ `the edge \`${row.edge}\` of \`${row.of ?? ANY_NAME}\` stands twice, first at ` +
515
+ `\`data.naming.edges[${first}]\``,
516
+ ],
517
+ );
518
+ once(
519
+ "draws",
520
+ (row) => (typeof row.kind === "string" ? row.kind : undefined),
521
+ (row, first) => [
522
+ ["kind"],
523
+ `the kind \`${row.kind}\` has its draws stated twice, first at ` +
524
+ `\`data.naming.draws[${first}]\``,
525
+ ],
526
+ );
527
+ listOf(naming.shapes).forEach((row, i) => {
528
+ if (!isMap(row)) return;
529
+ const at = (...rest) => ["data", "naming", "shapes", i, ...rest];
530
+ if (row.syllables == null && row.perVowel == null)
531
+ findings.push(
532
+ finding(
533
+ at(),
534
+ "error",
535
+ `\`data.naming.shapes[${i}]\` states neither \`syllables\` nor \`perVowel\``,
536
+ ),
537
+ );
538
+ for (const key of ["syllables", "perVowel"]) {
539
+ const range = row[key];
540
+ if (!isMap(range) || typeof range.min !== "number" || typeof range.max !== "number")
541
+ continue;
542
+ if (range.min > range.max)
543
+ findings.push(
544
+ finding(
545
+ at(key),
546
+ "error",
547
+ `\`data.naming.shapes[${i}].${key}\` runs from ${range.min} to ${range.max}; ` +
548
+ "its `min` is above its `max`",
549
+ ),
550
+ );
551
+ }
552
+ });
553
+ listOf(naming.heldFrom).forEach((row, i) => {
554
+ if (!isMap(row) || !HELD_TAKES.includes(row.takes)) return;
555
+ const at = (...rest) => ["data", "naming", "heldFrom", i, ...rest];
556
+ const label = `\`data.naming.heldFrom[${i}]\``;
557
+ if (row.takes === "element") {
558
+ if (!listOf(row.elements).length)
559
+ findings.push(
560
+ finding(
561
+ at(),
562
+ "error",
563
+ `${label} takes an \`element\` and names no \`elements\``,
564
+ ),
565
+ );
566
+ } else if (row.elements != null)
567
+ findings.push(
568
+ finding(
569
+ at("elements"),
570
+ "error",
571
+ `\`elements\` lists what a place takes first, and ${label} takes a ` +
572
+ `\`${row.takes}\``,
573
+ true,
574
+ ),
575
+ );
576
+ if (!HELD_NAMES.includes(row.takes))
577
+ for (const key of ["clip", "genitive"])
578
+ if (row[key] != null)
579
+ findings.push(
580
+ finding(
581
+ at(key),
582
+ "error",
583
+ `\`${key}\` shapes a name, and ${label} takes a \`${row.takes}\``,
584
+ true,
585
+ ),
586
+ );
587
+ });
588
+
589
+ // ── Scope ────────────────────────────────────────────────────────────────
590
+ const scope = isMap(data.scope) ? data.scope : {};
591
+ for (const kind of SCOPE_KINDS) {
592
+ listOf(scope[kind]).forEach((value, i) => {
593
+ const path = ["data", "scope", kind, i];
594
+ if (kind === "paths") return;
595
+ if (kind === "types") {
596
+ if (types && typeof value === "string" && !types.includes(value))
597
+ findings.push(finding(path, "error", `\`${value}\` is not a note type`));
598
+ return;
599
+ }
600
+ const result = resolve(value, path, SCOPE_DEFAULT_TYPES[kind]);
601
+ if (result.finding) findings.push(result.finding);
602
+ });
603
+ }
604
+
605
+ // ── Cognates ─────────────────────────────────────────────────────────────
606
+ listOf(data.cognates).forEach((row, i) => {
607
+ if (!isMap(row)) return;
608
+ for (const [written, , target] of formEntries(row.forms))
609
+ findings.push(...language(target, ["data", "cognates", i, "forms", written], true));
610
+ });
611
+
612
+ return findings;
613
+ }