@heroiclands/package-build 22.4.1 → 22.4.3

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.
@@ -646,6 +646,29 @@ export class SystemActorCompiler extends BasePackCompiler {
646
646
  return referencedSubtype(this.documentSubtypes, type, "Item");
647
647
  }
648
648
 
649
+ /**
650
+ * The default art for an embedded item's type, when the entry names none
651
+ * of its own and copies no template that carries one.
652
+ *
653
+ * **The base case is {@link itemArt}**, keyed by the note vocabulary — the
654
+ * same table an item note's own compile defaults from, because most
655
+ * references are written in that vocabulary too (`weapongear`, `skill`,
656
+ * `armorgear`). It is not the whole answer: a reference into a one-to-many
657
+ * row may instead name one of the row's own **subtypes** directly — HM3's
658
+ * `spell`, `invocation` and `psionic` are never a note's own `type`, only
659
+ * `mysticalability`'s `hm3.type` discriminator ever writes them, so no
660
+ * note-type table has a row for them. A system whose one-to-many rows are
661
+ * addressed that way overrides this to answer for those subtypes too; see
662
+ * `hm3/actors.mjs`.
663
+ *
664
+ * @param {string} type - The **note** type the reference names.
665
+ * @param {string} subType - The document subtype it resolved to.
666
+ * @returns {string} The default image path.
667
+ */
668
+ embeddedItemArt(type, subType) {
669
+ return itemArt(type, this.system);
670
+ }
671
+
649
672
  /**
650
673
  * Read an entry's `model:` — the address of the item it is a copy of.
651
674
  *
@@ -825,7 +848,9 @@ export class SystemActorCompiler extends BasePackCompiler {
825
848
  // default answers last. Nullish coalescing throughout, so `icon: ""`
826
849
  // ships blank on purpose rather than collecting a default.
827
850
  merged.img =
828
- this.artPath(overlay ?? {}, "icon") ?? merged.img ?? itemArt(type, this.system);
851
+ this.artPath(overlay ?? {}, "icon") ??
852
+ merged.img ??
853
+ this.embeddedItemArt(type, /** @type {string} */ (subType));
829
854
  const identity = embeddedIdentity(merged, this.system);
830
855
  const claim = `${actorId}\u0000${itemAddress(/** @type {string} */ (subType), identity)}`;
831
856
  const first = this.#embeddedClaims.get(claim);
