@heroiclands/package-build 22.4.0 → 22.4.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.
- package/CHANGELOG.md +18 -0
- package/CONTENT.md +7 -0
- package/bin/package-build.mjs +83 -0
- package/docs/commands.md +67 -0
- package/engine/actor-compiler.mjs +53 -14
- package/engine/changelog-lint.mjs +629 -0
- package/githooks/pre-commit +53 -2
- package/package.json +1 -1
- package/types/engine/actor-compiler.d.mts +42 -9
- package/types/engine/changelog-lint.d.mts +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @heroiclands/package-build
|
|
2
2
|
|
|
3
|
+
## 22.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 37d874d: Adds `package-build changelog check`, which lints a pending changeset or a
|
|
8
|
+
`CHANGELOG.md` release section for the marks a pull-request description
|
|
9
|
+
leaves behind — a commit hash, an issue reference, a code fence, a
|
|
10
|
+
"Verified" paragraph, a byte or test count, an over-long or nested bullet,
|
|
11
|
+
a stray heading, or simply too many bullets or lines for one entry — and
|
|
12
|
+
refuses to let one merge. The shipped `pre-commit` hook runs it on a staged
|
|
13
|
+
changeset automatically.
|
|
14
|
+
- 0007964: **A module that embeds HârnMaster 3 items resolves them from a release that
|
|
15
|
+
stores their shortcodes under the system's own flags.** The actor pass reads
|
|
16
|
+
an item's `(type, shortcode)` address from `system.shortcode` where a system's
|
|
17
|
+
data model declares one, and otherwise from that system's own flag namespace —
|
|
18
|
+
never another system's, so a catalogue extracted for one system resolves
|
|
19
|
+
against its own handles only.
|
|
20
|
+
|
|
3
21
|
## 22.4.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/CONTENT.md
CHANGED
|
@@ -1784,6 +1784,13 @@ fetch time, so a cache filled before this rule existed is treated as incomplete
|
|
|
1784
1784
|
and `deps fetch` refills it — the alternative is a lookup that answers with the
|
|
1785
1785
|
wrong system's document and reports nothing.
|
|
1786
1786
|
|
|
1787
|
+
**Within one document, the shortcode itself comes from `system.shortcode`
|
|
1788
|
+
where the data model declares such a field, and otherwise from
|
|
1789
|
+
`flags.<systemId>.shortcode` in that same system's own flag namespace** — a
|
|
1790
|
+
system writes its per-document handle into its own flags and never another
|
|
1791
|
+
system's, so a document carrying its shortcode under a different system's flag
|
|
1792
|
+
namespace is not resolved by it.
|
|
1793
|
+
|
|
1787
1794
|
**`--from` is for two packages changing together.** It fills the cache from a
|
|
1788
1795
|
locally built artifact — a package zip or the directory it was built from — so a
|
|
1789
1796
|
consumer can be built against a dependency that has not shipped. Without it,
|
package/bin/package-build.mjs
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* npx package-build lang check
|
|
54
54
|
* npx package-build lang coverage [--unused]
|
|
55
55
|
* npx package-build lang hardcoded
|
|
56
|
+
* npx package-build changelog check [--release] [paths..]
|
|
56
57
|
* npx package-build bundle check
|
|
57
58
|
* npx package-build release
|
|
58
59
|
* npx package-build deploy <stage>
|
|
@@ -88,6 +89,7 @@ import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
|
|
|
88
89
|
import { validateLangSource } from "../lang.mjs";
|
|
89
90
|
import { checkLabelRegistry } from "../labels.mjs";
|
|
90
91
|
import { lintYaml } from "../engine/yaml-lint.mjs";
|
|
92
|
+
import { lintChangesetText, lintReleaseText } from "../engine/changelog-lint.mjs";
|
|
91
93
|
import { bumpDependencies } from "../engine/dependency-bump.mjs";
|
|
92
94
|
import {
|
|
93
95
|
analyzeCoverage,
|
|
@@ -776,6 +778,86 @@ function yamlCommand() {
|
|
|
776
778
|
};
|
|
777
779
|
}
|
|
778
780
|
|
|
781
|
+
/**
|
|
782
|
+
* `changelog check` — lint release prose against the rules a changeset is
|
|
783
|
+
* actually held to (`check` is the only action).
|
|
784
|
+
*
|
|
785
|
+
* A changeset answers one question — who notices, and what do they see — and
|
|
786
|
+
* nothing enforced it, so a pull-request description pasted into one ships
|
|
787
|
+
* verbatim as a release note. Default reads every pending changeset;
|
|
788
|
+
* `--release` reads the first `## <version>` section of `CHANGELOG.md`
|
|
789
|
+
* instead, for the **Version Packages** branch a merge to `main` opens.
|
|
790
|
+
*
|
|
791
|
+
* @returns {object} The yargs command module.
|
|
792
|
+
*/
|
|
793
|
+
function changelogCommand() {
|
|
794
|
+
return {
|
|
795
|
+
command: "changelog <action> [paths..]",
|
|
796
|
+
describe: "Release-prose checks",
|
|
797
|
+
builder: (y) =>
|
|
798
|
+
y
|
|
799
|
+
.positional("action", {
|
|
800
|
+
choices: ["check"],
|
|
801
|
+
describe: "check: lint pending changesets, or a release section",
|
|
802
|
+
})
|
|
803
|
+
.positional("paths", {
|
|
804
|
+
describe:
|
|
805
|
+
"Files to check. Defaults to `.changeset/*.md` (config.json and " +
|
|
806
|
+
"README.md excluded), or `CHANGELOG.md` with --release.",
|
|
807
|
+
type: "string",
|
|
808
|
+
})
|
|
809
|
+
.option("release", {
|
|
810
|
+
type: "boolean",
|
|
811
|
+
default: false,
|
|
812
|
+
describe:
|
|
813
|
+
"Check the first `## <version>` section of CHANGELOG.md instead of " +
|
|
814
|
+
"pending changesets",
|
|
815
|
+
}),
|
|
816
|
+
handler: handler(async (args) => changelogCheck(args)),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The files `changelog check` reads by default: every pending changeset, or
|
|
822
|
+
* `CHANGELOG.md` alone under `--release`.
|
|
823
|
+
*
|
|
824
|
+
* `config.json` is excluded by the glob itself (it is not `.md`); `README.md`
|
|
825
|
+
* is excluded by name, since it is prose about changesets rather than one.
|
|
826
|
+
*
|
|
827
|
+
* @param {object} args - Parsed CLI arguments.
|
|
828
|
+
* @returns {string[]} Paths, relative to the working directory.
|
|
829
|
+
*/
|
|
830
|
+
function changelogFiles(args) {
|
|
831
|
+
if (args.paths?.length) return args.paths;
|
|
832
|
+
if (args.release) return ["CHANGELOG.md"];
|
|
833
|
+
return globSync(".changeset/*.md", { cwd: process.cwd() }).filter(
|
|
834
|
+
(file) => path.basename(file) !== "README.md",
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Run `changelog check` over every resolved file and report the result.
|
|
840
|
+
*
|
|
841
|
+
* @param {object} args - Parsed CLI arguments.
|
|
842
|
+
*/
|
|
843
|
+
function changelogCheck(args) {
|
|
844
|
+
const files = changelogFiles(args);
|
|
845
|
+
let errors = 0;
|
|
846
|
+
let total = 0;
|
|
847
|
+
for (const file of files) {
|
|
848
|
+
if (!fs.existsSync(file)) die(`changelog check: ${file} does not exist.`);
|
|
849
|
+
const text = fs.readFileSync(file, "utf8");
|
|
850
|
+
const { findings } = args.release ? lintReleaseText(text) : lintChangesetText(text);
|
|
851
|
+
errors += reportFindings(findings, { file });
|
|
852
|
+
total += findings.length;
|
|
853
|
+
}
|
|
854
|
+
console.log(
|
|
855
|
+
`package-build: ${files.length} file(s) checked · ` +
|
|
856
|
+
`${errors} error(s) · ${total - errors} warning(s)`,
|
|
857
|
+
);
|
|
858
|
+
if (errors) process.exitCode = 1;
|
|
859
|
+
}
|
|
860
|
+
|
|
779
861
|
/**
|
|
780
862
|
* `labels check` — do the machine registry and the documented table agree?
|
|
781
863
|
*
|
|
@@ -1237,6 +1319,7 @@ yargs(hideBin(process.argv))
|
|
|
1237
1319
|
.command(labelsCommand())
|
|
1238
1320
|
.command(bumpCommand())
|
|
1239
1321
|
.command(yamlCommand())
|
|
1322
|
+
.command(changelogCommand())
|
|
1240
1323
|
.command(bundleCommand())
|
|
1241
1324
|
.command(releaseCommand())
|
|
1242
1325
|
.command(deployCommand())
|
package/docs/commands.md
CHANGED
|
@@ -611,6 +611,73 @@ package-build: 2 file(s) checked · 0 error(s) · 0 warning(s)
|
|
|
611
611
|
|
|
612
612
|
[Diagnostics](diagnostics.md).
|
|
613
613
|
|
|
614
|
+
### `package-build changelog check`
|
|
615
|
+
|
|
616
|
+
**NAME**
|
|
617
|
+
|
|
618
|
+
Lint release prose — pending changesets, or a `CHANGELOG.md` release section
|
|
619
|
+
— against the rules a changeset is actually held to (`check` is the only
|
|
620
|
+
action).
|
|
621
|
+
|
|
622
|
+
**SYNOPSIS**
|
|
623
|
+
|
|
624
|
+
```
|
|
625
|
+
package-build changelog check [--release] [paths..]
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
**DESCRIPTION**
|
|
629
|
+
|
|
630
|
+
A changeset answers one question — who notices, and what do they see — and
|
|
631
|
+
nothing enforced that, so a pull-request description pasted into one ships
|
|
632
|
+
verbatim as a release note: commit hashes, issue references, code fences,
|
|
633
|
+
"Verified" paragraphs, byte counts and test tallies, a paragraph disguised as
|
|
634
|
+
a bullet, a nested checklist, a `#` heading that outranks the version heading
|
|
635
|
+
above it once wrapped into a list item, or simply too many bullets or too
|
|
636
|
+
many lines for one entry. Each finding names the rule it tripped and says
|
|
637
|
+
what to write instead.
|
|
638
|
+
|
|
639
|
+
Default reads every pending changeset (`.changeset/*.md`; `config.json` and
|
|
640
|
+
`README.md` excluded) and checks each one's body, frontmatter fence dropped.
|
|
641
|
+
`--release` reads the first `## <version>` section of `CHANGELOG.md` instead
|
|
642
|
+
— the section a **Version Packages** branch is about to publish — with its
|
|
643
|
+
generated `## <version>` and `### <Bump> Changes` scaffold lines exempted
|
|
644
|
+
from the heading rule, since neither is authored.
|
|
645
|
+
|
|
646
|
+
A token that reads as code — `camelCase()`, a `path/with/slashes.ext`,
|
|
647
|
+
`SCREAMING_SNAKE` — outside any code span is a warning, not a failure: a
|
|
648
|
+
user-facing note sometimes needs one (`Compendium.hm3.items.Item.<id>`), but
|
|
649
|
+
rarely. Every other finding is an error. Reads the files given, or resolves
|
|
650
|
+
its own defaults; writes nothing.
|
|
651
|
+
|
|
652
|
+
**OPTIONS**
|
|
653
|
+
|
|
654
|
+
| Positional | Type | Default | Description |
|
|
655
|
+
| ----------- | --------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
656
|
+
| `paths` | string(s) | `.changeset/*.md` (`config.json`, `README.md` excluded), or `CHANGELOG.md` with `--release` | Files to check. |
|
|
657
|
+
| `--release` | boolean | `false` | Check the first `## <version>` section of `CHANGELOG.md` instead of pending changesets. |
|
|
658
|
+
|
|
659
|
+
**EXIT STATUS**
|
|
660
|
+
|
|
661
|
+
1 if a named file does not exist. 1 if any finding is an error. A run with
|
|
662
|
+
only warnings — the code-like-token case — exits 0.
|
|
663
|
+
|
|
664
|
+
**EXAMPLES**
|
|
665
|
+
|
|
666
|
+
```
|
|
667
|
+
$ package-build changelog check
|
|
668
|
+
package-build: 1 file(s) checked · 0 error(s) · 0 warning(s)
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
```
|
|
672
|
+
$ package-build changelog check --release
|
|
673
|
+
CHANGELOG.md:5:3: error: changelog-check/commit-hash a commit hash is a commit-log artefact; the changelog generator should not write one — set `changelog` to `@changesets/cli/changelog`
|
|
674
|
+
package-build: 1 file(s) checked · 1 error(s) · 0 warning(s)
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**SEE ALSO**
|
|
678
|
+
|
|
679
|
+
[Diagnostics](diagnostics.md).
|
|
680
|
+
|
|
614
681
|
### `package-build bundle check`
|
|
615
682
|
|
|
616
683
|
**NAME**
|
|
@@ -200,13 +200,39 @@ export function catalogueKey(subType, shortcode, pkg) {
|
|
|
200
200
|
return pkg ? packagedItemAddress(pkg, subType, folded) : itemAddress(subType, folded);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* An item's own shortcode, read off its compiled document.
|
|
205
|
+
*
|
|
206
|
+
* **`system.shortcode`**, where a system's data model declares such a field.
|
|
207
|
+
* Where it does not — HM3's has no such field — the handle instead lives in
|
|
208
|
+
* that system's own flag namespace, `flags.<systemId>.shortcode`: a system
|
|
209
|
+
* writes its per-document handle into its own flags and never another
|
|
210
|
+
* system's, so a document extracted from one system's catalogue is read
|
|
211
|
+
* through that system's namespace and no other. `system.shortcode` wins where
|
|
212
|
+
* both are present.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} doc - A compiled Item document, or an embedded item merged
|
|
215
|
+
* from one.
|
|
216
|
+
* @param {string|null} [systemId] - The system whose catalogue `doc` was read
|
|
217
|
+
* from. Omitted or `null`, only `system.shortcode` is read.
|
|
218
|
+
* @returns {string|undefined} The shortcode, or `undefined` when the document
|
|
219
|
+
* states neither.
|
|
220
|
+
*/
|
|
221
|
+
export function shortcodeOf(doc, systemId = null) {
|
|
222
|
+
const own = doc?.system?.shortcode;
|
|
223
|
+
if (own) return own;
|
|
224
|
+
if (!systemId) return undefined;
|
|
225
|
+
return doc?.flags?.[systemId]?.shortcode;
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
/**
|
|
204
229
|
* What identifies one embedded item on its actor.
|
|
205
230
|
*
|
|
206
|
-
* **Its own
|
|
207
|
-
* merely *selects* the catalogue template the
|
|
208
|
-
* never written to the document. Two daggers
|
|
209
|
-
* embodiments and each must declare its
|
|
231
|
+
* **Its own shortcode** — read by {@link shortcodeOf} — not the entry's
|
|
232
|
+
* top-level `shortcode`, which merely *selects* the catalogue template the
|
|
233
|
+
* entry is written from and is never written to the document. Two daggers
|
|
234
|
+
* may share a selector; they are two embodiments and each must declare its
|
|
235
|
+
* own.
|
|
210
236
|
*
|
|
211
237
|
* The name is a last resort, for a **stand-alone** entry that names no template
|
|
212
238
|
* and states no shortcode. It is a poor identity — presentation, and free to be
|
|
@@ -215,10 +241,13 @@ export function catalogueKey(subType, shortcode, pkg) {
|
|
|
215
241
|
* message says to state a `system.shortcode`.
|
|
216
242
|
*
|
|
217
243
|
* @param {object} item - The merged embedded item.
|
|
244
|
+
* @param {string|null} [systemId] - The system this item's document was
|
|
245
|
+
* compiled or extracted for, so a document whose own data model carries no
|
|
246
|
+
* `system.shortcode` field is still read by its own flag namespace.
|
|
218
247
|
* @returns {string} The identity, for {@link embeddedItemId}.
|
|
219
248
|
*/
|
|
220
|
-
export function embeddedIdentity(item) {
|
|
221
|
-
const own = item
|
|
249
|
+
export function embeddedIdentity(item, systemId = null) {
|
|
250
|
+
const own = shortcodeOf(item, systemId);
|
|
222
251
|
if (typeof own === "string" && own.trim()) return own.trim();
|
|
223
252
|
return typeof item?.name === "string" ? item.name : "";
|
|
224
253
|
}
|
|
@@ -253,9 +282,9 @@ export function embeddedItemId(actorId, subType, identity) {
|
|
|
253
282
|
/**
|
|
254
283
|
* Load every JSON file under each of `itemsSourceDirs`, returning one Map keyed
|
|
255
284
|
* by {@link itemAddress} — the compiled document's **subtype** and its
|
|
256
|
-
*
|
|
257
|
-
* The `_key` field is stripped from each entry — it is
|
|
258
|
-
* data model.
|
|
285
|
+
* shortcode, read by {@link shortcodeOf}. Folder docs and entries without a
|
|
286
|
+
* shortcode are skipped. The `_key` field is stripped from each entry — it is
|
|
287
|
+
* not part of the item data model.
|
|
259
288
|
*
|
|
260
289
|
* The directories are read as one address space, because an actor names an item
|
|
261
290
|
* by `(type, shortcode)` and never by the pack it happens to ship in. Two local
|
|
@@ -269,12 +298,22 @@ export function embeddedItemId(actorId, subType, identity) {
|
|
|
269
298
|
* colliding with it. Local directories are therefore read first, and anything
|
|
270
299
|
* already claimed is left alone.
|
|
271
300
|
*
|
|
301
|
+
* **Each foreign directory reads its own flag namespace.** A foreign entry's
|
|
302
|
+
* `package` is the system whose catalogue it was extracted from, and that is
|
|
303
|
+
* the only namespace {@link shortcodeOf} is asked to fall back to for it — a
|
|
304
|
+
* document carrying another system's flag, sitting in this system's catalogue,
|
|
305
|
+
* is exactly the defect a system writing outside its own namespace produces,
|
|
306
|
+
* and is silently skipped rather than resolved.
|
|
307
|
+
*
|
|
272
308
|
* @param {readonly string[]} itemsSourceDirs - Every local Item pack's JSON tree.
|
|
273
309
|
* @param {readonly string[]} [foreignSourceDirs] - Extracted dependency
|
|
274
310
|
* catalogues, consulted only for addresses no local pack defines.
|
|
311
|
+
* @param {string|null} [system] - The system `itemsSourceDirs` were compiled
|
|
312
|
+
* for, so a local document whose data model carries no `system.shortcode`
|
|
313
|
+
* field is still read by its own flag namespace.
|
|
275
314
|
* @returns {Map<string, object>} The predefined items, by address.
|
|
276
315
|
*/
|
|
277
|
-
export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
316
|
+
export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = [], system = null) {
|
|
278
317
|
const map = new Map();
|
|
279
318
|
const source = new Map();
|
|
280
319
|
const shadowed = [];
|
|
@@ -307,7 +346,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
|
307
346
|
});
|
|
308
347
|
continue;
|
|
309
348
|
}
|
|
310
|
-
const shortcode = doc
|
|
349
|
+
const shortcode = shortcodeOf(doc, system);
|
|
311
350
|
if (!doc?.type || !shortcode) continue;
|
|
312
351
|
const address = catalogueKey(doc.type, shortcode);
|
|
313
352
|
const owner = source.get(address);
|
|
@@ -349,7 +388,7 @@ export function loadItemsMap(itemsSourceDirs, foreignSourceDirs = []) {
|
|
|
349
388
|
});
|
|
350
389
|
continue;
|
|
351
390
|
}
|
|
352
|
-
const shortcode = doc
|
|
391
|
+
const shortcode = shortcodeOf(doc, foreignPackage);
|
|
353
392
|
if (!doc?.type || !shortcode) continue;
|
|
354
393
|
const address = catalogueKey(doc.type, shortcode);
|
|
355
394
|
// eslint-disable-next-line no-unused-vars
|
|
@@ -574,7 +613,7 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
574
613
|
*/
|
|
575
614
|
async prepare() {
|
|
576
615
|
await super.prepare();
|
|
577
|
-
this.itemsMap = loadItemsMap(this.itemsSourceDirs, this.foreignSourceDirs);
|
|
616
|
+
this.itemsMap = loadItemsMap(this.itemsSourceDirs, this.foreignSourceDirs, this.system);
|
|
578
617
|
log.info(`Loaded ${this.itemsMap.size} predefined items for actor resolution`);
|
|
579
618
|
}
|
|
580
619
|
|
|
@@ -787,7 +826,7 @@ export class SystemActorCompiler extends BasePackCompiler {
|
|
|
787
826
|
// ships blank on purpose rather than collecting a default.
|
|
788
827
|
merged.img =
|
|
789
828
|
this.artPath(overlay ?? {}, "icon") ?? merged.img ?? itemArt(type, this.system);
|
|
790
|
-
const identity = embeddedIdentity(merged);
|
|
829
|
+
const identity = embeddedIdentity(merged, this.system);
|
|
791
830
|
const claim = `${actorId}\u0000${itemAddress(/** @type {string} */ (subType), identity)}`;
|
|
792
831
|
const first = this.#embeddedClaims.get(claim);
|
|
793
832
|
if (first !== undefined) {
|
|
@@ -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
|
+
}
|
package/githooks/pre-commit
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
#
|
|
3
|
-
# Refuse to commit on a protected branch
|
|
4
|
-
#
|
|
3
|
+
# Refuse to commit on a protected branch, and refuse a staged changeset that
|
|
4
|
+
# reads like a pull-request description.
|
|
5
|
+
#
|
|
6
|
+
# See protected-branch.sh for the branch rule and its opt-outs;
|
|
7
|
+
# pre-merge-commit is that guard's counterpart for merges. The changeset check
|
|
8
|
+
# below has no merge-commit counterpart — a merge commit stages no new
|
|
9
|
+
# changeset of its own.
|
|
5
10
|
#
|
|
6
11
|
# Installed for everyone via the package.json "prepare" script, which points git
|
|
7
12
|
# at this directory (`git config core.hooksPath .githooks`) on `npm install`.
|
|
@@ -9,3 +14,49 @@
|
|
|
9
14
|
. "$(dirname "$0")/protected-branch.sh"
|
|
10
15
|
|
|
11
16
|
guard_protected_branch
|
|
17
|
+
|
|
18
|
+
# On unless refused: `changelog check` reads only the files this commit is
|
|
19
|
+
# about to stage and runs in-process, so it costs a fraction of what the
|
|
20
|
+
# pre-push container check does and stays on by the same default as the
|
|
21
|
+
# no-attribution guard.
|
|
22
|
+
. "$(dirname "$0")/hook-enabled.sh"
|
|
23
|
+
|
|
24
|
+
hook_enabled changelogCheck true || exit 0
|
|
25
|
+
|
|
26
|
+
# Pending changesets this commit actually stages — added, copied or modified.
|
|
27
|
+
# A deleted changeset has nothing left to lint, and a changeset already on
|
|
28
|
+
# `main` was linted by the commit that staged it. `README.md` documents the
|
|
29
|
+
# changeset format; it is not one.
|
|
30
|
+
staged=""
|
|
31
|
+
while IFS= read -r file; do
|
|
32
|
+
[ -z "$file" ] && continue
|
|
33
|
+
case "$file" in
|
|
34
|
+
*/README.md | README.md) continue ;;
|
|
35
|
+
esac
|
|
36
|
+
staged="$staged $file"
|
|
37
|
+
done <<EOF
|
|
38
|
+
$(git diff --cached --name-only --diff-filter=ACM -- '.changeset/*.md')
|
|
39
|
+
EOF
|
|
40
|
+
|
|
41
|
+
if [ -z "$staged" ]; then
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# The binary lives beside this hook inside the package. If it is not there,
|
|
46
|
+
# this hook is being used from somewhere else — a global `core.hooksPath`, a
|
|
47
|
+
# copy — in a repository that does not install the package, and there is
|
|
48
|
+
# nothing to run.
|
|
49
|
+
runner="$(dirname "$0")/../bin/package-build.mjs"
|
|
50
|
+
if [ ! -f "$runner" ]; then
|
|
51
|
+
exit 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# shellcheck disable=SC2086
|
|
55
|
+
node "$runner" changelog check $staged
|
|
56
|
+
status=$?
|
|
57
|
+
|
|
58
|
+
if [ "$status" -ne 0 ]; then
|
|
59
|
+
echo ""
|
|
60
|
+
echo "pre-commit: fix the changeset above, or skip this check with 'git commit --no-verify'."
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "22.4.
|
|
3
|
+
"version": "22.4.1",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -90,13 +90,33 @@ export function packagedItemAddress(pkg: string, subType: string, shortcode: str
|
|
|
90
90
|
* @returns {string} The catalogue key.
|
|
91
91
|
*/
|
|
92
92
|
export function catalogueKey(subType: string, shortcode: string, pkg?: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* An item's own shortcode, read off its compiled document.
|
|
95
|
+
*
|
|
96
|
+
* **`system.shortcode`**, where a system's data model declares such a field.
|
|
97
|
+
* Where it does not — HM3's has no such field — the handle instead lives in
|
|
98
|
+
* that system's own flag namespace, `flags.<systemId>.shortcode`: a system
|
|
99
|
+
* writes its per-document handle into its own flags and never another
|
|
100
|
+
* system's, so a document extracted from one system's catalogue is read
|
|
101
|
+
* through that system's namespace and no other. `system.shortcode` wins where
|
|
102
|
+
* both are present.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} doc - A compiled Item document, or an embedded item merged
|
|
105
|
+
* from one.
|
|
106
|
+
* @param {string|null} [systemId] - The system whose catalogue `doc` was read
|
|
107
|
+
* from. Omitted or `null`, only `system.shortcode` is read.
|
|
108
|
+
* @returns {string|undefined} The shortcode, or `undefined` when the document
|
|
109
|
+
* states neither.
|
|
110
|
+
*/
|
|
111
|
+
export function shortcodeOf(doc: object, systemId?: string | null): string | undefined;
|
|
93
112
|
/**
|
|
94
113
|
* What identifies one embedded item on its actor.
|
|
95
114
|
*
|
|
96
|
-
* **Its own
|
|
97
|
-
* merely *selects* the catalogue template the
|
|
98
|
-
* never written to the document. Two daggers
|
|
99
|
-
* embodiments and each must declare its
|
|
115
|
+
* **Its own shortcode** — read by {@link shortcodeOf} — not the entry's
|
|
116
|
+
* top-level `shortcode`, which merely *selects* the catalogue template the
|
|
117
|
+
* entry is written from and is never written to the document. Two daggers
|
|
118
|
+
* may share a selector; they are two embodiments and each must declare its
|
|
119
|
+
* own.
|
|
100
120
|
*
|
|
101
121
|
* The name is a last resort, for a **stand-alone** entry that names no template
|
|
102
122
|
* and states no shortcode. It is a poor identity — presentation, and free to be
|
|
@@ -105,9 +125,12 @@ export function catalogueKey(subType: string, shortcode: string, pkg?: string):
|
|
|
105
125
|
* message says to state a `system.shortcode`.
|
|
106
126
|
*
|
|
107
127
|
* @param {object} item - The merged embedded item.
|
|
128
|
+
* @param {string|null} [systemId] - The system this item's document was
|
|
129
|
+
* compiled or extracted for, so a document whose own data model carries no
|
|
130
|
+
* `system.shortcode` field is still read by its own flag namespace.
|
|
108
131
|
* @returns {string} The identity, for {@link embeddedItemId}.
|
|
109
132
|
*/
|
|
110
|
-
export function embeddedIdentity(item: object): string;
|
|
133
|
+
export function embeddedIdentity(item: object, systemId?: string | null): string;
|
|
111
134
|
/**
|
|
112
135
|
* The `_id` of one item embedded on an actor.
|
|
113
136
|
*
|
|
@@ -135,9 +158,9 @@ export function embeddedItemId(actorId: string, subType: string, identity: strin
|
|
|
135
158
|
/**
|
|
136
159
|
* Load every JSON file under each of `itemsSourceDirs`, returning one Map keyed
|
|
137
160
|
* by {@link itemAddress} — the compiled document's **subtype** and its
|
|
138
|
-
*
|
|
139
|
-
* The `_key` field is stripped from each entry — it is
|
|
140
|
-
* data model.
|
|
161
|
+
* shortcode, read by {@link shortcodeOf}. Folder docs and entries without a
|
|
162
|
+
* shortcode are skipped. The `_key` field is stripped from each entry — it is
|
|
163
|
+
* not part of the item data model.
|
|
141
164
|
*
|
|
142
165
|
* The directories are read as one address space, because an actor names an item
|
|
143
166
|
* by `(type, shortcode)` and never by the pack it happens to ship in. Two local
|
|
@@ -151,12 +174,22 @@ export function embeddedItemId(actorId: string, subType: string, identity: strin
|
|
|
151
174
|
* colliding with it. Local directories are therefore read first, and anything
|
|
152
175
|
* already claimed is left alone.
|
|
153
176
|
*
|
|
177
|
+
* **Each foreign directory reads its own flag namespace.** A foreign entry's
|
|
178
|
+
* `package` is the system whose catalogue it was extracted from, and that is
|
|
179
|
+
* the only namespace {@link shortcodeOf} is asked to fall back to for it — a
|
|
180
|
+
* document carrying another system's flag, sitting in this system's catalogue,
|
|
181
|
+
* is exactly the defect a system writing outside its own namespace produces,
|
|
182
|
+
* and is silently skipped rather than resolved.
|
|
183
|
+
*
|
|
154
184
|
* @param {readonly string[]} itemsSourceDirs - Every local Item pack's JSON tree.
|
|
155
185
|
* @param {readonly string[]} [foreignSourceDirs] - Extracted dependency
|
|
156
186
|
* catalogues, consulted only for addresses no local pack defines.
|
|
187
|
+
* @param {string|null} [system] - The system `itemsSourceDirs` were compiled
|
|
188
|
+
* for, so a local document whose data model carries no `system.shortcode`
|
|
189
|
+
* field is still read by its own flag namespace.
|
|
157
190
|
* @returns {Map<string, object>} The predefined items, by address.
|
|
158
191
|
*/
|
|
159
|
-
export function loadItemsMap(itemsSourceDirs: readonly string[], foreignSourceDirs?: readonly string[]): Map<string, object>;
|
|
192
|
+
export function loadItemsMap(itemsSourceDirs: readonly string[], foreignSourceDirs?: readonly string[], system?: string | null): Map<string, object>;
|
|
160
193
|
/**
|
|
161
194
|
* The Actor compile pass of one game system.
|
|
162
195
|
*
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lint one pending changeset (`.changeset/*.md`).
|
|
3
|
+
*
|
|
4
|
+
* @param {string} text - The file's full contents, frontmatter included.
|
|
5
|
+
* @returns {{findings: Array<{line: number, column?: number,
|
|
6
|
+
* severity: "error"|"warning", message: string}>}}
|
|
7
|
+
*/
|
|
8
|
+
export function lintChangesetText(text: string): {
|
|
9
|
+
findings: Array<{
|
|
10
|
+
line: number;
|
|
11
|
+
column?: number;
|
|
12
|
+
severity: "error" | "warning";
|
|
13
|
+
message: string;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Lint the first `## <version>` release section of a `CHANGELOG.md`.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text - The changelog's full contents.
|
|
20
|
+
* @returns {{findings: Array<{line?: number, column?: number,
|
|
21
|
+
* severity: "error"|"warning", message: string}>}}
|
|
22
|
+
*/
|
|
23
|
+
export function lintReleaseText(text: string): {
|
|
24
|
+
findings: Array<{
|
|
25
|
+
line?: number;
|
|
26
|
+
column?: number;
|
|
27
|
+
severity: "error" | "warning";
|
|
28
|
+
message: string;
|
|
29
|
+
}>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The finding one rule reports, before its line is mapped into the caller's
|
|
33
|
+
* file.
|
|
34
|
+
*/
|
|
35
|
+
export type RelativeFinding = {
|
|
36
|
+
/**
|
|
37
|
+
* - 1-based line within the section text.
|
|
38
|
+
*/
|
|
39
|
+
line: number;
|
|
40
|
+
/**
|
|
41
|
+
* - 1-based column, dropped when not meaningful.
|
|
42
|
+
*/
|
|
43
|
+
column?: number | undefined;
|
|
44
|
+
severity: "error" | "warning";
|
|
45
|
+
/**
|
|
46
|
+
* - Prefixed `changelog-check/<class> `, so a
|
|
47
|
+
* finding names the rule it tripped as well as what to write instead.
|
|
48
|
+
*/
|
|
49
|
+
message: string;
|
|
50
|
+
};
|