@heroiclands/package-build 5.0.0 → 6.1.0

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.
@@ -45,6 +45,7 @@
45
45
  * npx content-build manifest [root] [--out <dir>]
46
46
  * npx content-build site [--out <dir>]
47
47
  * npx content-build reachability <dir> [file] [--index <shortcode>]
48
+ * npx content-build addresses diff --from <zip|dir> [--strict]
48
49
  *
49
50
  * In a consuming repository, wrapped as npm scripts — SoHL spells them:
50
51
  * npm run build:compiledb // → … package compile (all packs)
@@ -92,6 +93,14 @@ import {
92
93
  walkReachability,
93
94
  } from "../engine/content-links.mjs";
94
95
  import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
96
+ import {
97
+ readItemAddresses,
98
+ diffItemAddresses,
99
+ noteFilesById,
100
+ locateAddressFinding,
101
+ addressFindingMessage,
102
+ } from "../engine/address-diff.mjs";
103
+ import { itemPackJsonDirs } from "../engine/generate.mjs";
95
104
  import {
96
105
  formatUnaddressableFinding,
97
106
  unaddressableForeignPackages,
@@ -158,6 +167,7 @@ const argv = yargs(hideBin(process.argv))
158
167
  .command(manifestCommand())
159
168
  .command(siteCommand())
160
169
  .command(reachabilityCommand())
170
+ .command(addressesCommand())
161
171
  .version(ownVersion())
162
172
  .help()
163
173
  .alias("help", "h")
@@ -340,7 +350,11 @@ function lintCommand() {
340
350
  const root = argv.root ?? config.paths.content;
341
351
  const manifestDir = argv.manifests ?? config.paths.manifests;
342
352
 
343
- const addresses = lintContentTree(root);
353
+ // The package is passed for the homepage rule (#52), which
354
+ // names the address a tree with no front page fails to serve.
355
+ const addresses = lintContentTree(root, {
356
+ contentPackage: config.contentPackage,
357
+ });
344
358
  // One index, built once, for the reference check. It is the
345
359
  // same resolver the wikilink audit uses, so a frontmatter
346
360
  // reference and a body link answer the same way.
@@ -366,7 +380,8 @@ function lintCommand() {
366
380
  } else {
367
381
  log.info(
368
382
  `Addresses and frontmatter are well-formed ` +
369
- `(${addresses.keys} across ${addresses.notes} note(s)).`,
383
+ `(${addresses.keys} address(es) across ` +
384
+ `${addresses.notes} note(s)).`,
370
385
  );
371
386
  }
372
387
  } catch (err) {
@@ -582,6 +597,7 @@ function linksCommand() {
582
597
  deadAnchors,
583
598
  deadAddresses,
584
599
  frontmatterLinks,
600
+ homepageLinks,
585
601
  usedManifest,
586
602
  } = auditLinks(index);
587
603
 
@@ -614,10 +630,24 @@ function linksCommand() {
614
630
  });
615
631
  }
616
632
 
633
+ // The package homepage. Its addresses are markdown links and
634
+ // `landing:` url/href fields rather than wikilinks — it is
635
+ // published verbatim, so nothing resolves a wikilink on it —
636
+ // and until #54 nothing looked at them at all.
637
+ for (const h of homepageLinks) {
638
+ emitDiagnostic({
639
+ file: h.note.file,
640
+ ...positionOfLiteral(h.note.raw, h.text, h.occurrence),
641
+ severity: "error",
642
+ message: `${h.field}: ${h.message}`,
643
+ });
644
+ }
645
+
617
646
  const failures =
618
647
  deadAnchors.length +
619
648
  deadAddresses.length +
620
- frontmatterLinks.length;
649
+ frontmatterLinks.length +
650
+ homepageLinks.length;
621
651
  if (failures) {
622
652
  log.error(
623
653
  `${failures} link problem(s) across ${index.notes.length} note(s).`,
@@ -628,7 +658,8 @@ function linksCommand() {
628
658
  `${index.notes.length} notes: every anchor link lands ` +
629
659
  `and every qualified address resolves ` +
630
660
  `(${usedManifest.size} cross-package reference(s) ` +
631
- `via manifest), no wikilink in frontmatter.`,
661
+ `via manifest), no wikilink in frontmatter, ` +
662
+ `every homepage address resolvable.`,
632
663
  );
633
664
  }
634
665
  } catch (err) {
@@ -750,6 +781,11 @@ function siteCommand() {
750
781
  });
