@heroiclands/package-build 23.3.0 → 23.3.2

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.
@@ -86,6 +86,7 @@
86
86
  */
87
87
 
88
88
  import { isAddressSegment } from "./address-charset.mjs";
89
+ import { cultureInheritance, reportCultureParents } from "./culture-parents.mjs";
89
90
  import {
90
91
  formatDiagnostic,
91
92
  locateFrontmatterKey,
@@ -1178,6 +1179,27 @@ export function collectContentIndex(
1178
1179
  // is found by its canonical address. Before the sort, which reads nothing
1179
1180
  // the pass writes.
1180
1181
  resolveCultures(records, { contentPackage, contentBase, foreignRecord, problems });
1182
+ const cultureNotes = records
1183
+ .filter((record) => isNoteRecord(record) && record.type === "lore")
1184
+ .map((record) => ({
1185
+ fm: record,
1186
+ file: noteFile(contentBase, record),
1187
+ raw: fs.readFileSync(noteFile(contentBase, record), "utf8"),
1188
+ }));
1189
+ const cultureByKey = new Map(
1190
+ cultureNotes.map((note) => [
1191
+ canonicalKey(contentPackage, "note", "lore", note.fm.shortcode),
1192
+ note,
1193
+ ]),
1194
+ );
1195
+ const inheritance = cultureInheritance({
1196
+ contentPackage,
1197
+ notes: cultureNotes,
1198
+ addressHit: (key) => cultureByKey.get(key) ?? foreignRecord?.(key),
1199
+ });
1200
+ reportCultureParents(inheritance.findings, problems);
1201
+ for (const [note, inherited] of inheritance.values)
1202
+ if (Object.keys(inherited).length) note.fm.data = { ...note.fm.data, ...inherited };
1181
1203
 
1182
1204
  // Sorted at every depth like a note's record, and for the same reason: the
1183
1205
  // declaration order of the `asset` fields is a fact about the emitter, not
@@ -55,6 +55,7 @@
55
55
  import { readWikilink } from "./wikilink-syntax.mjs";
56
56
  import { encodeAddresses } from "./address-values.mjs";
57
57
  import fs from "node:fs";
58
+ import { cultureRecordProblems, cultureInheritedFields } from "./culture-parents.mjs";
58
59
  import path from "node:path";
59
60
 
60
61
  import { matchAllOutsideCode } from "./code-fences.mjs";
@@ -224,6 +225,14 @@ export function buildLinkIndex(
224
225
  const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
225
226
  const type = record.type.toLowerCase();
226
227
  const note = { file: absPath, rel, fm, body, raw, type };
228
+ const hierarchyProblems = cultureRecordProblems(record);
229
+ note.inheritedCultureFields = cultureInheritedFields(record);
230
+ if (hierarchyProblems)
231
+ note.reportedCultureParents = new Set(
232
+ hierarchyProblems
233
+ .filter((problem) => problem.file === absPath)
234
+ .map((problem) => problem.parentIndex),
235
+ );
227
236
 
228
237
  for (const hit of frontmatterWikilinks(fm)) {
229
238
  frontmatterLinks.push({ note, ...hit });
@@ -0,0 +1,301 @@
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
+ import { isDeepStrictEqual } from "node:util";
15
+ import { canonicalKey, parseAddress, renderAddress } from "./address.mjs";
16
+ import { positionOfFrontmatterPath, formatDiagnostic } from "./diagnostics.mjs";
17
+
18
+ const fields = ["language", "appearanceProfile"];
19
+ const payload = (note) => note.fm ?? note;
20
+ const dataOf = (note) => payload(note).data ?? payload(note);
21
+ const culture = (note) => payload(note).type === "lore" && payload(note).subType === "culture";
22
+ const parentsOf = (note) => (Array.isArray(dataOf(note).parents) ? dataOf(note).parents : []);
23
+
24
+ /** Read a culture parent against the field's Address defaults. */
25
+ function address(value, index) {
26
+ const tuple = parseAddress(value, {
27
+ package: index.contentPackage,
28
+ system: "note",
29
+ type: "lore",
30
+ types: index.types,
31
+ packages: index.packages,
32
+ });
33
+ return tuple.reason ? undefined : tuple;
34
+ }
35
+
36
+ /** The authored edge graph, with every cyclic edge identified. */
37
+ function graph(index) {
38
+ const out = new Map();
39
+ const queue = [...(index.notes ?? [])].filter(culture);
40
+ while (queue.length) {
41
+ const note = queue.pop();
42
+ if (out.has(note)) continue;
43
+ const edges = [];
44
+ out.set(note, edges);
45
+ parentsOf(note).forEach((value, position) => {
46
+ const tuple = address(value, index);
47
+ if (!tuple || tuple.type !== "lore") return;
48
+ const target = index.addressHit?.(renderAddress(tuple));
49
+ if (!target || !culture(target)) return;
50
+ edges.push({ target, position });
51
+ queue.push(target);
52
+ });
53
+ }
54
+ const reaches = (start, end) => {
55
+ const seen = new Set();
56
+ const queue = [start];
57
+ while (queue.length) {
58
+ const current = queue.pop();
59
+ if (current === end) return true;
60
+ if (seen.has(current)) continue;
61
+ seen.add(current);
62
+ queue.push(...(out.get(current) ?? []).map((edge) => edge.target));
63
+ }
64
+ return false;
65
+ };
66
+ const cyclic = new Map();
67
+ for (const [note, edges] of out)
68
+ cyclic.set(
69
+ note,
70
+ new Set(
71
+ edges.filter((edge) => reaches(edge.target, note)).map((edge) => edge.position),
72
+ ),
73
+ );
74
+ return { out, cyclic };
75
+ }
76
+
77
+ /** A located hierarchy finding with the Address of the culture it concerns. */
78
+ function finding(note, index, path, message) {
79
+ const fm = payload(note);
80
+ return {
81
+ file: note.file,
82
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "parents", ...path]),
83
+ severity: "error",
84
+ code: "culture-parents",
85
+ cultureAddress: canonicalKey(index.contentPackage, "note", "lore", fm.shortcode),
86
+ parentIndex: path[0],
87
+ message,
88
+ };
89
+ }
90
+
91
+ /** Validate subtype scope and culture targets; unresolved targets use the ordinary reference check. */
92
+ function parentFindings(note, index, analysis, unresolved) {
93
+ const data = dataOf(note);
94
+ if (data.parents === undefined || data.parents === null) return [];
95
+ if (!culture(note))
96
+ return [finding(note, index, [], "data.parents is stated by only culture lore notes")];
97
+ const findings = [];
98
+ parentsOf(note).forEach((value, position) => {
99
+ const tuple = address(value, index);
100
+ if (!tuple) return;
101
+ const key = renderAddress(tuple);
102
+ const target = tuple.type === "lore" ? index.addressHit?.(key) : undefined;
103
+ if (tuple.type !== "lore") {
104
+ if (unresolved)
105
+ findings.push(
106
+ finding(
107
+ note,
108
+ index,
109
+ [position],
110
+ `data.parents.${position} accepts lore, not ${tuple.type}`,
111
+ ),
112
+ );
113
+ } else if (!target) {
114
+ if (unresolved)
115
+ findings.push(
116
+ finding(
117
+ note,
118
+ index,
119
+ [position],
120
+ `data.parents.${position} names ${key}, which does not resolve in this package or its declared dependencies`,
121
+ ),
122
+ );
123
+ } else if (!culture(target))
124
+ findings.push(
125
+ finding(
126
+ note,
127
+ index,
128
+ [position],
129
+ `data.parents.${position} names ${key}, whose subType is not culture`,
130
+ ),
131
+ );
132
+ else if (analysis.cyclic.get(note)?.has(position))
133
+ findings.push(
134
+ finding(
135
+ note,
136
+ index,
137
+ [position],
138
+ `data.parents.${position} forms a culture parent cycle; a culture is never its own ancestor`,
139
+ ),
140
+ );
141
+ });
142
+ return findings;
143
+ }
144
+
145
+ const graphs = new WeakMap();
146
+ const hierarchyProblems = new WeakMap();
147
+ const inheritedFields = new WeakMap();
148
+ const inheritedSources = new WeakMap();
149
+
150
+ /** The authored source of one inherited field, retained outside the published record. */
151
+ export function cultureFieldSource(records, key, field) {
152
+ const record = records.find(
153
+ (entry) =>
154
+ entry.type === "lore" &&
155
+ entry.subType === "culture" &&
156
+ canonicalKey(entry.package, "note", "lore", entry.shortcode) === key,
157
+ );
158
+ return record && inheritedSources.get(record)?.get(field);
159
+ }
160
+
161
+ /** Fields supplied by an ancestor rather than authored on this record. */
162
+ export function cultureInheritedFields(record) {
163
+ return inheritedFields.get(record);
164
+ }
165
+
166
+ /** Read the hierarchy findings retained beside a resolved index record. */
167
+ export function cultureRecordProblems(record) {
168
+ return hierarchyProblems.get(record);
169
+ }
170
+
171
+ /** Read hierarchy findings attached to one culture's resolved index record. */
172
+ export function cultureHierarchyProblems(records, key) {
173
+ return records.flatMap((record) =>
174
+ (
175
+ record.type === "lore" &&
176
+ record.subType === "culture" &&
177
+ canonicalKey(record.package, "note", "lore", record.shortcode) === key
178
+ ) ?
179
+ (hierarchyProblems.get(record) ?? [])
180
+ : [],
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Check the declared culture-parent field in the normal frontmatter lint.
186
+ * @param {object} note - The note's frontmatter and source location.
187
+ * @param {{index?: object}} [options] - The corpus and its Address lookup.
188
+ * @returns {object[]} Located findings.
189
+ */
190
+ export function checkCultureParents(note, { index } = {}) {
191
+ if (note.reportedCultureParents) return [];
192
+ const context = index ?? { contentPackage: undefined, notes: [note] };
193
+ let analysis = graphs.get(context);
194
+ if (!analysis) {
195
+ analysis = graph(context);
196
+ graphs.set(context, analysis);
197
+ }
198
+ return parentFindings(note, context, analysis, false);
199
+ }
200
+
201
+ /**
202
+ * Resolve each omitted culture field independently, searching parents in listed depth-first order.
203
+ * Same-depth disagreement withholds the inherited field and produces a finding.
204
+ * @param {object} index - The authored culture nodes and Address lookup.
205
+ * @returns {{values: Map<object, object>, findings: object[]}}
206
+ */
207
+ export function cultureInheritance(index) {
208
+ const analysis = graph(index);
209
+ const findings = [];
210
+ const values = new Map();
211
+ for (const note of index.notes ?? []) {
212
+ if (!culture(note)) continue;
213
+ findings.push(...parentFindings(note, index, analysis, true));
214
+ const inherited = {};
215
+ const sources = new Map();
216
+ for (const field of fields) {
217
+ if (dataOf(note)[field] !== undefined) continue;
218
+ const candidates = [];
219
+ const work = [{ node: note, depth: 0 }];
220
+ const seen = new Map();
221
+ while (work.length) {
222
+ const { node, depth, via } = work.pop();
223
+ if (analysis.cyclic.get(node)?.size) continue;
224
+ if (seen.has(node) && seen.get(node) <= depth) continue;
225
+ seen.set(node, depth);
226
+ const value = dataOf(node)[field];
227
+ if (depth && value !== undefined) {
228
+ candidates.push({ value, depth, via, node });
229
+ continue;
230
+ }
231
+ const edges = analysis.out.get(node) ?? [];
232
+ for (let i = edges.length - 1; i >= 0; i--) {
233
+ const edge = edges[i];
234
+ if (!analysis.cyclic.get(node)?.has(edge.position))
235
+ work.push({
236
+ node: edge.target,
237
+ depth: depth + 1,
238
+ via: depth ? via : edge.position,
239
+ });
240
+ }
241
+ }
242
+ const atDepth = new Map();
243
+ let conflicting;
244
+ for (const candidate of candidates) {
245
+ if (
246
+ atDepth.has(candidate.depth) &&
247
+ !isDeepStrictEqual(atDepth.get(candidate.depth), candidate.value)
248
+ )
249
+ conflicting = candidate;
250
+ else atDepth.set(candidate.depth, candidate.value);
251
+ }
252
+ if (conflicting)
253
+ findings.push(
254
+ finding(
255
+ note,
256
+ index,
257
+ [conflicting.via],
258
+ `culture parents at the same depth disagree on data.${field}; state the field on this culture`,
259
+ ),
260
+ );
261
+ else if (candidates.length) {
262
+ inherited[field] = candidates[0].value;
263
+ const source = candidates[0].node;
264
+ sources.set(field, { file: source.file, raw: source.raw, fm: payload(source) });
265
+ }
266
+ }
267
+ inheritedSources.set(payload(note), sources);
268
+ values.set(note, inherited);
269
+ }
270
+ for (const note of index.notes ?? []) {
271
+ if (!culture(note)) continue;
272
+ inheritedFields.set(payload(note), new Set(Object.keys(values.get(note) ?? {})));
273
+ const relevant = new Set();
274
+ const work = [note];
275
+ while (work.length) {
276
+ const current = work.pop();
277
+ if (relevant.has(current)) continue;
278
+ relevant.add(current);
279
+ work.push(...(analysis.out.get(current) ?? []).map((edge) => edge.target));
280
+ }
281
+ const addresses = new Set(
282
+ [...relevant].map((entry) =>
283
+ canonicalKey(index.contentPackage, "note", "lore", payload(entry).shortcode),
284
+ ),
285
+ );
286
+ hierarchyProblems.set(
287
+ payload(note),
288
+ findings.filter((problem) => addresses.has(problem.cultureAddress)),
289
+ );
290
+ }
291
+ return { values, findings };
292
+ }
293
+
294
+ /** Emit or collect all findings from culture inheritance. */
295
+ export function reportCultureParents(findings, problems) {
296
+ if (problems) problems.push(...findings);
297
+ else if (findings.length)
298
+ throw Object.assign(new Error(findings.map(formatDiagnostic).join("\n")), {
299
+ located: true,
300
+ });
301
+ }
@@ -33,6 +33,11 @@
33
33
  * Shortcode, a pack, a name), and this describes the value under each key.
34
34
  * The keys themselves are checked as the field's `keyKind` says.
35
35
  *
36
+ * A map declaring both `fields` and `values` holds its declared keys, each
37
+ * checked by its own declaration, beside keys named by the author, each
38
+ * checked by `values`. A language's `data.names` is one: `pattern` and
39
+ * `given` beside the lists the language names for itself.
40
+ *
36
41
  * Dotted field names (`appearance.eye_color`) declare a container's keys by
37
42
  * their segments, and the container is closed to them just the same.
38
43
  *
@@ -57,7 +62,7 @@ import { nearest } from "./near-miss.mjs";
57
62
  * @property {string} name - The key as an author writes it.
58
63
  * @property {string} [kind] - Its value's shape: `string`, `date`, `number`,
59
64
  * `integer`, `boolean`, `address`, `list`, `map`, `scalar-or-map`,
60
- * or `string-or-list` — see {@link fitsKind}. Absent, the check
65
+ * `string-or-list`, or `string-list-or-map` — see {@link fitsKind}. Absent, the check
61
66
  * makes no claim about the value.
62
67
  * @property {readonly string[]} [allowed] - The closed set a present value is
63
68
  * one of, where the key takes a fixed word.
@@ -145,6 +150,10 @@ export function fitsKind(value, kind) {
145
150
  return isScalar(value) || fitsKind(value, "map");
146
151
  case "string-or-list":
147
152
  return isScalar(value) || Array.isArray(value);
153
+ // One value, a list of them described by `entries`, or a map of them
154
+ // described by `values`.
155
+ case "string-list-or-map":
156
+ return fitsKind(value, "string-or-list") || fitsKind(value, "map");
148
157
  default:
149
158
  return true;
150
159
  }
@@ -258,7 +267,7 @@ export function checkDataKeys(note, fields) {
258
267
  */
259
268
  const walk = (spec, value, path) => {
260
269
  if (spec.fields && isPlainMap(value)) {
261
- closeMap(spec.fields, value, path);
270
+ closeMap(spec.fields, value, path, spec.values);
262
271
  return;
263
272
  }
264
273
  if (Array.isArray(value)) {
@@ -308,11 +317,18 @@ export function checkDataKeys(note, fields) {
308
317
  return false;
309
318
  };
310
319
 
311
- /** Close one map to its declared keys, and walk each that is present. */
312
- const closeMap = (inner, value, path) => {
320
+ /**
321
+ * Close one map to its declared keys, and walk each that is present. With
322
+ * `rest`, a key it does not declare is walked against `rest` instead.
323
+ */
324
+ const closeMap = (inner, value, path, rest) => {
313
325
  const names = inner.map((spec) => spec.name);
314
326
  for (const key of Object.keys(value)) {
315
327
  if (names.includes(key)) continue;
328
+ if (rest) {
329
+ visit(rest, value[key], [...path, key]);
330
+ continue;
331
+ }
316
332
  const guess = nearest(key, names);
317
333
  findings.push(
318
334
  finding(
@@ -412,6 +412,7 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
412
412
  }
413
413
 
414
414
  for (const field of fields) {
415
+ if (note.inheritedCultureFields?.has(field.name)) continue;
415
416
  if (!field.kind) continue;
416
417
  const segments = field.name.split(".");
417
418
  let value = entries;
@@ -457,7 +458,12 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
457
458
 
458
459
  // One level down and every level below: each field's value is closed to
459
460
  // the inner keys its declaration states, as the container is to its own.
460
- findings.push(...checkDataKeys(note, fields));
461
+ findings.push(
462
+ ...checkDataKeys(
463
+ note,
464
+ fields.filter((field) => !note.inheritedCultureFields?.has(field.name)),
465
+ ),
466
+ );
461
467
 
462
468
  return findings;
463
469
  }
@@ -600,6 +606,13 @@ function checkDataReferences(note, field, value, segments, context, index) {
600
606
  : `; write \`${slot.declines}\` to decline the image on purpose`);
601
607
  }
602
608
  if (!reason) continue;
609
+ if (
610
+ note.fm?.type === "lore" &&
611
+ note.fm?.subType === "culture" &&
612
+ field.name === "parents" &&
613
+ note.reportedCultureParents?.has(check.path[0])
614
+ )
615
+ continue;
603
616
  const path = ["data", ...segments, ...check.path];
604
617
  findings.push({
605
618
  file: note.file,
@@ -1526,6 +1539,7 @@ export function lintNote(
1526
1539
  // way a wikilink would. The linter names no such field itself; the
1527
1540
  // vocabulary does, beside the field.
1528
1541
  for (const field of fields) {
1542
+ if (note.inheritedCultureFields?.has(field.name)) continue;
1529
1543
  if (typeof field.check !== "function") continue;
1530
1544
  findings.push(...field.check(note, { index }));
1531
1545
  }