@heroiclands/package-build 22.4.0 → 22.4.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.
- package/CHANGELOG.md +32 -0
- package/CONTENT.md +7 -0
- package/bin/package-build.mjs +83 -0
- package/docs/commands.md +67 -0
- package/engine/actor-compiler.mjs +79 -15
- package/engine/changelog-lint.mjs +629 -0
- package/engine/document-subtypes.mjs +24 -7
- package/githooks/pre-commit +53 -2
- package/hm3/actors.mjs +42 -0
- package/package.json +1 -1
- package/types/engine/actor-compiler.d.mts +62 -9
- package/types/engine/changelog-lint.d.mts +50 -0
- package/types/engine/document-subtypes.d.mts +14 -7
- package/types/hm3/actors.d.mts +9 -0
|
@@ -0,0 +1,629 @@
|
|
|
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
|
+
* Lint release prose — a pending changeset, or the release section a
|
|
16
|
+
* **Version Packages** branch is about to publish — against the rules a
|
|
17
|
+
* changelog entry is actually held to.
|
|
18
|
+
*
|
|
19
|
+
* **Why this exists.** A changeset answers one question: *who notices, and
|
|
20
|
+
* what do they see?* Nothing enforced that, so a pull-request description
|
|
21
|
+
* pasted into one ships verbatim as a release note — commit hashes, issue
|
|
22
|
+
* numbers, code fences, byte counts and "Verified" paragraphs, all of it
|
|
23
|
+
* meant for a reviewer and none of it for someone deciding whether to
|
|
24
|
+
* upgrade. Every rule here names one way that happens and says what to write
|
|
25
|
+
* instead.
|
|
26
|
+
*
|
|
27
|
+
* **Where the text comes from is the caller's job.** This module lints a
|
|
28
|
+
* *section* of prose — the pending-changeset body with its frontmatter fence
|
|
29
|
+
* stripped, or the first `## <version>` section of a generated
|
|
30
|
+
* `CHANGELOG.md` — and reports every finding at the line it actually falls on
|
|
31
|
+
* in the file the caller read, via the `startLine` each entry point takes.
|
|
32
|
+
*
|
|
33
|
+
* **Code is found the same way a rewriter finds it** —
|
|
34
|
+
* {@link module:engine/code-fences.codeRegions}, not a second fence scanner —
|
|
35
|
+
* because a fenced sample, a verbatim path and a backticked literal must never
|
|
36
|
+
* be misread as the violation they merely *contain*.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { codeRegions } from "./code-fences.mjs";
|
|
42
|
+
|
|
43
|
+
/** The generated scaffold `content-build`/Changesets writes, never authored prose. */
|
|
44
|
+
const RELEASE_HEADING_RE = /^## /;
|
|
45
|
+
const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes\s*$/;
|
|
46
|
+
|
|
47
|
+
/** A top-level bullet: a hyphen at column 0. Never indented — that is a nested list. */
|
|
48
|
+
const TOP_BULLET_RE = /^-\s+/;
|
|
49
|
+
/** A list item indented under something else. */
|
|
50
|
+
const NESTED_BULLET_RE = /^[ \t]+[-*+]\s+/;
|
|
51
|
+
|
|
52
|
+
/** A commit-hash prefix: `- abc1234: …`, or a bare hex token opening the bullet. */
|
|
53
|
+
const COMMIT_HASH_RE = /^-\s+([0-9a-fA-F]{7,40})(?=[:\s]|$)/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An issue or pull-request reference: `#123`, `owner/repo#123`.
|
|
57
|
+
*
|
|
58
|
+
* No leading `\b` — `#` is not a word character, so a boundary assertion
|
|
59
|
+
* immediately before it never matches the ordinary case of a bare `#123`
|
|
60
|
+
* preceded by whitespace or punctuation.
|
|
61
|
+
*/
|
|
62
|
+
const ISSUE_REF_RE = /(?:[\w.-]+\/[\w.-]+)?#\d+\b/g;
|
|
63
|
+
|
|
64
|
+
/** A paragraph or bullet headed by a verification word, bold or plain. */
|
|
65
|
+
const VERIFY_HEADING_RE =
|
|
66
|
+
/^(?:[-*+]\s+)?(?:\*\*|__)?(Verification|Verified|Bump|What was run|Commands)(?:\*\*|__)?(?=[\s:.]|$)/i;
|
|
67
|
+
|
|
68
|
+
/** The five scoreboard shapes a release note carries when it is really a test log. */
|
|
69
|
+
const SCOREBOARD_PATTERNS = [
|
|
70
|
+
{ re: /\bbyte-identical\b/gi, phrase: "byte-identical" },
|
|
71
|
+
{ re: /\b\d+\s+files?\b/gi, phrase: "an N files count" },
|
|
72
|
+
{ re: /\b\d+\s+tests?\s+pass(?:ed|ing)?\b/gi, phrase: "an N tests pass count" },
|
|
73
|
+
{ re: /\b\d+\s*→\s*\d+\b/g, phrase: "an N → M tally" },
|
|
74
|
+
{ re: /[++]\d+\s*\/\s*[−–-]\d+/g, phrase: "a +N / −M tally" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** A token that reads as code but sits outside any code span — warning only. */
|
|
78
|
+
const CODE_TOKEN_RE =
|
|
79
|
+
/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*(?:\(\))?\b|\b[\w-]+(?:\/[\w-]+)+\.[A-Za-z]{1,8}\b|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g;
|
|
80
|
+
|
|
81
|
+
/** Bullets over this many words read as a paragraph, not a release note. */
|
|
82
|
+
const MAX_BULLET_WORDS = 40;
|
|
83
|
+
/** Bullets over this many read as an itemized log, not "who notices". */
|
|
84
|
+
const MAX_TOP_BULLETS = 15;
|
|
85
|
+
/** Lines over this many read as a pull-request description. */
|
|
86
|
+
const MAX_SECTION_LINES = 60;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where a character offset in `text` falls, as a 1-based line and column.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} text - The text the offset indexes into.
|
|
92
|
+
* @param {number} index - 0-based character offset.
|
|
93
|
+
* @returns {{line: number, column: number}}
|
|
94
|
+
*/
|
|
95
|
+
function lineColOf(text, index) {
|
|
96
|
+
const before = text.slice(0, Math.max(0, index));
|
|
97
|
+
const nl = before.lastIndexOf("\n");
|
|
98
|
+
return { line: before.split("\n").length, column: index - nl };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Every code region in `text`, as a set of 1-based line numbers it spans.
|
|
103
|
+
*
|
|
104
|
+
* Block-level only (`spans: false`): an inline code span does not remove a
|
|
105
|
+
* whole line from consideration, only the characters it covers, which the
|
|
106
|
+
* text-scanning rules mask separately.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text - The section text.
|
|
109
|
+
* @returns {Set<number>} Lines that fall inside a fenced or indented block.
|
|
110
|
+
*/
|
|
111
|
+
function codeLineSet(text) {
|
|
112
|
+
const lines = new Set();
|
|
113
|
+
for (const region of codeRegions(text, { spans: false })) {
|
|
114
|
+
const from = lineColOf(text, region.start).line;
|
|
115
|
+
const to = lineColOf(text, Math.max(region.start, region.end - 1)).line;
|
|
116
|
+
for (let l = from; l <= to; l++) lines.add(l);
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this character offset inside a code region — block or inline span?
|
|
123
|
+
*
|
|
124
|
+
* @param {Array<{start: number, end: number}>} regions - Sorted, from
|
|
125
|
+
* {@link module:engine/code-fences.codeRegions}.
|
|
126
|
+
* @param {number} offset - A character offset into the text the regions were
|
|
127
|
+
* computed against.
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
function isMasked(regions, offset) {
|
|
131
|
+
for (const region of regions) {
|
|
132
|
+
if (offset < region.start) return false;
|
|
133
|
+
if (offset < region.end) return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The finding one rule reports, before its line is mapped into the caller's
|
|
140
|
+
* file.
|
|
141
|
+
*
|
|
142
|
+
* @typedef {object} RelativeFinding
|
|
143
|
+
* @property {number} line - 1-based line within the section text.
|
|
144
|
+
* @property {number} [column] - 1-based column, dropped when not meaningful.
|
|
145
|
+
* @property {"error"|"warning"} severity
|
|
146
|
+
* @property {string} message - Prefixed `changelog-check/<class> `, so a
|
|
147
|
+
* finding names the rule it tripped as well as what to write instead.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The opening line of every fenced code block — not an indented one, which
|
|
152
|
+
* reads as a sample and not as a pasted terminal transcript.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} text - Section text.
|
|
155
|
+
* @returns {RelativeFinding[]}
|
|
156
|
+
*/
|
|
157
|
+
function checkCodeFences(text) {
|
|
158
|
+
const lines = text.split("\n");
|
|
159
|
+
/** @type {RelativeFinding[]} */
|
|
160
|
+
const findings = [];
|
|
161
|
+
for (const region of codeRegions(text, { spans: false })) {
|
|
162
|
+
const { line } = lineColOf(text, region.start);
|
|
163
|
+
if (!/^[ \t]*(`{3,}|~{3,})/.test(lines[line - 1])) continue; // an indented block, not a fence
|
|
164
|
+
findings.push({
|
|
165
|
+
line,
|
|
166
|
+
severity: "error",
|
|
167
|
+
message:
|
|
168
|
+
"changelog-check/code-fence a fenced code block reads like a pull-request " +
|
|
169
|
+
"description, not a release note — describe what a user sees in prose",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return findings;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every top-level bullet, as its own contiguous run of lines.
|
|
177
|
+
*
|
|
178
|
+
* A bullet's continuation — a wrapped line, a second paragraph, a nested
|
|
179
|
+
* elaboration — is indented under it and belongs to it; a line back at
|
|
180
|
+
* column 0 that is not itself a bullet (a bold subsection label, ordinary
|
|
181
|
+
* prose) closes it.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} text - Section text.
|
|
184
|
+
* @param {Set<number>} codeLines - Lines inside a code region, from
|
|
185
|
+
* {@link codeLineSet}.
|
|
186
|
+
* @returns {Array<{startLine: number, text: string}>}
|
|
187
|
+
*/
|
|
188
|
+
function topLevelBullets(text, codeLines) {
|
|
189
|
+
const lines = text.split("\n");
|
|
190
|
+
/** @type {Array<{startLine: number, text: string}>} */
|
|
191
|
+
const bullets = [];
|
|
192
|
+
/** @type {{startLine: number, parts: string[]}|null} */
|
|
193
|
+
let current = null;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const lineNo = i + 1;
|
|
197
|
+
const raw = lines[i];
|
|
198
|
+
|
|
199
|
+
if (codeLines.has(lineNo)) {
|
|
200
|
+
if (current) current.parts.push(raw);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (TOP_BULLET_RE.test(raw)) {
|
|
204
|
+
if (current)
|
|
205
|
+
bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
|
|
206
|
+
current = { startLine: lineNo, parts: [raw] };
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (/^[ \t]+\S/.test(raw) || raw.trim() === "") {
|
|
210
|
+
if (current) current.parts.push(raw);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Column 0, not a bullet: a bold subsection label or a scaffold
|
|
214
|
+
// heading closes whatever bullet was open.
|
|
215
|
+
current = null;
|
|
216
|
+
}
|
|
217
|
+
if (current) bullets.push({ startLine: current.startLine, text: current.parts.join("\n") });
|
|
218
|
+
return bullets;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A bullet's word count, the bullet marker and markdown decoration stripped.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} bulletText - As {@link topLevelBullets} collects it.
|
|
225
|
+
* @returns {number}
|
|
226
|
+
*/
|
|
227
|
+
function wordCount(bulletText) {
|
|
228
|
+
return bulletText.replace(TOP_BULLET_RE, "").split(/\s+/).filter(Boolean).length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A commit hash opening a bullet — the artefact a changeset generator that is
|
|
233
|
+
* not `@changesets/cli/changelog` writes on every entry.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} text - Section text.
|
|
236
|
+
* @param {Set<number>} codeLines
|
|
237
|
+
* @param {Set<number>} scaffoldLines
|
|
238
|
+
* @returns {RelativeFinding[]}
|
|
239
|
+
*/
|
|
240
|
+
function checkCommitHash(text, codeLines, scaffoldLines) {
|
|
241
|
+
const lines = text.split("\n");
|
|
242
|
+
/** @type {RelativeFinding[]} */
|
|
243
|
+
const findings = [];
|
|
244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
245
|
+
const lineNo = i + 1;
|
|
246
|
+
if (codeLines.has(lineNo) || scaffoldLines.has(lineNo)) continue;
|
|
247
|
+
const m = COMMIT_HASH_RE.exec(lines[i]);
|
|
248
|
+
if (!m) continue;
|
|
249
|
+
findings.push({
|
|
250
|
+
line: lineNo,
|
|
251
|
+
column: lines[i].indexOf(m[1]) + 1,
|
|
252
|
+
severity: "error",
|
|
253
|
+
message:
|
|
254
|
+
"changelog-check/commit-hash a commit hash is a commit-log artefact; the " +
|
|
255
|
+
"changelog generator should not write one — set `changelog` to " +
|
|
256
|
+
"`@changesets/cli/changelog`",
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return findings;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* An issue or pull-request reference, wherever it appears outside code.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} text - Section text.
|
|
266
|
+
* @param {Array<{start: number, end: number}>} maskedRegions
|
|
267
|
+
* @returns {RelativeFinding[]}
|
|
268
|
+
*/
|
|
269
|
+
function checkIssueReferences(text, maskedRegions) {
|
|
270
|
+
/** @type {RelativeFinding[]} */
|
|
271
|
+
const findings = [];
|
|
272
|
+
for (const m of text.matchAll(ISSUE_REF_RE)) {
|
|
273
|
+
if (isMasked(maskedRegions, m.index)) continue;
|
|
274
|
+
const { line, column } = lineColOf(text, m.index);
|
|
275
|
+
findings.push({
|
|
276
|
+
line,
|
|
277
|
+
column,
|
|
278
|
+
severity: "error",
|
|
279
|
+
message:
|
|
280
|
+
`changelog-check/issue-reference "${m[0]}" is an issue or pull-request ` +
|
|
281
|
+
"reference — that belongs on the pull request's own `Closes #<n>` line, " +
|
|
282
|
+
"not the changelog; a released package has no tracker for its reader to open",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return findings;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A paragraph or bullet whose first words announce how the change was
|
|
290
|
+
* verified, rather than what it does.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} text - Section text.
|
|
293
|
+
* @param {Set<number>} codeLines
|
|
294
|
+
* @returns {RelativeFinding[]}
|
|
295
|
+
*/
|
|
296
|
+
function checkVerificationParagraphs(text, codeLines) {
|
|
297
|
+
const lines = text.split("\n");
|
|
298
|
+
/** @type {RelativeFinding[]} */
|
|
299
|
+
const findings = [];
|
|
300
|
+
for (let i = 0; i < lines.length; i++) {
|
|
301
|
+
const lineNo = i + 1;
|
|
302
|
+
if (codeLines.has(lineNo)) continue;
|
|
303
|
+
const trimmed = lines[i].replace(/^[ \t]+/, "");
|
|
304
|
+
const m = VERIFY_HEADING_RE.exec(trimmed);
|
|
305
|
+
if (!m) continue;
|
|
306
|
+
findings.push({
|
|
307
|
+
line: lineNo,
|
|
308
|
+
column: lines[i].length - trimmed.length + 1,
|
|
309
|
+
severity: "error",
|
|
310
|
+
message:
|
|
311
|
+
`changelog-check/verification-paragraph "${m[1]}" is how the change was ` +
|
|
312
|
+
"checked, not what it does — say what a user sees, and move this to the " +
|
|
313
|
+
"pull request's own description",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return findings;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A byte count, file count, pass tally or before/after count — every one a
|
|
321
|
+
* verification artefact, not something a user meets.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} text - Section text.
|
|
324
|
+
* @param {Array<{start: number, end: number}>} maskedRegions
|
|
325
|
+
* @returns {RelativeFinding[]}
|
|
326
|
+
*/
|
|
327
|
+
function checkScoreboardPhrases(text, maskedRegions) {
|
|
328
|
+
/** @type {RelativeFinding[]} */
|
|
329
|
+
const findings = [];
|
|
330
|
+
for (const { re, phrase } of SCOREBOARD_PATTERNS) {
|
|
331
|
+
for (const m of text.matchAll(re)) {
|
|
332
|
+
if (isMasked(maskedRegions, m.index)) continue;
|
|
333
|
+
const { line, column } = lineColOf(text, m.index);
|
|
334
|
+
findings.push({
|
|
335
|
+
line,
|
|
336
|
+
column,
|
|
337
|
+
severity: "error",
|
|
338
|
+
message:
|
|
339
|
+
`changelog-check/scoreboard-phrase "${m[0]}" is ${phrase} — a scoreboard ` +
|
|
340
|
+
"a reviewer wanted, not a change a user meets; describe the user-visible " +
|
|
341
|
+
"effect instead",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return findings;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A bullet running past {@link MAX_BULLET_WORDS} words.
|
|
350
|
+
*
|
|
351
|
+
* @param {Array<{startLine: number, text: string}>} bullets
|
|
352
|
+
* @returns {RelativeFinding[]}
|
|
353
|
+
*/
|
|
354
|
+
function checkLongBullets(bullets) {
|
|
355
|
+
/** @type {RelativeFinding[]} */
|
|
356
|
+
const findings = [];
|
|
357
|
+
for (const bullet of bullets) {
|
|
358
|
+
const words = wordCount(bullet.text);
|
|
359
|
+
if (words <= MAX_BULLET_WORDS) continue;
|
|
360
|
+
findings.push({
|
|
361
|
+
line: bullet.startLine,
|
|
362
|
+
column: 1,
|
|
363
|
+
severity: "error",
|
|
364
|
+
message:
|
|
365
|
+
`changelog-check/long-bullet this bullet runs to ${words} words — a ` +
|
|
366
|
+
`changeset bullet is one sentence naming who notices and what they see; ` +
|
|
367
|
+
"split it, or move the detail to the pull request's description",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return findings;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The first line of every contiguous run of nested list items.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} text - Section text.
|
|
377
|
+
* @param {Set<number>} codeLines
|
|
378
|
+
* @returns {RelativeFinding[]}
|
|
379
|
+
*/
|
|
380
|
+
function checkNestedLists(text, codeLines) {
|
|
381
|
+
const lines = text.split("\n");
|
|
382
|
+
/** @type {RelativeFinding[]} */
|
|
383
|
+
const findings = [];
|
|
384
|
+
let prevNested = false;
|
|
385
|
+
for (let i = 0; i < lines.length; i++) {
|
|
386
|
+
const lineNo = i + 1;
|
|
387
|
+
if (codeLines.has(lineNo)) {
|
|
388
|
+
prevNested = false;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const isNested = NESTED_BULLET_RE.test(lines[i]);
|
|
392
|
+
if (isNested && !prevNested) {
|
|
393
|
+
const marker = /^[ \t]*/.exec(lines[i])[0];
|
|
394
|
+
findings.push({
|
|
395
|
+
line: lineNo,
|
|
396
|
+
column: marker.length + 1,
|
|
397
|
+
severity: "error",
|
|
398
|
+
message:
|
|
399
|
+
"changelog-check/nested-list a nested list reads like a pull-request " +
|
|
400
|
+
"checklist — write one flat sentence per bullet instead",
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
prevNested = isNested;
|
|
404
|
+
}
|
|
405
|
+
return findings;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A `#` heading of any level inside the prose — the scaffold `## <version>`
|
|
410
|
+
* and `### <Bump> Changes` lines excepted, since neither is authored.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} text - Section text.
|
|
413
|
+
* @param {Set<number>} codeLines
|
|
414
|
+
* @param {Set<number>} scaffoldLines
|
|
415
|
+
* @returns {RelativeFinding[]}
|
|
416
|
+
*/
|
|
417
|
+
function checkHeadings(text, codeLines, scaffoldLines) {
|
|
418
|
+
const lines = text.split("\n");
|
|
419
|
+
/** @type {RelativeFinding[]} */
|
|
420
|
+
const findings = [];
|
|
421
|
+
for (let i = 0; i < lines.length; i++) {
|
|
422
|
+
const lineNo = i + 1;
|
|
423
|
+
if (codeLines.has(lineNo) || scaffoldLines.has(lineNo)) continue;
|
|
424
|
+
if (!/^#{1,6}\s/.test(lines[i])) continue;
|
|
425
|
+
findings.push({
|
|
426
|
+
line: lineNo,
|
|
427
|
+
column: 1,
|
|
428
|
+
severity: "error",
|
|
429
|
+
message:
|
|
430
|
+
"changelog-check/heading a `#` heading inside a changelog entry outranks the " +
|
|
431
|
+
"version heading above it once the entry is wrapped into a list item — use a " +
|
|
432
|
+
"bold label instead",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return findings;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* More than {@link MAX_TOP_BULLETS} top-level bullets in one section.
|
|
440
|
+
*
|
|
441
|
+
* @param {Array<{startLine: number, text: string}>} bullets
|
|
442
|
+
* @returns {RelativeFinding[]}
|
|
443
|
+
*/
|
|
444
|
+
function checkTooManyBullets(bullets) {
|
|
445
|
+
if (bullets.length <= MAX_TOP_BULLETS) return [];
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
line: bullets[MAX_TOP_BULLETS].startLine,
|
|
449
|
+
severity: "error",
|
|
450
|
+
message:
|
|
451
|
+
`changelog-check/too-many-bullets ${bullets.length} top-level bullets is an ` +
|
|
452
|
+
`itemized log, not a release note — one to six, so a reader sees the shape ` +
|
|
453
|
+
"of the release at a glance",
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* More than {@link MAX_SECTION_LINES} lines in one section.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} text - Section text.
|
|
462
|
+
* @returns {RelativeFinding[]}
|
|
463
|
+
*/
|
|
464
|
+
function checkTooManyLines(text) {
|
|
465
|
+
const lines = text.split("\n");
|
|
466
|
+
if (lines.length <= MAX_SECTION_LINES) return [];
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
line: MAX_SECTION_LINES + 1,
|
|
470
|
+
severity: "error",
|
|
471
|
+
message:
|
|
472
|
+
`changelog-check/too-many-lines ${lines.length} lines in one release section ` +
|
|
473
|
+
"reads like a pull-request description — trim to what a user meets",
|
|
474
|
+
},
|
|
475
|
+
];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* A token that reads as code — `camelCase()`, `a/path.ext`, `SCREAMING_SNAKE`
|
|
480
|
+
* — outside any code span. A warning: a user-facing note sometimes needs one
|
|
481
|
+
* (`Compendium.hm3.items.Item.<id>`), but rarely.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} text - Section text.
|
|
484
|
+
* @param {Array<{start: number, end: number}>} maskedRegions
|
|
485
|
+
* @returns {RelativeFinding[]}
|
|
486
|
+
*/
|
|
487
|
+
function checkCodeLikeTokens(text, maskedRegions) {
|
|
488
|
+
/** @type {RelativeFinding[]} */
|
|
489
|
+
const findings = [];
|
|
490
|
+
for (const m of text.matchAll(CODE_TOKEN_RE)) {
|
|
491
|
+
if (isMasked(maskedRegions, m.index)) continue;
|
|
492
|
+
const { line, column } = lineColOf(text, m.index);
|
|
493
|
+
findings.push({
|
|
494
|
+
line,
|
|
495
|
+
column,
|
|
496
|
+
severity: "warning",
|
|
497
|
+
message:
|
|
498
|
+
`changelog-check/code-like-token "${m[0]}" looks like code outside a code ` +
|
|
499
|
+
"span — wrap it in backticks if it is a literal a user would type",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return findings;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The 1-based lines a caller drops from the heading rule because they are the
|
|
507
|
+
* generated scaffold, never authored prose: the section's own `## <version>`
|
|
508
|
+
* opening and any `### <Bump> Changes` line.
|
|
509
|
+
*
|
|
510
|
+
* @param {string} text - Section text.
|
|
511
|
+
* @returns {Set<number>}
|
|
512
|
+
*/
|
|
513
|
+
function scaffoldLineSet(text) {
|
|
514
|
+
const lines = text.split("\n");
|
|
515
|
+
const set = new Set();
|
|
516
|
+
if (RELEASE_HEADING_RE.test(lines[0] ?? "")) set.add(1);
|
|
517
|
+
for (let i = 0; i < lines.length; i++) {
|
|
518
|
+
if (CHANGES_HEADING_RE.test(lines[i])) set.add(i + 1);
|
|
519
|
+
}
|
|
520
|
+
return set;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Run every rule over one section of release prose.
|
|
525
|
+
*
|
|
526
|
+
* @param {string} text - The section, already isolated by the caller.
|
|
527
|
+
* @returns {RelativeFinding[]} Findings with line numbers relative to `text`.
|
|
528
|
+
*/
|
|
529
|
+
function lintSection(text) {
|
|
530
|
+
const codeLines = codeLineSet(text);
|
|
531
|
+
const maskedRegions = codeRegions(text, { spans: true });
|
|
532
|
+
const scaffoldLines = scaffoldLineSet(text);
|
|
533
|
+
const bullets = topLevelBullets(text, codeLines);
|
|
534
|
+
|
|
535
|
+
return [
|
|
536
|
+
...checkCommitHash(text, codeLines, scaffoldLines),
|
|
537
|
+
...checkIssueReferences(text, maskedRegions),
|
|
538
|
+
...checkCodeFences(text),
|
|
539
|
+
...checkVerificationParagraphs(text, codeLines),
|
|
540
|
+
...checkScoreboardPhrases(text, maskedRegions),
|
|
541
|
+
...checkLongBullets(bullets),
|
|
542
|
+
...checkNestedLists(text, codeLines),
|
|
543
|
+
...checkHeadings(text, codeLines, scaffoldLines),
|
|
544
|
+
...checkTooManyBullets(bullets),
|
|
545
|
+
...checkTooManyLines(text),
|
|
546
|
+
...checkCodeLikeTokens(text, maskedRegions),
|
|
547
|
+
].sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* A pending changeset's frontmatter fence, stripped.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} text - The changeset file's full contents.
|
|
554
|
+
* @returns {{body: string, startLine: number}} The body, and the 1-based line
|
|
555
|
+
* in the original file its first line falls on.
|
|
556
|
+
*/
|
|
557
|
+
function stripFrontmatter(text) {
|
|
558
|
+
const lines = text.split("\n");
|
|
559
|
+
if (lines[0] !== "---") return { body: text, startLine: 1 };
|
|
560
|
+
let end = -1;
|
|
561
|
+
for (let i = 1; i < lines.length; i++) {
|
|
562
|
+
if (lines[i] === "---") {
|
|
563
|
+
end = i;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (end === -1) return { body: text, startLine: 1 };
|
|
568
|
+
return { body: lines.slice(end + 1).join("\n"), startLine: end + 2 };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The first `## <version>` section of a `CHANGELOG.md`.
|
|
573
|
+
*
|
|
574
|
+
* @param {string} text - The changelog's full contents.
|
|
575
|
+
* @returns {{body: string, startLine: number}|null} `null` when no `## `
|
|
576
|
+
* heading is present at all.
|
|
577
|
+
*/
|
|
578
|
+
function extractReleaseSection(text) {
|
|
579
|
+
const lines = text.split("\n");
|
|
580
|
+
const start = lines.findIndex((line) => RELEASE_HEADING_RE.test(line));
|
|
581
|
+
if (start === -1) return null;
|
|
582
|
+
let end = lines.length;
|
|
583
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
584
|
+
if (RELEASE_HEADING_RE.test(lines[i])) {
|
|
585
|
+
end = i;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { body: lines.slice(start, end).join("\n"), startLine: start + 1 };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Lint one pending changeset (`.changeset/*.md`).
|
|
594
|
+
*
|
|
595
|
+
* @param {string} text - The file's full contents, frontmatter included.
|
|
596
|
+
* @returns {{findings: Array<{line: number, column?: number,
|
|
597
|
+
* severity: "error"|"warning", message: string}>}}
|
|
598
|
+
*/
|
|
599
|
+
export function lintChangesetText(text) {
|
|
600
|
+
const { body, startLine } = stripFrontmatter(text);
|
|
601
|
+
const findings = lintSection(body).map((f) => ({ ...f, line: f.line + startLine - 1 }));
|
|
602
|
+
return { findings };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Lint the first `## <version>` release section of a `CHANGELOG.md`.
|
|
607
|
+
*
|
|
608
|
+
* @param {string} text - The changelog's full contents.
|
|
609
|
+
* @returns {{findings: Array<{line?: number, column?: number,
|
|
610
|
+
* severity: "error"|"warning", message: string}>}}
|
|
611
|
+
*/
|
|
612
|
+
export function lintReleaseText(text) {
|
|
613
|
+
const section = extractReleaseSection(text);
|
|
614
|
+
if (!section) {
|
|
615
|
+
return {
|
|
616
|
+
findings: [
|
|
617
|
+
{
|
|
618
|
+
severity: "error",
|
|
619
|
+
message: "changelog-check/no-release-section no `## <version>` heading found",
|
|
620
|
+
},
|
|
621
|
+
],
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
const findings = lintSection(section.body).map((f) => ({
|
|
625
|
+
...f,
|
|
626
|
+
line: f.line + section.startLine - 1,
|
|
627
|
+
}));
|
|
628
|
+
return { findings };
|
|
629
|
+
}
|
|
@@ -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
|
-
*
|
|
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
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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} ` +
|