@@ -0,0 +1,256 @@
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
+ * Fold a release's changeset blocks together by their bold label.
16
+ *
17
+ * `@heroiclands/package-build/changelog` (`changelog.cjs`) writes each
18
+ * changeset's summary into `CHANGELOG.md` as its own block, verbatim, under
19
+ * `### <Bump> Changes` — one per changeset, in whatever order Changesets
20
+ * happened to read the files. A repository that groups its release prose by
21
+ * subject (`**Compendiums**`, `**Website**`) ends up with the same label
22
+ * opening several scattered blocks instead of one, because nothing ever
23
+ * merges them: three pull requests touching compendium content each write
24
+ * their own `**Compendiums**` block, and the release reads as three
25
+ * unrelated entries rather than one.
26
+ *
27
+ * `groupChangelogText` merges same-label blocks into one, in the display
28
+ * order `changelog.labels` declares, with the unlabelled lead paragraph
29
+ * first and any label absent from that vocabulary last. It reuses
30
+ * {@link module:engine/changelog-lint.topLevelBlocks} — the same block
31
+ * model `changelog check` reads a block's label from — rather than parsing
32
+ * the markdown a second way.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import {
38
+ codeLineSet,
39
+ lineColOf,
40
+ releaseSectionRange,
41
+ topLevelBlocks,
42
+ topLevelBullets,
43
+ } from "./changelog-lint.mjs";
44
+
45
+ /** A `### <Bump> Changes` heading — the generated scaffold, never authored. */
46
+ const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes$/gm;
47
+
48
+ /**
49
+ * A labelled block's own `**Label**` line, split from everything after it.
50
+ *
51
+ * @param {string} blockText - One block, as {@link topLevelBlocks} collects
52
+ * it — its first line is the label line for a labelled block.
53
+ * @returns {{labelLine: string, rest: string}} `rest` has its leading blank
54
+ * line dropped; a block with nothing but the label line yields `""`.
55
+ */
56
+ function splitLabelLine(blockText) {
57
+ const nl = blockText.indexOf("\n");
58
+ if (nl === -1) return { labelLine: blockText, rest: "" };
59
+ return { labelLine: blockText.slice(0, nl), rest: blockText.slice(nl + 1).replace(/^\n+/, "") };
60
+ }
61
+
62
+ /**
63
+ * A block's top-level items: its bullets, or — when it carries none — the
64
+ * whole thing as one paragraph item. This is what merging concatenates and
65
+ * deduplicates, so a bulleted `**Compendiums**` block and a prose one merge
66
+ * on the same footing.
67
+ *
68
+ * @param {string} text - A block's content, label line already stripped (or
69
+ * a lead block's whole text).
70
+ * @returns {string[]}
71
+ */
72
+ function itemsOf(text) {
73
+ const trimmed = text.replace(/\s+$/, "");
74
+ if (!trimmed) return [];
75
+ const bullets = topLevelBullets(trimmed, codeLineSet(trimmed));
76
+ if (bullets.length === 0) return [trimmed];
77
+ return bullets.map((bullet) => bullet.text.replace(/\s+$/, ""));
78
+ }
79
+
80
+ /**
81
+ * The first occurrence of each item, exact-duplicate text dropped, order
82
+ * preserved — the "an exact-duplicate bullet once" rule.
83
+ *
84
+ * @param {string[]} items
85
+ * @returns {string[]}
86
+ */
87
+ function dedupeItems(items) {
88
+ const seen = new Set();
89
+ const out = [];
90
+ for (const item of items) {
91
+ const key = item.trim();
92
+ if (seen.has(key)) continue;
93
+ seen.add(key);
94
+ out.push(item);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * Join a merged group's items the way its own shape calls for: bullets
101
+ * adjacent, one to a line; a paragraph or a mix of paragraphs separated by a
102
+ * blank line, the same spacing a changeset's own multi-paragraph summary
103
+ * already uses.
104
+ *
105
+ * @param {string[]} items
106
+ * @returns {string}
107
+ */
108
+ function joinItems(items) {
109
+ if (items.length === 0) return "";
110
+ const allBullets = items.every((item) => /^-\s/.test(item));
111
+ return items.join(allBullets ? "\n" : "\n\n");
112
+ }
113
+
114
+ /**
115
+ * One label's blocks (or every unlabelled one), folded into the single
116
+ * block `group` writes for it.
117
+ *
118
+ * @param {Array<{label: string|null, text: string}>} blocks - Every block
119
+ * sharing one label, in file order. All carry the same `label`.
120
+ * @returns {string}
121
+ */
122
+ function mergeGroup(blocks) {
123
+ if (blocks[0].label === null) {
124
+ return joinItems(dedupeItems(blocks.flatMap((block) => itemsOf(block.text))));
125
+ }
126
+ const { labelLine } = splitLabelLine(blocks[0].text);
127
+ const items = dedupeItems(blocks.flatMap((block) => itemsOf(splitLabelLine(block.text).rest)));
128
+ return items.length ? `${labelLine}\n\n${joinItems(items)}` : labelLine;
129
+ }
130
+
131
+ /**
132
+ * Where a group sorts, relative to the others in its section.
133
+ *
134
+ * The unlabelled lead group always sorts first, whether or not
135
+ * `changelog.labels` is declared — it is the summary a changeset writes with
136
+ * no category, and reads like the section's own opening line. A declared
137
+ * vocabulary then orders everything else by its position in that list, with
138
+ * an undeclared label sorted after every declared one; with no vocabulary
139
+ * declared at all, every labelled group keeps the order its label first
140
+ * appeared in.
141
+ *
142
+ * @param {string|null} key - A group's label, or `null` for the lead group.
143
+ * @param {string[]} appearanceOrder - Every key, in the order its first
144
+ * block appeared in the section.
145
+ * @param {readonly string[]|null} labels - `changelog.labels`, or `null`.
146
+ * @returns {[number, number]} `[tier, rank]`, compared tier first.
147
+ */
148
+ function groupRank(key, appearanceOrder, labels) {
149
+ if (key === null) return [-1, 0];
150
+ if (labels) {
151
+ const configured = labels.indexOf(key);
152
+ return configured === -1 ? [1, appearanceOrder.indexOf(key)] : [0, configured];
153
+ }
154
+ return [0, appearanceOrder.indexOf(key)];
155
+ }
156
+
157
+ /**
158
+ * Fold one `### <Bump> Changes` section's blocks together by label.
159
+ *
160
+ * @param {string} bodyText - Everything after the heading, up to the next
161
+ * one or the end of the release section.
162
+ * @param {readonly string[]|null} labels - `changelog.labels`, in display
163
+ * order, or `null` when the repository declares none.
164
+ * @returns {{text: string, unknown: Array<{label: string, startLine: number}>}}
165
+ * The regrouped body (no leading or trailing blank lines), and every label
166
+ * it wrote that `labels` does not declare, each at its merged block's
167
+ * first-occurring line within `bodyText` — empty when `labels` is `null`,
168
+ * since nothing is "unknown" against no vocabulary.
169
+ */
170
+ function groupSection(bodyText, labels) {
171
+ const codeLines = codeLineSet(bodyText);
172
+ const blocks = topLevelBlocks(bodyText, codeLines);
173
+ if (blocks.length === 0) return { text: "", unknown: [] };
174
+
175
+ /** @type {string[]} */
176
+ const appearanceOrder = [];
177
+ /** @type {Map<string|null, Array<{label: string|null, text: string}>>} */
178
+ const groups = new Map();
179
+ for (const block of blocks) {
180
+ if (!groups.has(block.label)) {
181
+ groups.set(block.label, []);
182
+ appearanceOrder.push(block.label);
183
+ }
184
+ groups.get(block.label).push(block);
185
+ }
186
+
187
+ const sortedKeys = [...appearanceOrder].sort((a, b) => {
188
+ const [aTier, aRank] = groupRank(a, appearanceOrder, labels);
189
+ const [bTier, bRank] = groupRank(b, appearanceOrder, labels);
190
+ return aTier - bTier || aRank - bRank;
191
+ });
192
+
193
+ const unknown =
194
+ labels === null ?
195
+ []
196
+ : sortedKeys
197
+ .filter((key) => key !== null && !labels.includes(key))
198
+ .map((key) => ({ label: key, startLine: groups.get(key)[0].startLine }));
199
+
200
+ const text = sortedKeys.map((key) => mergeGroup(groups.get(key))).join("\n\n");
201
+ return { text, unknown };
202
+ }
203
+
204
+ /**
205
+ * `changelog group`: fold the newest release's changeset blocks together by
206
+ * their bold label, order them, and file an undeclared one last.
207
+ *
208
+ * Every earlier release section is untouched, byte for byte — only the
209
+ * first `## <version>` section's `### <Bump> Changes` bodies are rewritten,
210
+ * each independently (a label groups within its own bump level, never
211
+ * across one). Running this on its own output is a no-op: a release already
212
+ * in label order, with each label merged to one block, groups to itself.
213
+ *
214
+ * @param {string} text - The changelog's full contents.
215
+ * @param {object} [opts]
216
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
217
+ * display order. `null`/absent orders every group by first appearance
218
+ * instead, lead paragraph first, and files nothing as unknown.
219
+ * @returns {{text: string, findings: Array<{line: number,
220
+ * severity: "warning", message: string}>}}
221
+ */
222
+ export function groupChangelogText(text, { labels = null } = {}) {
223
+ const range = releaseSectionRange(text);
224
+ if (!range) return { text, findings: [] };
225
+
226
+ const section = text.slice(range.start, range.end);
227
+ const headings = [...section.matchAll(CHANGES_HEADING_RE)];
228
+ if (headings.length === 0) return { text, findings: [] };
229
+
230
+ let rebuilt = section.slice(0, headings[0].index);
231
+ /** @type {Array<{line: number, severity: "warning", message: string}>} */
232
+ const findings = [];
233
+
234
+ for (let i = 0; i < headings.length; i++) {
235
+ const heading = headings[i];
236
+ const bodyStart = heading.index + heading[0].length;
237
+ const bodyEnd = i + 1 < headings.length ? headings[i + 1].index : section.length;
238
+ const { text: grouped, unknown } = groupSection(section.slice(bodyStart, bodyEnd), labels);
239
+
240
+ const isVeryLast = i === headings.length - 1 && range.end === text.length;
241
+ rebuilt += heading[0] + (grouped ? `\n\n${grouped}` : "") + (isVeryLast ? "\n" : "\n\n");
242
+
243
+ const { line: bodyFirstLine } = lineColOf(text, range.start + bodyStart);
244
+ for (const { label, startLine } of unknown) {
245
+ findings.push({
246
+ line: bodyFirstLine + (startLine - 1),
247
+ severity: "warning",
248
+ message:
249
+ `changelog-group/unknown-label "${label}" is not declared in ` +
250
+ "`changelog.labels` — filed last, in order of first appearance",
251
+ });
252
+ }
253
+ }
254
+
255
+ return { text: text.slice(0, range.start) + rebuilt + text.slice(range.end), findings };
256
+ }
@@ -49,6 +49,9 @@ const TOP_BULLET_RE = /^-\s+/;
49
49
  /** A list item indented under something else. */
50
50
  const NESTED_BULLET_RE = /^[ \t]+[-*+]\s+/;
51
51
 
52
+ /** A bold label opening a block at column 0: `**Compendiums**`. */
53
+ const LABEL_LINE_RE = /^\*\*([^*]+)\*\*/;
54
+
52
55
  /** A commit-hash prefix: `- abc1234: …`, or a bare hex token opening the bullet. */
53
56
  const COMMIT_HASH_RE = /^-\s+([0-9a-fA-F]{7,40})(?=[:\s]|$)/;
54
57
 
@@ -92,7 +95,7 @@ const MAX_SECTION_LINES = 60;
92
95
  * @param {number} index - 0-based character offset.
93
96
  * @returns {{line: number, column: number}}
94
97
  */
95
- function lineColOf(text, index) {
98
+ export function lineColOf(text, index) {
96
99
  const before = text.slice(0, Math.max(0, index));
97
100
  const nl = before.lastIndexOf("\n");
98
101
  return { line: before.split("\n").length, column: index - nl };
@@ -108,7 +111,7 @@ function lineColOf(text, index) {
108
111
  * @param {string} text - The section text.
109
112
  * @returns {Set<number>} Lines that fall inside a fenced or indented block.
110
113
  */
111
- function codeLineSet(text) {
114
+ export function codeLineSet(text) {
112
115
  const lines = new Set();
113
116
  for (const region of codeRegions(text, { spans: false })) {
114
117
  const from = lineColOf(text, region.start).line;
@@ -185,7 +188,7 @@ function checkCodeFences(text) {
185
188
  * {@link codeLineSet}.
186
189
  * @returns {Array<{startLine: number, text: string}>}
187
190
  */
188
- function topLevelBullets(text, codeLines) {
191
+ export function topLevelBullets(text, codeLines) {
189
192
  const lines = text.split("\n");
190
193
  /** @type {Array<{startLine: number, text: string}>} */
191
194
  const bullets = [];
@@ -211,13 +214,93 @@ function topLevelBullets(text, codeLines) {
211
214
  continue;
212
215
  }
213
216
  // Column 0, not a bullet: a bold subsection label or a scaffold
214
- // heading closes whatever bullet was open.
217
+ // heading closes whatever bullet was open — pushed here, or it is
218
+ // lost rather than merely closed.
219
+ if (current) bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
215
220
  current = null;
216
221
  }
217
222
  if (current) bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
218
223
  return bullets;
219
224
  }
220
225
 
226
+ /**
227
+ * Every top-level block of release prose — one rendered changeset entry, or
228
+ * one unlabelled paragraph standing in for one.
229
+ *
230
+ * `@heroiclands/package-build/changelog` (`changelog.cjs`) writes a
231
+ * changeset's whole summary as one block, verbatim, so a block here holds
232
+ * together the same way that summary is authored: a bold label opening a
233
+ * line at column 0 (`**Compendiums**`) starts a new block, and — unlike
234
+ * {@link topLevelBullets}, where a bullet marker *is* the top-level
235
+ * construct — a `-` bullet at column 0 belongs to whatever block is
236
+ * already open, since a label's bullets sit unindented directly under it.
237
+ * Any other column-0 line (plain prose with no label, a bullet with no
238
+ * block open yet) starts the one "lead" block (`label: null`) a changeset
239
+ * with no category writes — the case `changelog group` sorts first and
240
+ * `check` never flags. Nested detail — a wrapped line, a bullet's own
241
+ * continuation — stays indented and belongs to whatever it follows.
242
+ * `changelog group` folds same-label blocks together; `check` warns when a
243
+ * block's label is not in the declared vocabulary.
244
+ *
245
+ * @param {string} text - Section text.
246
+ * @param {Set<number>} codeLines - Lines inside a code region, from
247
+ * {@link codeLineSet}.
248
+ * @param {Set<number>} [scaffoldLines] - Generated heading lines that close
249
+ * whatever block is open without starting one, from
250
+ * {@link scaffoldLineSet} — empty for a caller that already isolated one
251
+ * `### <Bump> Changes` body, since no heading falls inside it.
252
+ * @returns {Array<{startLine: number, label: string|null, text: string}>}
253
+ */
254
+ export function topLevelBlocks(text, codeLines, scaffoldLines = new Set()) {
255
+ const lines = text.split("\n");
256
+ /** @type {Array<{startLine: number, label: string|null, text: string}>} */
257
+ const blocks = [];
258
+ /** @type {{startLine: number, label: string|null, parts: string[]}|null} */
259
+ let current = null;
260
+ const close = () => {
261
+ if (current)
262
+ blocks.push({
263
+ startLine: current.startLine,
264
+ label: current.label,
265
+ text: current.parts.join("\n"),
266
+ });
267
+ current = null;
268
+ };
269
+
270
+ for (let i = 0; i < lines.length; i++) {
271
+ const lineNo = i + 1;
272
+ const raw = lines[i];
273
+
274
+ if (codeLines.has(lineNo)) {
275
+ if (current) current.parts.push(raw);
276
+ continue;
277
+ }
278
+ if (scaffoldLines.has(lineNo)) {
279
+ close();
280
+ continue;
281
+ }
282
+ const atColumnZero = raw.trim() !== "" && !/^[ \t]/.test(raw);
283
+ if (!atColumnZero) {
284
+ if (current) current.parts.push(raw);
285
+ continue;
286
+ }
287
+ const label = LABEL_LINE_RE.exec(raw)?.[1] ?? null;
288
+ if (label !== null) {
289
+ close();
290
+ current = { startLine: lineNo, label, parts: [raw] };
291
+ continue;
292
+ }
293
+ if (TOP_BULLET_RE.test(raw) && current) {
294
+ current.parts.push(raw);
295
+ continue;
296
+ }
297
+ close();
298
+ current = { startLine: lineNo, label: null, parts: [raw] };
299
+ }
300
+ close();
301
+ return blocks;
302
+ }
303
+
221
304
  /**
222
305
  * A bullet's word count, the bullet marker and markdown decoration stripped.
223
306
  *
@@ -502,6 +585,41 @@ function checkCodeLikeTokens(text, maskedRegions) {
502
585
  return findings;
503
586
  }
504
587
 
588
+ /**
589
+ * A block's bold label absent from the declared `changelog.labels`
590
+ * vocabulary — the drift `**Character data**` beside `**Characters**`
591
+ * produces, invisible until something reads the declared list against what a
592
+ * changeset actually wrote. A warning, not an error: an undeclared label
593
+ * still ships and still groups (`changelog group` files it last), so nothing
594
+ * here blocks a release — it only says the vocabulary and the prose have
595
+ * drifted apart.
596
+ *
597
+ * @param {Array<{startLine: number, label: string|null}>} blocks - From
598
+ * {@link topLevelBlocks}.
599
+ * @param {readonly string[]|null|undefined} labels - `changelog.labels`, or
600
+ * `null`/`undefined` when the repository declares none, in which case
601
+ * nothing is checked — there is no vocabulary for a label to drift from.
602
+ * @returns {RelativeFinding[]}
603
+ */
604
+ function checkUnknownLabels(blocks, labels) {
605
+ if (!labels) return [];
606
+ /** @type {RelativeFinding[]} */
607
+ const findings = [];
608
+ for (const block of blocks) {
609
+ if (block.label === null || labels.includes(block.label)) continue;
610
+ findings.push({
611
+ line: block.startLine,
612
+ column: 3, // right after the opening `**`
613
+ severity: "warning",
614
+ message:
615
+ `changelog-check/unknown-label "${block.label}" is not declared in ` +
616
+ "`changelog.labels` (declared: " +
617
+ `${labels.join(", ")}) — add it there, or correct the label`,
618
+ });
619
+ }
620
+ return findings;
621
+ }
622
+
505
623
  /**
506
624
  * The 1-based lines a caller drops from the heading rule because they are the
507
625
  * generated scaffold, never authored prose: the section's own `## <version>`
@@ -524,13 +642,17 @@ function scaffoldLineSet(text) {
524
642
  * Run every rule over one section of release prose.
525
643
  *
526
644
  * @param {string} text - The section, already isolated by the caller.
645
+ * @param {object} [opts]
646
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, for
647
+ * {@link checkUnknownLabels}. `null`/absent checks nothing.
527
648
  * @returns {RelativeFinding[]} Findings with line numbers relative to `text`.
528
649
  */
529
- function lintSection(text) {
650
+ function lintSection(text, { labels = null } = {}) {
530
651
  const codeLines = codeLineSet(text);
531
652
  const maskedRegions = codeRegions(text, { spans: true });
532
653
  const scaffoldLines = scaffoldLineSet(text);
533
654
  const bullets = topLevelBullets(text, codeLines);
655
+ const blocks = topLevelBlocks(text, codeLines, scaffoldLines);
534
656
 
535
657
  return [
536
658
  ...checkCommitHash(text, codeLines, scaffoldLines),
@@ -544,6 +666,7 @@ function lintSection(text) {
544
666
  ...checkTooManyBullets(bullets),
545
667
  ...checkTooManyLines(text),
546
668
  ...checkCodeLikeTokens(text, maskedRegions),
669
+ ...checkUnknownLabels(blocks, labels),
547
670
  ].sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
548
671
  }
549
672
 
@@ -593,12 +716,19 @@ function extractReleaseSection(text) {
593
716
  * Lint one pending changeset (`.changeset/*.md`).
594
717
  *
595
718
  * @param {string} text - The file's full contents, frontmatter included.
719
+ * @param {object} [opts]
720
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
721
+ * display order, or `null`/absent when the repository declares none —
722
+ * {@link checkUnknownLabels} checks nothing in that case.
596
723
  * @returns {{findings: Array<{line: number, column?: number,
597
724
  * severity: "error"|"warning", message: string}>}}
598
725
  */
599
- export function lintChangesetText(text) {
726
+ export function lintChangesetText(text, { labels = null } = {}) {
600
727
  const { body, startLine } = stripFrontmatter(text);
601
- const findings = lintSection(body).map((f) => ({ ...f, line: f.line + startLine - 1 }));
728
+ const findings = lintSection(body, { labels }).map((f) => ({
729
+ ...f,
730
+ line: f.line + startLine - 1,
731
+ }));
602
732
  return { findings };
603
733
  }
604
734
 
@@ -606,10 +736,14 @@ export function lintChangesetText(text) {
606
736
  * Lint the first `## <version>` release section of a `CHANGELOG.md`.
607
737
  *
608
738
  * @param {string} text - The changelog's full contents.
739
+ * @param {object} [opts]
740
+ * @param {readonly string[]|null} [opts.labels] - `changelog.labels`, in
741
+ * display order, or `null`/absent when the repository declares none —
742
+ * {@link checkUnknownLabels} checks nothing in that case.
609
743
  * @returns {{findings: Array<{line?: number, column?: number,
610
744
  * severity: "error"|"warning", message: string}>}}
611
745
  */
612
- export function lintReleaseText(text) {
746
+ export function lintReleaseText(text, { labels = null } = {}) {
613
747
  const section = extractReleaseSection(text);
614
748
  if (!section) {
615
749
  return {
@@ -621,9 +755,34 @@ export function lintReleaseText(text) {
621
755
  ],
622
756
  };
623
757
  }
624
- const findings = lintSection(section.body).map((f) => ({
758
+ const findings = lintSection(section.body, { labels }).map((f) => ({
625
759
  ...f,
626
760
  line: f.line + section.startLine - 1,
627
761
  }));
628
762
  return { findings };
629
763
  }
764
+
765
+ /**
766
+ * The first `## <version>` release section of a changelog, as raw character
767
+ * offsets rather than {@link extractReleaseSection}'s line-joined copy.
768
+ *
769
+ * `extractReleaseSection` rebuilds its `body` by joining a slice of
770
+ * `text.split("\n")`, which is fine for reporting a line number but drops
771
+ * the exact byte the next `## ` heading sits after — a caller rewriting the
772
+ * file in place, such as `changelog group`, needs `text.slice(start, end)`
773
+ * to be the section verbatim, so it can splice a replacement back in without
774
+ * guessing at the whitespace on either side.
775
+ *
776
+ * @param {string} text - The changelog's full contents.
777
+ * @returns {{start: number, end: number}|null} `null` when no `## ` heading
778
+ * is present. `text.slice(start, end)` is the section, byte-exact,
779
+ * including whatever separates it from the next `## ` heading or the end
780
+ * of the file.
781
+ */
782
+ export function releaseSectionRange(text) {
783
+ const matches = [...text.matchAll(/^## /gm)];
784
+ if (matches.length === 0) return null;
785
+ const start = matches[0].index;
786
+ const end = matches.length > 1 ? matches[1].index : text.length;
787
+ return { start, end };
788
+ }
@@ -329,7 +329,7 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
329
329
  * dependency catalogue actually carry, and a reference is translated forward
330
330
  * here before it is looked up.
331
331
  *
332
- * Four answers, and only the first resolves:
332
+ * Five answers, and only the last refuses:
333
333
  *
334
334
  * - _A one-to-one row_ → the subtype it declares. `armor` addresses an
335
335
  * `armorgear`.
@@ -340,12 +340,19 @@ export function documentSubtype(map, noteType, fm, { file, absPath } = {}) {
340
340
  * existed.
341
341
  * - _A row for another document class_ → a problem. A being is not an item,
342
342
  * however the address is spelled.
343
- * - _A one-to-many row_ a problem naming the candidates. The note that owns
344
- * such a row resolves it from its own frontmatter block; a reference has no
345
- * block, so nothing here can choose, and choosing anyway would be right about
346
- * half the time. No system declares a one-to-many **Item** row today, so this
347
- * is a guard rather than a behaviour but it is a loud one, which is the
348
- * whole point of the issue.
343
+ * - _A one-to-many row, named by one of its own permitted subtypes_ that
344
+ * subtype. HM3's `weapongear` row is keyed by the note type `weapongear` but
345
+ * permits `["weapongear", "missilegear"]`; a reference spelled `weapongear`
346
+ * is not ambiguous it already names the subtype it wants, the same as a
347
+ * reference spelled `missilegear` does by matching no row at all and taking
348
+ * the unmapped fallback above. Only the row's own key can coincide with one
349
+ * of its subtypes, so this is never a second guess at the note's
350
+ * frontmatter — the row was looked up by this exact spelling.
351
+ * - _A one-to-many row, named by neither the row's other permitted subtypes
352
+ * nor resolved above_ → a problem naming the candidates. The note that owns
353
+ * such a row resolves it from its own frontmatter block; a reference naming
354
+ * only the row has no block to read a discriminator from, so nothing here
355
+ * can choose, and choosing anyway would be right about half the time.
349
356
  *
350
357
  * A **retired** spelling is refused by name before any of that. Without it a
351
358
  * reference left behind by a merge would take the unmapped fallback and address
@@ -392,6 +399,16 @@ export function referencedSubtype(map, noteType, document) {
392
399
  }
393
400
  if (!row.subType) {
394
401
  const permitted = /** @type {readonly string[]} */ (row.subTypes);
402
+ // The reference is not ambiguous when its own spelling names one of the
403
+ // row's permitted subtypes rather than merely the row itself: a
404
+ // `weapongear` reference into HM3's one-to-many `weapongear` row already
405
+ // says which subtype it wants, in the same way a `missilegear`
406
+ // reference does by never matching this row's key at all. Only a
407
+ // reference that names the row without naming a subtype is genuinely
408
+ // undecidable.
409
+ if (permitted.includes(currentType(noteType))) {
410
+ return { subType: currentType(noteType) };
411
+ }
395
412
  return {
396
413
  problem:
397
414
  `a "${noteType}" note compiles into more than one ${map.system} ` +
package/hm3/actors.mjs CHANGED
@@ -162,6 +162,35 @@ function defaultActorImg(subType) {
162
162
  return img;
163
163
  }
164
164
 
165
+ /**
166
+ * Default art for the three subtypes HM3's one-to-many `mysticalability` row
167
+ * permits — `spell`, `invocation` and `psionic` — keyed by the **document**
168
+ * subtype rather than the note type.
169
+ *
170
+ * `hm3/default-item-art.mjs` is keyed the other way on purpose: a registry
171
+ * entry is addressed by what a note calls itself, and no note ever authors
172
+ * `type: spell` — only `mysticalability`, discriminated by its own
173
+ * `hm3.type`. But a being's `(type, shortcode)` reference has no note to
174
+ * discriminate, so it names the subtype it wants directly, and an embedded
175
+ * entry that supplies neither a template's own `img` nor a `data.icon` of its
176
+ * own reaches {@link Hm3Actors#embeddedItemArt} in exactly that vocabulary.
177
+ *
178
+ * Each path is the icon HM3's own item sheet assigns a freshly created item
179
+ * of that subtype — `HM3.defaultMagicIconName`, `HM3.defaultRitualIconName`
180
+ * and `HM3.defaultPsionicsIconName` in the system's own `config.js` — so a
181
+ * compiled item looks like one created in the client. `weapongear` and
182
+ * `missilegear` need no row here: a reference spelled either resolves through
183
+ * `HM3_DEFAULT_ITEM_ART`'s own `weapongear` key (see #582), and every
184
+ * predefined weapon and missile in HM3's catalogue carries its own `img`.
185
+ *
186
+ * @type {Readonly<Record<string, string>>}
187
+ */
188
+ const EMBEDDED_ITEM_ART = Object.freeze({
189
+ invocation: "systems/hm3/images/icons/svg/circle.svg",
190
+ psionic: "systems/hm3/images/icons/svg/psionics.svg",
191
+ spell: "systems/hm3/images/icons/svg/pentacle.svg",
192
+ });
193
+
165
194
  /**
166
195
  * HM3's Actor compile pass.
167
196
  *
@@ -192,6 +221,19 @@ export class Hm3Actors extends SystemActorCompiler {
192
221
  return this.buildActor(this.itemsMap, fm, markdown);
193
222
  }
194
223
 
224
+ /**
225
+ * @inheritdoc
226
+ *
227
+ * Answers for `spell`, `invocation` and `psionic` too — see
228
+ * {@link EMBEDDED_ITEM_ART} — falling back to the engine's note-type
229
+ * table for every other reference, `weapongear` and `missilegear`
230
+ * included.
231
+ */
232
+ embeddedItemArt(type, subType) {
233
+ const art = /** @type {Record<string, string|undefined>} */ (EMBEDDED_ITEM_ART)[subType];
234
+ return art ?? super.embeddedItemArt(type, subType);
235
+ }
236
+
195
237
  /**
196
238
  * Build every embedded item an HM3 actor carries, from `hm3.items`.
197
239
  *