@heroiclands/package-build 17.1.0 → 18.0.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -0,0 +1,495 @@
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
+ * The published content index — the artifact packages exchange addresses
16
+ * through (#239).
17
+ *
18
+ * **A package publishes its own index; a consumer fetches the ones it depends
19
+ * on.** That is the whole mechanism, and it replaces a vendored link manifest
20
+ * that each repository committed a copy of every other repository's file into.
21
+ * Vendoring failed three ways, and only the last is about staleness:
22
+ *
23
+ * 1. **A copy goes stale silently.** `Song-of-Heroic-Lands-FoundryVTT` carried
24
+ * 2,101 `thalorna` entries whose address was the old name-derived form, so
25
+ * every cross-package link it rendered pointed at a URL the site had stopped
26
+ * publishing. The format version gate saw nothing, because the format had
27
+ * not changed — only the values were wrong.
28
+ * 2. **Mutual vendoring deadlocks.** Both packages vendored each other, so a
29
+ * format bump stopped both builds until the other had already published:
30
+ * neither could go first.
31
+ * 3. **It was a second answer to a settled question.** The content index
32
+ * already carries the canonical key, the name, the anchors, the Foundry
33
+ * `uuid` and the web address. There was nothing in a manifest entry it did
34
+ * not hold.
35
+ *
36
+ * A fetched artifact cannot drift from its producer, and a consumer reads one
37
+ * the producer has already shipped — so the cycle has no way to form.
38
+ *
39
+ * @module
40
+ */
41
+
42
+ import fs from "node:fs";
43
+ import path from "node:path";
44
+
45
+ import { formatDiagnostic, positionOfLiteral } from "./diagnostics.mjs";
46
+ import { PACKAGE_BASE, readCanonicalKey, resolvePackageUrl } from "./content-address.mjs";
47
+
48
+ /**
49
+ * Written once a fetch completes, so a half-finished cache is never used.
50
+ *
51
+ * The same sentinel the item catalogue uses, and deliberately the same value:
52
+ * the two caches sit side by side under `build/cache`, and one convention read
53
+ * by both is one thing to know.
54
+ */
55
+ const STAMP = ".complete";
56
+
57
+ /**
58
+ * The relationship kinds that are dependencies, and therefore citable.
59
+ *
60
+ * A package may cite what it depends on and nothing else. `recommends` and
61
+ * `conflicts` are declarations *about* other packages rather than dependencies
62
+ * on them, so an address resolved through one would emit a link into a package
63
+ * the consumer does not require — a defect in the citing note, not a lookup to
64
+ * satisfy.
65
+ *
66
+ * @type {readonly string[]}
67
+ */
68
+ export const METADATA_RELATIONSHIP_KINDS = Object.freeze(["systems", "requires"]);
69
+
70
+ /**
71
+ * What a package's content index is called, wherever it is written or fetched.
72
+ *
73
+ * **The local index and the published artifact are one file.** A package emits
74
+ * this, ships it as a release asset, and advertises it as `flags.metadataUrl`;
75
+ * a consumer fetches that same file into its cache and reads it. Naming it in
76
+ * one function is what keeps the emitter, the release and the fetcher from
77
+ * drifting into three spellings of one artifact.
78
+ *
79
+ * The `-metadata` suffix earns its place: a bare `<package>.jsonl` says nothing
80
+ * about what it holds, and these files land in a cache directory beside other
81
+ * packages' artifacts where the name is all a reader has.
82
+ *
83
+ * @param {string} pkg - The content package name.
84
+ * @returns {string} The file name, e.g. `sohl-metadata.jsonl`.
85
+ */
86
+ export function metadataFileName(pkg) {
87
+ return `${pkg}-metadata.jsonl`;
88
+ }
89
+
90
+ /**
91
+ * Every dependency whose published index this build resolves addresses through.
92
+ *
93
+ * **Every declared dependency, not only those supplying an item catalogue.**
94
+ * `itemCatalog: true` says a dependency supplies *items*; citing its
95
+ * *addresses* is a separate edge, and a package may have either without the
96
+ * other. `harn-ensemble` cites no foreign address and carries 324,016 embedded
97
+ * item references (`HeroicLands/harn-ensemble#42`); a package citing addresses
98
+ * and needing no items is the mirror of it. Gating the index on the catalogue
99
+ * flag would serve neither.
100
+ *
101
+ * The declaration is the one already in the emitted `system.json` /
102
+ * `module.json`, so it cannot drift from what Foundry itself installs, and
103
+ * there is no new configuration key to keep in step. Each entry carries the
104
+ * producer's own `manifest` URL, so the fetcher needs no address of its own:
105
+ * it reads that manifest and takes the `flags.metadataUrl` it advertises.
106
+ *
107
+ * @param {object} config - The resolved build configuration.
108
+ * @returns {Array<{id: string, manifest: string, kind: string,
109
+ * verified: string|undefined}>} The dependencies, in declaration order.
110
+ */
111
+ export function metadataRelationships(config) {
112
+ const out = [];
113
+ for (const kind of METADATA_RELATIONSHIP_KINDS) {
114
+ for (const rel of config?.relationships?.[kind] ?? []) {
115
+ out.push({
116
+ id: rel.id,
117
+ manifest: rel.manifest,
118
+ kind,
119
+ verified: rel.compatibility?.verified,
120
+ });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * The cache directory for one dependency's index at one version.
128
+ *
129
+ * Keyed by version so that changing the pinned version is a different cache
130
+ * rather than a silent overwrite, and so a second build costs nothing — the
131
+ * same rule the item catalogue's cache follows, for the same reason.
132
+ *
133
+ * @param {object} config - The resolved build configuration.
134
+ * @param {string} id - The dependency's package id.
135
+ * @param {string} version - Its resolved version.
136
+ * @returns {string} The directory.
137
+ */
138
+ export function metadataCacheDir(config, id, version) {
139
+ return path.join(config.paths.metadataCache, `${id}@${version}`);
140
+ }
141
+
142
+ /**
143
+ * Whether a dependency's cache is present and complete.
144
+ *
145
+ * @param {string} dir - The dependency's cache directory.
146
+ * @returns {boolean} True when it was fetched to completion.
147
+ */
148
+ export const isComplete = (dir) => fs.existsSync(path.join(dir, STAMP));
149
+
150
+ /**
151
+ * Mark a dependency's cache complete.
152
+ *
153
+ * @param {string} dir - The dependency's cache directory.
154
+ * @returns {void}
155
+ */
156
+ export function markComplete(dir) {
157
+ fs.writeFileSync(path.join(dir, STAMP), "");
158
+ }
159
+
160
+ /**
161
+ * The fetched index files this build resolves foreign addresses against.
162
+ *
163
+ * **Reads the cache only.** A cold cache is an error naming the command that
164
+ * fills it, rather than a download nobody asked for: a compile that reaches the
165
+ * network is not reproducible and fails strangely offline. That is the item
166
+ * catalogue's rule, and it holds here for the same reason.
167
+ *
168
+ * A half-finished fetch counts as cold. A partial index resolves some addresses
169
+ * and fails others with nothing to distinguish the two, which is worse than
170
+ * resolving none — the failure would read as a typo in whichever note happened
171
+ * to cite the missing half.
172
+ *
173
+ * @param {object} config - The resolved build configuration.
174
+ * @returns {string[]} One index file per declared dependency.
175
+ * @throws {Error} When a declared dependency has not been fetched.
176
+ */
177
+ export function cachedMetadataFiles(config) {
178
+ return cachedMetadataIndexes(config).map((entry) => entry.file);
179
+ }
180
+
181
+ /**
182
+ * The same fetched indexes, each paired with the package that published it.
183
+ *
184
+ * The id is what a SQL content table addresses a dependency's notes by
185
+ * (`FROM sohl.notes`, #246), so the pairing has to survive the lookup —
186
+ * {@link cachedMetadataFiles} drops it, and a caller reconstructing the id from
187
+ * the file name would be parsing a path to recover something the declaration
188
+ * already stated.
189
+ *
190
+ * @param {object} config - The resolved build configuration.
191
+ * @returns {Array<{id: string, file: string}>} One entry per declared
192
+ * dependency.
193
+ * @throws {Error} When a declared dependency has not been fetched.
194
+ */
195
+ export function cachedMetadataIndexes(config) {
196
+ const files = [];
197
+ const root = config.paths.metadataCache;
198
+ for (const rel of metadataRelationships(config)) {
199
+ const cached =
200
+ fs.existsSync(root) ?
201
+ fs
202
+ .readdirSync(root)
203
+ .filter((name) => name.startsWith(`${rel.id}@`))
204
+ .map((name) => path.join(root, name))
205
+ .filter(isComplete)
206
+ : [];
207
+ if (!cached.length) {
208
+ throw new Error(
209
+ `${rel.id} is a declared dependency but its content index has ` +
210
+ `not been fetched. Run \`content-build deps fetch\` first.`,
211
+ );
212
+ }
213
+ files.push({ id: rel.id, file: newestIndex(cached) });
214
+ }
215
+ return files;
216
+ }
217
+
218
+ /**
219
+ * The newest cached version among several version-keyed cache directories.
220
+ *
221
+ * Versions are compared **numerically per segment**, not as strings: a plain
222
+ * sort puts `0.8.10` before `0.8.2`, so a build that had cached both would
223
+ * silently resolve against the older one. A fetch always writes the currently
224
+ * declared version, so several present at once means an earlier pin was left
225
+ * behind rather than that a choice is genuinely open.
226
+ *
227
+ * **Both version-keyed caches under `build/cache` choose this way** — the
228
+ * content index here and the item catalogue in
229
+ * {@link module:engine/foreign-catalog} — so the comparison lives in one place
230
+ * rather than being written once per cache. Two copies would be two chances to
231
+ * get it wrong, and the wrong answer is invisible: every cached version is a
232
+ * complete, stamped, perfectly valid artifact, so picking the older one reports
233
+ * nothing and simply resolves against stale data (#272).
234
+ *
235
+ * @param {string[]} dirs - Complete cache directories, named `<id>@<version>`.
236
+ * @returns {string} The newest one.
237
+ */
238
+ export function newestVersionDir(dirs) {
239
+ const rank = (dir) =>
240
+ path
241
+ .basename(dir)
242
+ .split("@")
243
+ .slice(1)
244
+ .join("@")
245
+ .split(/[.-]/)
246
+ .map((part) => (/^\d+$/.test(part) ? Number(part) : part));
247
+ const sorted = [...dirs].sort((a, b) => {
248
+ const x = rank(a);
249
+ const y = rank(b);
250
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
251
+ const p = x[i];
252
+ const q = y[i];
253
+ if (p === q) continue;
254
+ if (p === undefined) return -1;
255
+ if (q === undefined) return 1;
256
+ return typeof p === "number" && typeof q === "number" ?
257
+ p - q
258
+ : String(p).localeCompare(String(q));
259
+ }
260
+ return 0;
261
+ });
262
+ return sorted[sorted.length - 1];
263
+ }
264
+
265
+ /**
266
+ * The index file of the newest cached version among several.
267
+ *
268
+ * @param {string[]} dirs - Complete cache directories, named `<id>@<version>`.
269
+ * @returns {string} The newest one's index file.
270
+ */
271
+ function newestIndex(dirs) {
272
+ const dir = newestVersionDir(dirs);
273
+ const entries = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
274
+ if (!entries.length) {
275
+ throw new Error(`${dir} was fetched but holds no index file`);
276
+ }
277
+ return path.join(dir, entries[0]);
278
+ }
279
+
280
+ /**
281
+ * Resolve every foreign address this build can cite, from the fetched indexes.
282
+ *
283
+ * The replacement for the vendored link manifest, and deliberately the same
284
+ * return shape — a `Map` from canonical address to `{ url, name, uuid, … }` —
285
+ * so a foreign entry and a local one stay interchangeable at the point of use.
286
+ * What changed is where the data comes from: a file the producer published,
287
+ * not a copy a consumer committed.
288
+ *
289
+ * **A package this build publishes is skipped**, however it got into the cache.
290
+ * A build is authoritative in its own addresses, and reading them back from a
291
+ * fetched artifact would let a stale copy overrule the tree that is being
292
+ * compiled right now. It is also what stops a cycle forming: the mutual
293
+ * vendoring this replaces deadlocked because each package had to read the
294
+ * other's file before it could publish its own.
295
+ *
296
+ * **Nothing here can be stale.** The version gate the manifest needed existed
297
+ * because a vendored copy could sit at any age; a fetched index is pinned to
298
+ * the version the relationship declares, so `stale` now reports only what is
299
+ * genuinely unusable — an unreadable file, or a package with pages and no base
300
+ * to serve them from.
301
+ *
302
+ * @param {object} config - The resolved build configuration.
303
+ * @param {Iterable<string>} localPackages - Packages this build publishes.
304
+ * @param {Record<string, string>} [bases] - Where each package is served.
305
+ * @returns {{index: Map<string, object>, packages: Set<string>,
306
+ * stale: Array<{package: string, reason: string}>}} The resolved addresses,
307
+ * which packages contributed, and what could not be read.
308
+ */
309
+ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE) {
310
+ const local = new Set(localPackages);
311
+ const index = new Map();
312
+ const packages = new Set();
313
+ const stale = [];
314
+
315
+ for (const file of cachedMetadataFiles(config)) {
316
+ let records;
317
+ try {
318
+ records = fs
319
+ .readFileSync(file, "utf8")
320
+ .split("\n")
321
+ .filter((line) => line.trim())
322
+ .map((line) => JSON.parse(line));
323
+ } catch (err) {
324
+ stale.push({ package: packageOfCache(file), reason: `unreadable: ${err.message}` });
325
+ continue;
326
+ }
327
+
328
+ const pkg = records[0]?.package ?? packageOfCache(file);
329
+ if (local.has(pkg)) continue;
330
+
331
+ // A base is only needed to resolve a page *URL*, so a pack-only
332
+ // dependency — Foundry addresses and no site, which `kethira` is by
333
+ // licensing rather than by accident — needs none. Demanding one would
334
+ // make its documents uncitable from anywhere, which is a worse answer
335
+ // than citing them by UUID and rendering the prose unlinked.
336
+ //
337
+ // So an absent base degrades rather than fails: every entry keeps its
338
+ // `uuid` and simply has no `url`, exactly as a consumer must already
339
+ // tolerate for an entry that compiles into no document.
340
+ const base = bases?.[pkg];
341
+ const web = typeof base === "string" && base.length > 0;
342
+
343
+ for (const record of records) {
344
+ const key = record?.address?.canonical;
345
+ if (!key) continue;
346
+ const parts = readCanonicalKey(key);
347
+ if (!parts) continue;
348
+ // First writer wins, so two packages claiming one address cannot
349
+ // make the build depend on the order the cache was read in.
350
+ if (index.has(key)) continue;
351
+ const foundry = record.foundry?.[parts.system];
352
+ index.set(key, {
353
+ name: record.name?.full ?? record.name,
354
+ // Absent where the package publishes no page for it. A consumer
355
+ // must tolerate that rather than invent an href, exactly as it
356
+ // already tolerates an entry with no `uuid`.
357
+ url:
358
+ web && record.address.slug ?
359
+ resolvePackageUrl(`${record.address.slug}/`, base)
360
+ : undefined,
361
+ uuid: foundry?.uuid,
362
+ doc: record.documentation ?? undefined,
363
+ anchors: foundry?.anchors,
364
+ type: parts.type,
365
+ package: pkg,
366
+ });
367
+ }
368
+ packages.add(pkg);
369
+ }
370
+
371
+ return { index, packages, stale };
372
+ }
373
+
374
+ /**
375
+ * Which package a cached index belongs to, read from its directory name.
376
+ *
377
+ * Used only to name a package in a diagnostic when its records could not be
378
+ * read — the authoritative answer is the `package` field the records carry.
379
+ *
380
+ * @param {string} file - The cached index file.
381
+ * @returns {string} The dependency id.
382
+ */
383
+ function packageOfCache(file) {
384
+ return path.basename(path.dirname(file)).split("@")[0];
385
+ }
386
+
387
+ /**
388
+ * Where a dependency's fetched index sits, for naming it in a diagnostic.
389
+ *
390
+ * Best effort: the newest complete cache for that package, or the directory it
391
+ * would occupy. A finding has to name *a* file even when the cache is in the
392
+ * state the finding is about.
393
+ *
394
+ * @param {object} config - The resolved build configuration.
395
+ * @param {string} pkg - The dependency's package id.
396
+ * @returns {string} A path to name in a diagnostic.
397
+ */
398
+ export function cachedIndexPath(config, pkg) {
399
+ const root = config?.paths?.metadataCache ?? "build/cache/metadata";
400
+ try {
401
+ const dirs = fs
402
+ .readdirSync(root)
403
+ .filter((name) => name.startsWith(`${pkg}@`))
404
+ .map((name) => path.join(root, name))
405
+ .filter(isComplete);
406
+ if (dirs.length) {
407
+ const dir = dirs.sort()[dirs.length - 1];
408
+ const file = fs.readdirSync(dir).find((n) => n.endsWith(".jsonl"));
409
+ if (file) return path.join(dir, file);
410
+ }
411
+ } catch {
412
+ // The cache is missing or unreadable, which is often the very thing
413
+ // being reported. The conventional path is all that can be named.
414
+ }
415
+ return path.join(root, `${pkg}@<version>`, metadataFileName(pkg));
416
+ }
417
+
418
+ /**
419
+ * Whether a fetched index can still be *addressed*, as distinct from read.
420
+ *
421
+ * A consumer resolves cross-package links by canonical key, so it needs both
422
+ * sides to agree on the key's shape. When they drift the lookup cannot match on
423
+ * *any* input — and because a miss is indistinguishable from a typo, the
424
+ * symptom is a pile of dead addresses blamed on the notes that cite them rather
425
+ * than on the index at fault. A package whose every key is unreadable is
426
+ * therefore reported against the index, once, instead of once per citing note.
427
+ *
428
+ * The realistic cause is a version skew: a dependency released before the
429
+ * address grammar gained its `<system>` segment (#59) ships three-segment keys.
430
+ * Re-fetching after that dependency releases is the fix.
431
+ *
432
+ * @param {Map<string, object>} foreignIndex - The resolved foreign index.
433
+ * @returns {Array<{package: string, entries: number, sampleKey: string}>} One
434
+ * finding per drifted package, in the order the index first names each.
435
+ */
436
+ export function unaddressableForeignPackages(foreignIndex) {
437
+ const byPackage = new Map();
438
+ for (const [key, value] of foreignIndex ?? new Map()) {
439
+ // The package is read from the entry rather than the key, since the key
440
+ // is the very thing under suspicion — deriving it from a shape that may
441
+ // not parse would report the finding against `undefined`.
442
+ const pkg = value?.package;
443
+ if (!pkg) continue;
444
+ const seen = byPackage.get(pkg) ?? { entries: 0, readable: 0, sampleKey: key };
445
+ seen.entries += 1;
446
+ if (readCanonicalKey(key)) seen.readable += 1;
447
+ byPackage.set(pkg, seen);
448
+ }
449
+
450
+ const findings = [];
451
+ for (const [pkg, seen] of byPackage) {
452
+ if (seen.entries > 0 && seen.readable === 0) {
453
+ findings.push({ package: pkg, entries: seen.entries, sampleKey: seen.sampleKey });
454
+ }
455
+ }
456
+ return findings;
457
+ }
458
+
459
+ /**
460
+ * One finding, in the standard `file:line:column: severity: message` form.
461
+ *
462
+ * The position is recovered by locating the offending key in the index text:
463
+ * the finding is about a literal the reader can see in the file, so its
464
+ * position is implicit rather than absent. When the file cannot be read, or the
465
+ * key is not in it, the locator degrades to the file alone — a dropped field,
466
+ * never a guessed `1:1` that would send the reader to the top of a large file
467
+ * for a finding that is not there.
468
+ *
469
+ * @param {{package: string, entries: number, sampleKey: string}} finding - One
470
+ * finding from {@link unaddressableForeignPackages}.
471
+ * @param {object} config - The resolved build configuration.
472
+ * @returns {string} The formatted diagnostic, path first on the line.
473
+ */
474
+ export function formatUnaddressableFinding(finding, config) {
475
+ const file = cachedIndexPath(config, finding.package);
476
+ let at = {};
477
+ try {
478
+ at = positionOfLiteral(fs.readFileSync(file, "utf8"), `"${finding.sampleKey}"`);
479
+ } catch {
480
+ // Unreadable here is not itself the finding — the loader already
481
+ // reports that. The file is simply all that is known about where this
482
+ // one is.
483
+ }
484
+ return formatDiagnostic({
485
+ file,
486
+ ...at,
487
+ severity: "error",
488
+ message:
489
+ "no key in this content index is a canonical " +
490
+ `\`package-system-type-shortcode\` address (${finding.entries} ` +
491
+ `${finding.entries === 1 ? "entry" : "entries"}, none addressable; ` +
492
+ `first is \`${finding.sampleKey}\`) — every cross-package link to ` +
493
+ `${finding.package} would resolve to nothing, silently`,
494
+ });
495
+ }