751
782
  const { gates } = result;
752
783
 
784
+ // First, because it is decided before the tree is walked and
785
+ // before the output is cleared: a package with no front page,
786
+ // or two competing for it, has nothing to say about its pages
787
+ // yet (#52).
788
+ for (const f of gates.homepages) emitDiagnostic(f);
753
789
  for (const f of gates.frontmatterLinks) {
754
790
  emitDiagnostic({
755
791
  file: f.file,
@@ -1023,6 +1059,154 @@ function depsCommand() {
1023
1059
  };
1024
1060
  }
1025
1061
 
1062
+ /**
1063
+ * `addresses diff` — report every published `(type, shortcode)` this build no
1064
+ * longer publishes, against a released artifact.
1065
+ *
1066
+ * The address space is a published interface (see `engine/address-diff.mjs`),
1067
+ * and renaming a shortcode used to cost nothing and produce no signal. This is
1068
+ * the signal, emitted in the repository doing the renaming while the change is
1069
+ * still in front of the author.
1070
+ *
1071
+ * **Its own command rather than a step of `package compile`.** It reads a
1072
+ * *second* artifact that the compile knows nothing about and that has to be
1073
+ * obtained separately, and it is a question about a release rather than about a
1074
+ * build — a repository between releases has nothing to compare against.
1075
+ *
1076
+ * **The baseline is named, never derived, and never downloaded.** `--from`
1077
+ * takes the artifact — the `.zip` a release publishes, or a directory built
1078
+ * from one — for the same reason `deps fetch --from` does: a command that
1079
+ * reaches the network on its own is not reproducible and fails strangely
1080
+ * offline. In a release workflow the artifact is one line ahead of it:
1081
+ *
1082
+ * ```sh
1083
+ * gh release download v0.8.2 -p system.zip -D build/baseline
1084
+ * npx content-build addresses diff --from build/baseline/system.zip
1085
+ * ```
1086
+ *
1087
+ * @param {object} config - The resolved build configuration.
1088
+ * @param {{from: string, strict?: boolean}} argv - The parsed arguments.
1089
+ * @returns {Promise<void>}
1090
+ */
1091
+ async function diffAddresses(config, argv) {
1092
+ // The baseline is this package's own earlier self, so it is cached beside
1093
+ // the dependency catalogues rather than among them — a release of `sohl`
1094
+ // is not a dependency of `sohl`, and filing it as one would collide with a
1095
+ // genuine relationship of the same id.
1096
+ const cacheConfig = {
1097
+ ...config,
1098
+ paths: {
1099
+ ...config.paths,
1100
+ foreignCache: path.join(
1101
+ path.dirname(config.paths.foreignCache),
1102
+ "baseline",
1103
+ ),
1104
+ },
1105
+ };
1106
+ const dir = await fetchCatalogFromPath(
1107
+ cacheConfig,
1108
+ { id: config.foundryPackage },
1109
+ argv.from,
1110
+ );
1111
+ // `<id>@<version>`, which is what the diagnostics name the baseline by.
1112
+ const label = path.basename(dir);
1113
+
1114
+ const itemsRoot = path.join(dir, "items");
1115
+ const baselineDirs = fs
1116
+ .readdirSync(itemsRoot)
1117
+ .map((name) => path.join(itemsRoot, name));
1118
+ const currentDirs = itemPackJsonDirs(config);
1119
+ if (!currentDirs.length) {
1120
+ throw new Error(
1121
+ 'this repository declares no pack of type "Item", so it ' +
1122
+ "publishes no item addresses to diff",
1123
+ );
1124
+ }
1125
+
1126
+ const findings = diffItemAddresses(
1127
+ readItemAddresses(baselineDirs),
1128
+ readItemAddresses(currentDirs),
1129
+ { baseline: label },
1130
+ );
1131
+ if (!findings.length) {
1132
+ log.info(`Every address ${label} published is still published.`);
1133
+ return;
1134
+ }
1135
+
1136
+ // A rename is fixed in the note that made it, so findings are placed
1137
+ // against the tree rather than against the compiled output they were read
1138
+ // from.
1139
+ const noteFiles = noteFilesById(config.paths.content);
1140
+ const severity = argv.strict ? "error" : "warning";
1141
+ for (const finding of findings) {
1142
+ emitDiagnostic({
1143
+ ...locateAddressFinding(finding, noteFiles),
1144
+ severity,
1145
+ message: addressFindingMessage(finding),
1146
+ });
1147
+ }
1148
+ const renamed = findings.filter((f) => f.kind === "renamed").length;
1149
+ log.info(
1150
+ `${findings.length} address(es) ${label} published are no longer ` +
1151
+ `published (${renamed} renamed, ${findings.length - renamed} ` +
1152
+ `withdrawn).`,
1153
+ );
1154
+ // Retiring content is legitimate and so is renaming; neither fails a build
1155
+ // unless the caller asked for a gate.
1156
+ if (argv.strict) process.exitCode = 1;
1157
+ }
1158
+
1159
+ function addressesCommand() {
1160
+ return {
1161
+ command: "addresses <action>",
1162
+ describe:
1163
+ "Compare the addresses this build publishes against a release's",
1164
+ builder: (yargs) => {
1165
+ // Required, for the reason every other action is (#57): an
1166
+ // optional one exits 0 having compared nothing.
1167
+ yargs.positional("action", {
1168
+ describe: "The action to perform.",
1169
+ type: "string",
1170
+ choices: ["diff"],
1171
+ });
1172
+ yargs.option("from", {
1173
+ describe:
1174
+ "The released artifact to compare against — a package " +
1175
+ "zip, or the directory it was built from.",
1176
+ type: "string",
1177
+ });
1178
+ yargs.option("strict", {
1179
+ describe:
1180
+ "Report findings as errors and exit non-zero, for a " +
1181
+ "release workflow that gates on them.",
1182
+ type: "boolean",
1183
+ default: false,
1184
+ });
1185
+ // Checked while parsing, so it fails in a repository whose
1186
+ // configuration a handler would never get far enough to resolve.
1187
+ yargs.check((parsed) => {
1188
+ if (parsed.action === "diff" && !parsed.from) {
1189
+ throw new Error(
1190
+ "`addresses diff` needs `--from <zip|dir>`: the " +
1191
+ "release to compare against. There is nothing to " +
1192
+ "derive it from — a repository between releases " +
1193
+ "has no previous artifact on disk.",
1194
+ );
1195
+ }
1196
+ return true;
1197
+ });
1198
+ },
1199
+ handler: async (argv) => {
1200
+ try {
1201
+ await diffAddresses(loadPackConfig(), argv);
1202
+ } catch (err) {
1203
+ log.error(err.message);
1204
+ process.exitCode = 1;
1205
+ }
1206
+ },
1207
+ };
1208
+ }
1209
+
1026
1210
  function packageCommand() {
1027
1211
  return {
1028
1212
  command: "package <action> [pack] [entry]",
@@ -78,7 +78,7 @@ import yargs from "yargs";
78
78
  import { hideBin } from "yargs/helpers";
79
79
 
80
80
  import { loadPackageBuildConfig } from "../config.mjs";
81
- import { loadPackConfig } from "../engine/pack-config.mjs";
81
+ import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
82
82
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
83
83
  import { validateLangSource } from "../lang.mjs";
84
84
  import {
@@ -354,6 +354,8 @@ function manifestCommand() {
354
354
  artifact: config.artifact,
355
355
  outDir: path.join(config.rootDir, config.stageDir),
356
356
  flags,
357
+ // So a `packFolders` finding names the line it is about.
358
+ configFile: packConfigPath(),
357
359
  });
358
360
  console.log(
359
361
  `✅ Wrote ${path.relative(config.rootDir, written)} ` +
@@ -602,7 +602,7 @@ const SITE_KEYS = [
602
602
  "backfillSections",
603
603
  ];
604
604
  const SITE_TREE_KEYS = ["from", "section"];
605
- const SECTION_META_KEYS = ["title", "banner"];
605
+ const SECTION_META_KEYS = ["title", "banner", "description"];
606
606
  const DOC_PAGE_KEYS = ["title", "out", "preamble"];
607
607
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
608
608
  const RELATIONSHIP_KEYS = [
@@ -973,16 +973,32 @@ function normalizeDocs(value) {
973
973
  }
974
974
 
975
975
  /**
976
- * One section's landing metadata — the title and hero a generated `_index.md`
977
- * carries.
978
- *
979
- * `banner` is optional because the hero images are external assets and not
980
- * every section has one. It is left off entirely rather than written as
976
+ * One section's landing metadata — what a section says about itself on the
977
+ * `_index.md` this build generates for it.
978
+ *
979
+ * A generated landing is the *only* place a section can speak: a content
980
+ * package has no authored `_index.md` for `weapongear` or `affliction`, so the
981
+ * file the theme reads is the one the site build writes. This is therefore the
982
+ * whole vocabulary, and it is deliberately a **closed** one.
983
+ *
984
+ * The alternative — passing whatever a section declared straight through, as
985
+ * `site.landing` does — was weighed and refused. `landing` is written once, for
986
+ * the mount, and its keys are one landing template's own; a section entry is
987
+ * written fourteen to twenty times per build against a contract every package
988
+ * and every section shares. Unbounded there, a mistyped `descrption:` publishes
989
+ * into front matter, is read by nobody, and says nothing to anyone — which is
990
+ * the failure #91 was filed about, moved one step downstream where no build can
991
+ * see it. So the keys are named here, and the writers emit what this produced
992
+ * rather than transcribing a second list of their own (#91).
993
+ *
994
+ * `banner` and `description` are optional — the hero images are external assets
995
+ * and not every section has one, and a section may reasonably have nothing to
996
+ * add to its title. Each is left off entirely rather than written as
981
997
  * `undefined`, which is not a value YAML can carry.
982
998
  *
983
999
  * @param {unknown} value - The declared entry.
984
1000
  * @param {string} where - Dotted path, for the error.
985
- * @returns {Readonly<{title: string, banner?: string}>}
1001
+ * @returns {Readonly<{title: string, banner?: string, description?: string}>}
986
1002
  */
987
1003
  function normalizeSectionMeta(value, where) {
988
1004
  if (!isPlainObject(value)) fail(where, "must be a mapping");
@@ -992,6 +1008,12 @@ function normalizeSectionMeta(value, where) {
992
1008
  if (input.banner !== undefined) {
993
1009
  out.banner = requireNonEmptyString(input.banner, `${where}.banner`);
994
1010
  }
1011
+ if (input.description !== undefined) {
1012
+ out.description = requireNonEmptyString(
1013
+ input.description,
1014
+ `${where}.description`,
1015
+ );
1016
+ }
995
1017
  return Object.freeze(out);
996
1018
  }
997
1019
 
@@ -0,0 +1,290 @@
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
+ * Diffing a package's published item addresses against a released one (#66).
16
+ *
17
+ * A package's `(type, shortcode)` addresses are a **published interface**.
18
+ * Every satellite that declares `itemCatalog: true` assembles its beings out of
19
+ * them — `attribute:str`, `skill:awar`, `weapongear:Tabri` — resolving each one
20
+ * against the Item packs of the release its `compatibility.verified` pins. So
21
+ * renaming a shortcode is a breaking change to something other repositories
22
+ * consume, and until this module there was nothing that noticed: the check that
23
+ * got made was a repository-local grep, which cannot see the other
24
+ * repositories and reports the reassuring answer.
25
+ *
26
+ * `sohl` renamed one weapon's shortcode from `Tabri` to `Taburi` two days after
27
+ * the `v0.8.2` tag, on the stated ground that "nothing referenced the old
28
+ * value, so the rename is self-contained". True of that repository. Both
29
+ * satellites pin `v0.8.2` and address `weapongear:Tabri` on their copy of the
30
+ * same character — five lookups that resolve today and fail the moment either
31
+ * pin moves, with an error reading like a missing item.
32
+ *
33
+ * **The comparison is release-to-release, in the repository doing the
34
+ * renaming.** The alternative — checking a consumer's addresses against its
35
+ * pinned release — already exists and already fails the build (`no predefined
36
+ * item for "weapongear:Taburi"`); what it lacks is an explanation, and it
37
+ * cannot honestly produce one, because at the point of the miss all it holds is
38
+ * the address string. It has no document id and no name to match a candidate
39
+ * against, so any successor it named would be a guess at a similar-looking
40
+ * string. Here both sides are whole documents, so the question is decidable.
41
+ *
42
+ * **A rename is told from a removal by the document id, and that is an identity
43
+ * match rather than an inference.** A note authors its `_id` in frontmatter; it
44
+ * is not derived from the shortcode, and the `Tabri` → `Taburi` commit changed
45
+ * the shortcode alone. So an address that disappeared while its document is
46
+ * still published elsewhere *is* a rename — not "probably" one. When the id is
47
+ * published under no address at all, that is all this can say: **withdrawn**,
48
+ * with no successor named. A split, a deletion and a merge are indistinguish-
49
+ * able from one another at that point, and inventing a "did you mean" from
50
+ * string similarity would be worse than saying nothing, because a wrong one
51
+ * sends the reader to the wrong fix.
52
+ *
53
+ * **Severity is decided per case.** A withdrawal is legitimate — content is
54
+ * allowed to be retired — so it is reported and does not fail a build. A rename
55
+ * is equally legitimate as a decision (#1397's charset rule forces some), which
56
+ * is why it does not fail one either; what it must not do is happen in silence.
57
+ * A caller that wants a gate passes `error` and treats any finding as one.
58
+ *
59
+ * Item packs only, because that is the address space consumers resolve
60
+ * against: {@link foreignItemCatalogDirs} extracts nothing else, and a being's
61
+ * embedded items are the only cross-package resolution by `(type, shortcode)`.
62
+ *
63
+ * @module
64
+ */
65
+
66
+ import fs from "node:fs";
67
+ import path from "node:path";
68
+
69
+ import { formatDiagnostic, positionInFrontmatter } from "./diagnostics.mjs";
70
+ import { positionOfLiteral } from "./diagnostics.mjs";
71
+ import { walkMarkdownTree } from "./helpers.mjs";
72
+
73
+ /**
74
+ * The address space a set of compiled Item pack directories publishes.
75
+ *
76
+ * The directories are read as one space for the same reason the actors pass
77
+ * reads them as one: a being names an item by `(type, shortcode)` and never by
78
+ * the pack it happens to ship in. Both sides of a diff are built by this one
79
+ * function, so a released catalogue extracted by `deps fetch` and a freshly
80
+ * compiled pack are indexed identically and a difference between them is a real
81
+ * one rather than an artefact of two readers.
82
+ *
83
+ * A missing directory throws rather than reading as an empty space: an empty
84
+ * baseline would report every address in the package as withdrawn, and an empty
85
+ * current side would report every address as gone — the loudest possible
86
+ * output from the quietest possible mistake.
87
+ *
88
+ * @param {readonly string[]} dirs - Directories of item JSON.
89
+ * @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
90
+ * Every item, keyed `type:shortcode`.
91
+ */
92
+ export function readItemAddresses(dirs) {
93
+ const space = new Map();
94
+ for (const dir of dirs) {
95
+ if (!fs.existsSync(dir)) {
96
+ throw new Error(
97
+ `Item source directory ${dir} does not exist — an address ` +
98
+ `diff reads compiled Item pack output, so those packs ` +
99
+ `must be compiled (or the catalogue fetched) first`,
100
+ );
101
+ }
102
+ for (const name of fs.readdirSync(dir)) {
103
+ if (!name.endsWith(".json")) continue;
104
+ if (name.startsWith("folder_")) continue;
105
+ const file = path.join(dir, name);
106
+ let doc;
107
+ try {
108
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
109
+ } catch {
110
+ // Unparseable output is the compile's problem to report, not
111
+ // this pass's; skipping it here loses one address rather than
112
+ // failing a diff that has nothing to do with it.
113
+ continue;
114
+ }
115
+ const shortcode = doc?.system?.shortcode;
116
+ if (!doc?.type || !shortcode || !doc?._id) continue;
117
+ space.set(`${doc.type}:${shortcode}`, {
118
+ id: doc._id,
119
+ name: doc.name ?? "",
120
+ type: doc.type,
121
+ shortcode,
122
+ file,
123
+ });
124
+ }
125
+ }
126
+ return space;
127
+ }
128
+
129
+ /**
130
+ * Every address the baseline published that this build does not.
131
+ *
132
+ * An address that merely *arrived* is not a finding: adding one breaks nobody.
133
+ * The arrivals are read only to answer the one question that matters about a
134
+ * departure — is the document still here under another name?
135
+ *
136
+ * @param {Map<string, object>} baseline - The released address space.
137
+ * @param {Map<string, object>} current - This build's address space.
138
+ * @param {object} opts
139
+ * @param {string} opts.baseline - What the baseline is, for the message —
140
+ * conventionally `<package>@<version>`.
141
+ * @returns {Array<object>} One finding per departed address, in address order
142
+ * so two runs read the same. `kind` is `"renamed"` (with `to`) or
143
+ * `"withdrawn"`.
144
+ */
145
+ export function diffItemAddresses(baseline, current, { baseline: label }) {
146
+ // A baseline that yields no address at all cannot produce a finding, so it
147
+ // reports a clean result for every possible input — the one failure a check
148
+ // like this can never catch, and the same one `foreign-manifests.mjs` exists
149
+ // to stop. It is a real state, not a hypothetical: `sohl-kethira-basic@0.5.3`
150
+ // shipped 307 items carrying no `system.shortcode` between them.
151
+ if (!baseline.size) {
152
+ throw new Error(
153
+ `${label} publishes no addressable item — no document in its Item ` +
154
+ `packs carries a \`system.shortcode\`. A diff against it can ` +
155
+ `only report that nothing changed, whatever this build does, ` +
156
+ `so it is refused rather than passed`,
157
+ );
158
+ }
159
+ // Where each still-published document lives now. Built once: a rename is
160
+ // decided by identity, so this is the whole evidence base.
161
+ const currentById = new Map();
162
+ for (const [address, entry] of current) {
163
+ if (!currentById.has(entry.id)) currentById.set(entry.id, address);
164
+ }
165
+
166
+ const findings = [];
167
+ for (const [address, entry] of baseline) {
168
+ if (current.has(address)) continue;
169
+ const to = currentById.get(entry.id);
170
+ findings.push({
171
+ kind: to ? "renamed" : "withdrawn",
172
+ address,
173
+ ...(to ? { to } : {}),
174
+ id: entry.id,
175
+ name: entry.name,
176
+ shortcode: entry.shortcode,
177
+ baselineFile: entry.file,
178
+ baseline: label,
179
+ });
180
+ }
181
+ findings.sort((a, b) => (a.address < b.address ? -1 : 1));
182
+ return findings;
183
+ }
184
+
185
+ /**
186
+ * Every content note in a tree, indexed by the document id it authors.
187
+ *
188
+ * The address space is read from compiled output because that is what actually
189
+ * ships; the tree is read only to place a finding somewhere a reader can open
190
+ * and fix it. Each source answers the question it is good at, and the id is the
191
+ * exact key that joins them.
192
+ *
193
+ * @param {string} contentBase - Root of the content tree.
194
+ * @param {object} [opts]
195
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
196
+ * @returns {Map<string, string>} Document id → the note's absolute path.
197
+ */
198
+ export function noteFilesById(contentBase, { skipDirectories } = {}) {
199
+ const byId = new Map();
200
+ const walkOpts = skipDirectories ? { skipDirectories } : undefined;
201
+ for (const { frontmatter: fm, absPath } of walkMarkdownTree(
202
+ contentBase,
203
+ walkOpts,
204
+ )) {
205
+ if (fm?.id && !byId.has(fm.id)) byId.set(fm.id, absPath);
206
+ }
207
+ return byId;
208
+ }
209
+
210
+ /**
211
+ * Where to send the reader for one finding.
212
+ *
213
+ * A rename is fixed in the note that made it, so a finding whose id is still in
214
+ * this tree is reported at that note's `shortcode:` line — the line the author
215
+ * just edited. A withdrawal has no such note by definition, so it degrades to
216
+ * the baseline document, which is the only artefact left that records the
217
+ * address existing. When neither is readable the position is **dropped**, never
218
+ * defaulted to `1:1`.
219
+ *
220
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
221
+ * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
222
+ * @returns {{file?: string, line?: number, column?: number}} Spreadable
223
+ * position fields for {@link formatDiagnostic}.
224
+ */
225
+ export function locateAddressFinding(finding, noteFiles) {
226
+ const note = noteFiles?.get(finding.id);
227
+ if (note) {
228
+ try {
229
+ const raw = fs.readFileSync(note, "utf8");
230
+ return { file: note, ...positionInFrontmatter(raw, "shortcode") };
231
+ } catch {
232
+ return { file: note };
233
+ }
234
+ }
235
+ if (!finding.baselineFile) return {};
236
+ try {
237
+ const raw = fs.readFileSync(finding.baselineFile, "utf8");
238
+ return {
239
+ file: finding.baselineFile,
240
+ ...positionOfLiteral(raw, `"${finding.shortcode}"`),
241
+ };
242
+ } catch {
243
+ return { file: finding.baselineFile };
244
+ }
245
+ }
246
+
247
+ /**
248
+ * What one finding says, without a locator or a severity.
249
+ *
250
+ * The rename message names the identity it matched on, because that is what
251
+ * separates this from a spelling suggestion: the reader can check the id in
252
+ * both artefacts. The withdrawal message names no successor, because none is
253
+ * known — and says so, rather than leaving the reader to wonder whether one was
254
+ * looked for.
255
+ *
256
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
257
+ * @returns {string} The message.
258
+ */
259
+ export function addressFindingMessage(finding) {
260
+ if (finding.kind === "renamed") {
261
+ return (
262
+ `since ${finding.baseline}, ${finding.address} is no longer ` +
263
+ `published; the same document (${finding.id}) is now published ` +
264
+ `as ${finding.to}. Every package that resolves ${finding.address} ` +
265
+ `breaks when it moves past ${finding.baseline}`
266
+ );
267
+ }
268
+ return (
269
+ `since ${finding.baseline}, ${finding.address} is no longer ` +
270
+ `published, and its document (${finding.id}) is published under no ` +
271
+ `other address`
272
+ );
273
+ }
274
+
275
+ /**
276
+ * One finding, in the standard `file:line:column: severity: message` form.
277
+ *
278
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
279
+ * @param {{file?: string, line?: number, column?: number}} at - From
280
+ * {@link locateAddressFinding}.
281
+ * @param {"warning"|"error"} [severity] - `error` when the caller is gating.
282
+ * @returns {string} The formatted diagnostic, path first on the line.
283
+ */
284
+ export function formatAddressFinding(finding, at, severity = "warning") {
285
+ return formatDiagnostic({
286
+ ...at,
287
+ severity,
288
+ message: addressFindingMessage(finding),
289
+ });
290
+ }
@@ -34,9 +34,11 @@
34
34
  * | {@link BasePackCompiler#finish} | Work that needs every note first. |
35
35
  * | {@link BasePackCompiler#reportCompiled} / {@link BasePackCompiler#reportDetail} | The pass's own log lines. |
36
36
  *
37
- * plus two static switches — `requiresId` (a note with no id is fatal, or
38
- * merely skipped) and `convertsWikilinks` (whether the body reaching
39
- * `buildEntry` is converted or exactly as authored).
37
+ * plus three static switches — `requiresId` (a note with no id is fatal, or
38
+ * merely skipped), `convertsWikilinks` (whether the body reaching
39
+ * `buildEntry` is converted or exactly as authored) and `readsPackOutputOf`
40
+ * (the document types whose compiled output this pass reads, which is what the
41
+ * generator derives the compile order from).
40
42
  *
41
43
  * `selects` answers *which document type* a pass claims, and it is the same
42
44
  * answer for every pack of that type. Which **pack of that type** a claimed
@@ -141,6 +143,29 @@ export class BasePackCompiler {
141
143
  */
142
144
  static convertsWikilinks = true;
143
145
 
146
+ /**
147
+ * The document types whose **compiled output** this pass reads.
148
+ *
149
+ * Empty for every pass that reads only the content tree. The actors pass
150
+ * is the exception: a being names its embedded items by
151
+ * `(type, shortcode)`, and it resolves them against the JSON the item
152
+ * passes wrote — so an Actor pass must run after every Item pass, and it
153
+ * says so here.
154
+ *
155
+ * The generator derives the compile order from this (#73), so the order
156
+ * `packs:` declares is presentation only — it is the manifest's `packs`
157
+ * array as well, and a consumer orders that for a reader. A pass that
158
+ * reads another's output states the dependency once, in the class that
159
+ * does the reading, instead of every consuming repository having to know
160
+ * it when writing its pack list.
161
+ *
162
+ * A consumer registering a compiler of its own declares its dependencies
163
+ * the same way; a type no pack declares is simply not waited for.
164
+ *
165
+ * @type {readonly string[]}
166
+ */
167
+ static readsPackOutputOf = Object.freeze([]);
168
+
144
169
  /** @type {string} */
145
170
  contentBase;
146
171
  /** @type {string} */