@heroiclands/package-build 20.0.0 → 20.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,481 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 20.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 85fffd6: **The frontmatter lint checks the system blocks a package ships for, instead of
8
+ a block named `sohl`.**
9
+
10
+ A system block is a closed region: a key the system's vocabulary does not
11
+ declare is an error, because the compiler's builders are an allow-list and drop
12
+ it without a word. That held for exactly one block, `sohl:`, and it held whatever
13
+ system the package shipped for — the linter takes the blocks its caller names,
14
+ and the only caller named none, so every tree fell back to the same constant.
15
+
16
+ Both directions of that are wrong once a second system exists, and the second is
17
+ the costlier:
18
+
19
+ | | before | now |
20
+ | ----------------------------- | --------------------------------------------------------- | ------------------------------------ |
21
+ | a package shipping for `sohl` | `sohl:` checked | unchanged |
22
+ | a package shipping for `hm3` | `sohl:` checked — a block it does not carry | `hm3:` checked |
23
+ | an `hm3:` block | **never read**, every key discarded at compile in silence | checked against HM3's own vocabulary |
24
+ | a tree feeding both | one of two blocks checked | each block against its own system |
25
+
26
+ **Which systems a package ships for is already declared**, so this reads that
27
+ rather than asking for it again — in all three places it is written:
28
+
29
+ - `systems:`, which declares them without requiring one;
30
+ - a **pack's** `system:`, which is the same statement per pack and the only one
31
+ some trees make. It is already authoritative at compile, where a note routed
32
+ to such a pack and carrying no such block fails the build, so a lint blind to
33
+ it would refuse a note for want of a block it never checked;
34
+ - `stats.systemId` where neither is written, which has already absorbed every
35
+ remaining spelling: a system package is its own system, and a module takes
36
+ `requiresSystem`, its lone `systems:` entry, or its lone system relationship.
37
+
38
+ **Each block is held to its own system's vocabulary**, and that has two sources.
39
+ A system's `itemBuilders` registry covers its item types — `skill` is one name
40
+ over two data models, so a key SoHL's `skill` declares is not thereby a key
41
+ HM3's. The note schemas cover the rest, `being` above all, which is an actor type
42
+ sitting in no item registry; they are SoHL's, because that is the vocabulary
43
+ `content-build` is built with.
44
+
45
+ A type neither source names is a type that system says nothing about, and its
46
+ block is left alone on such a note rather than reported wholesale. A package
47
+ naming no system anywhere is system-agnostic on purpose — its packs are core
48
+ document types carrying no system data — so it has no system block, and none is
49
+ invented for it.
50
+
51
+ **A block whose vocabulary nothing states is said out loud.** A package
52
+ declaring a system other than SoHL and no `itemBuilders` registry for it has
53
+ nothing that can say what that block may carry, so the block goes unchecked and
54
+ `content-build lint` reports that once, naming the system and the registry to
55
+ declare. A check that quietly does nothing is indistinguishable from one that
56
+ passed, which is the whole subject here.
57
+
58
+ For `harn-ensemble` — the tree this issue is about, declaring both systems
59
+ through its packs — that means its `sohl:` block is checked exactly as before,
60
+ its 2,512 `being` notes included, and its `hm3:` block waits on
61
+ `itemBuilders: [hm3, sohl]`, which the lint now asks for by name.
62
+
63
+ **Nothing changes for a package shipping for SoHL**, which is every consumer
64
+ today: one system, one registry, and the derivation is the identity on it.
65
+ - e084547: The e2e harness no longer reports a run that never started as green.
66
+
67
+ Observed against a licensed container: a concurrent `npm ci` removed
68
+ `node_modules` out from under a run in progress, Cypress died with
69
+ `Cannot find package '.../cypress/index.js'`, and `package-build e2e run`
70
+ **exited 0**. The concurrency was an operator's mistake; the exit code was not.
71
+ A scripted caller, or anyone reading the tail of a log, would have recorded the
72
+ suite as passing when nothing was executed — and the suite is what moves
73
+ `compatibility.verified`, so an exit code that says green when nothing ran makes
74
+ that evidence unfalsifiable in the one direction that matters.
75
+
76
+ The suite is now bracketed rather than trusted on its exit status:
77
+
78
+ | When | Check | What it catches |
79
+ | ------ | ---------------------------------- | ---------------------------------------------------------------------------------------- |
80
+ | Before | Every executable the command names | The runner is not installed — an error naming it, before a container and a world. |
81
+ | Before | The tool behind a package runner | `npx cypress run` resolves **`cypress`**; `npx` is never missing, so it answers nothing. |
82
+ | After | Those executables again | The runner disappeared mid-run, which is the failure reported above. |
83
+ | After | Results written since the spawn | The suite started and produced nothing. Needs the new `results` key. |
84
+
85
+ **New: `packageBuild.e2e.results`.** One path, or a list of them, relative to
86
+ the repository root, naming where the suite writes its results:
87
+
88
+ ```yaml
89
+ e2e:
90
+ suite:
91
+ run: [npx, cypress, run]
92
+ results: [cypress/results]
93
+ ```
94
+
95
+ Existence is not the test — a directory the _previous_ run left behind exists,
96
+ and reading that as evidence would make the check agree with exactly the thing
97
+ it was built to catch. What counts is a file modified since the suite was
98
+ spawned. Declaring nothing keeps the previous contract, in which the exit status
99
+ is taken at its word; declaring a path is what buys the distinction between _the
100
+ suite ran and passed_ and _the suite did not run_.
101
+
102
+ **What a consumer may notice.** A `run`, `fast` or `sweep` whose suite is not
103
+ installed now fails immediately with a diagnostic naming the missing program,
104
+ where before it stood a container up and failed later — or, in the reported
105
+ case, did not fail at all. The check only ever makes a verdict _worse_: a suite
106
+ that failed keeps its own exit status, so there is no new way for the harness to
107
+ report a result that did not happen. `open` is untouched, because a person
108
+ decides what to execute there and a session that ran no specs is not a fault.
109
+
110
+ Also exported from `@heroiclands/package-build/e2e`, for a repository that wants
111
+ the same rules elsewhere: `suiteExecutables`, `findExecutable`,
112
+ `missingExecutables`, `freshResults` and the pure `suiteVerdict`.
113
+ - d72c4b2: A note can now name an interface icon instead of drawing one. `:icon-star:`
114
+ renders as the same Font Awesome element the system's own sheets emit, and an
115
+ undeclared name is reported rather than published as literal text.
116
+
117
+ The user guide described Foundry's interface by pasting Unicode lookalikes of
118
+ icons the sheets actually draw — `☆` for the improve flag, `✎` for the formula
119
+ editor — so the note and the screen it described were drifting apart. Those
120
+ characters are also the worst in the corpus to typeset: of eight candidate book
121
+ faces, none carries them.
122
+
123
+ A registry maps a writer's name to a style and a Font Awesome icon, because the
124
+ three surfaces need different artefacts from one name: the journals and the
125
+ website want an `<i class="fa-solid fa-star">`, and a PDF wants a font file and
126
+ a glyph. It also means an icon renamed between Font Awesome major versions costs
127
+ one line rather than a sweep of the corpus.
128
+
129
+ Codepoints are deliberately absent: a renderer embedding the font has to read it
130
+ to subset it, and the font's own `cmap` is the only trustworthy source for which
131
+ glyph a name resolves to.
132
+
133
+ Part of #378.
134
+ - 15fb41f: **A pack's `system:` must resolve to the version its documents are stamped with,
135
+ and a configuration where it resolves to nothing is now refused.**
136
+
137
+ Every document in a pack carries `_stats.systemId` and `_stats.systemVersion`,
138
+ and for a pack declaring `system:` those come from one of exactly two places:
139
+ the `systems:` entry for that system, which carries the verified version, or the
140
+ package-wide stats, which answer for a package whose packs are all for its own
141
+ system.
142
+
143
+ A pack naming a system that resolves to **neither** used to fall through to the
144
+ package-wide value — and a module that declares no system does not have one, so
145
+ both fields were stamped `null`. That is the plausible lie #43 was about,
146
+ reached by the one path the guard did not cover:
147
+
148
+ ```
149
+ _stats: { systemId: null, systemVersion: null, … }
150
+ ```
151
+
152
+ on 2,513 compiled actors in a pack whose configuration says `system: sohl` on
153
+ the line above.
154
+
155
+ **The check existed; it was skipped in exactly this case.** `packs.<n>.system`
156
+ was validated against `systems:` only when that block was non-empty — the guard
157
+ read `declaredSystems.size && …` — so an absent block meant no check at all. Its
158
+ sibling ten lines up refuses the same thing for `requiresSystem` and says "the
159
+ `systems:` block is empty or absent" in as many words, and the comment above
160
+ both already described this failure. The suite was green throughout because its
161
+ `harn-ensemble`-shaped fixture declares the `systems:` block the repository does
162
+ not: the fixture was more complete than the configuration it stood for.
163
+
164
+ **What a consumer sees.** A configuration in this shape now fails with the pack
165
+ named and the entry to add:
166
+
167
+ > `packs.actors-hm3.system` names `hm3`, which `systems:` does not declare — the
168
+ > `systems:` block is empty or absent, and this package has no package-wide
169
+ > system either. Every document in the pack is stamped `_stats.systemId` and
170
+ > `systemVersion` from one of those two, so with neither it would be stamped
171
+ > null. Add `systems:` naming `hm3` with a `compatibility.verified` version.
172
+
173
+ **Nothing changes for a package whose packs name no system**, or whose packs name
174
+ its own system — the package-wide stats answer for those exactly as before,
175
+ which is every single-system tree. The package-wide derivation itself is now a
176
+ named function read by both the stamp and the check, so the value validated
177
+ against and the value stamped cannot come to disagree about the case that has no
178
+ answer.
179
+ - c9e7a5a: **A pack default is resolved per system, so one note compiles into one pack per
180
+ system without declaring anything.**
181
+
182
+ This is the routing half of #58, and until now it made the documented
183
+ two-system layout impossible to build. A default was computed per document
184
+ _type_: a type with exactly one pack is that type's default implicitly, and a
185
+ type with several designates one with `default: true`. A tree shipping one Actor
186
+ pack per system has two, so it had neither — and a note feeding both systems
187
+ declares no `pack:` by design, since a block's `pack:` exists to say where one
188
+ system's document goes only when that _differs_.
189
+
190
+ So every note routed nowhere. On `harn-ensemble` that was all 2,519 of them, the
191
+ build failing on each in turn with a message saying the configuration was wrong
192
+ when it was the question being asked that was.
193
+
194
+ | | before | now |
195
+ | -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
196
+ | one Actor pack per system, no flag | every note routes nowhere; build fails | each system's document routes to its own pack |
197
+ | a type-wide `default: true` on `actors-sohl` | returned to the HM3 pass too, which saw a name that was not its own and **skipped every note in silence** | the HM3 pass gets `actors-hm3` |
198
+ | `hm3.pack:` naming a SoHL pack | routed there, and the HM3 document was lost without a word | refused, naming the note and the pack |
199
+ | a shared `pack:` naming a SoHL pack | the HM3 document was lost without a word | does not answer for HM3, which falls through to its own default |
200
+
201
+ **A system is never answered with another system's pack.** That is the rule the
202
+ four rows share, and the second is the one worth stating twice: it failed
203
+ silently. The pack compiled zero entries, which a build reports only because a
204
+ pack that compiles nothing from a non-empty tree is itself an error.
205
+
206
+ **Marking a default still means what it says** — it designates that _system's_
207
+ default where a system has several packs of one type — and every single-system
208
+ configuration is untouched, since a pack declaring no system belongs to all of
209
+ them and the type-wide default answers exactly as before.
210
+
211
+ On `harn-ensemble` this takes `actors-sohl` from 0 compiled actors to 2,497, and
212
+ `actors-hm3` from routing nothing to claiming every note and reporting what each
213
+ still needs: `hm3.type`, which `being` requires because it is one-to-many into
214
+ `character` and `creature`.
215
+ - 0418fa8: `content-build lint` now holds a content tree to a character allowlist, so a
216
+ book can choose its typeface without discovering at print time that no font
217
+ carries what the notes are written in.
218
+
219
+ Typst does not warn when a glyph is missing — it falls back to whatever system
220
+ font has one and exits 0, so a rules table can set in three unrelated faces and
221
+ the build still reports success. The check moves that failure back to where the
222
+ character is written.
223
+
224
+ The tiers are measured rather than chosen: eight candidate book faces were
225
+ probed over every non-ASCII character in the five content trees, and what is
226
+ admitted is what enough of them carry. Letters and typography are universal;
227
+ Latin Extended Additional is carried by seven of eight; IPA was considered and
228
+ refused at five of eight, because requiring it would cost font freedom rather
229
+ than buy it.
230
+
231
+ Two rules ride along that an allowlist cannot express. Content must be NFC — a
232
+ decomposed letter is a different string to every byte comparison, including
233
+ DuckDB's `=`, so a filter typed one way silently misses a note stored the other.
234
+ And box-drawing, geometric and arrow characters are permitted inside a fenced
235
+ code block only, where the mono face sets them.
236
+
237
+ Part of #377.
238
+ - 571d5bc: **`content-build format` now says which shared Prettier conventions your
239
+ repository is not using.**
240
+
241
+ A consumer's own Prettier config wins **wholesale** — that is Prettier's own
242
+ behaviour and it is not changing — so the conventions this package publishes held
243
+ by convention alone, and lapsed silently in two opposite directions (#133):
244
+
245
+ | what a repository declares | what it actually formatted to |
246
+ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
247
+ | `export { default } from "@heroiclands/package-build/prettier"` | the shared conventions |
248
+ | `{ ...PRETTIER_BASE }`, without the `**/*.md` override | markdown at `tabWidth: 4` — every note reindenting away from the form it was written in |
249
+ | a partial `.prettierrc`, e.g. `{"tabWidth": 2}` | Prettier's defaults for `printWidth`, `trailingComma`, `experimentalTernaries`, … |
250
+ | nothing at all | the shared conventions here, Prettier's own in your editor and in `npx prettier` |
251
+
252
+ Every `format` run now reports each disagreement by name, before the per-file
253
+ report:
254
+
255
+ ```text
256
+ prettier.config.mjs: warning: markdown `tabWidth` is 4 here; the shared configuration says 2
257
+ .prettierrc: warning: `printWidth` is not set here, so Prettier's own default applies; the shared configuration says 100
258
+ ```
259
+
260
+ A repository with no Prettier config is warned too, with the one line that fixes
261
+ it — that case is the sharper one, because the shared conventions then reach this
262
+ command and nothing else, so a bare `npx prettier --check .` and the lint chain
263
+ take turns rewriting the same lines.
264
+
265
+ **Nothing here fails a build.** Every finding is a `warning`, the exit code is
266
+ untouched, and a deliberate local override keeps working exactly as before — it
267
+ just stops being silent.
268
+
269
+ New export: `checkPrettierConventions(root)` from
270
+ `@heroiclands/package-build/engine/prose-lint`, and the pure comparison behind it,
271
+ `sharedPrettierDivergence(resolved, file)` from
272
+ `@heroiclands/package-build/engine/prose-config`.
273
+ - 9f638fa: **A note whose secondary document has no pack is now a finding, instead of
274
+ losing that document in silence.**
275
+
276
+ A note produces more than one document as a matter of course: an item note an
277
+ Item and the JournalEntry its prose becomes, a map note a Scene and a
278
+ JournalEntry, an actor note an Actor and a JournalEntry since #337. Where the
279
+ configuration declares no pack for one of them, that document was dropped while
280
+ the rest of the note compiled into a pack that does exist. The build succeeded,
281
+ the compendium shipped, and the missing half was discoverable only by noticing
282
+ it was not there.
283
+
284
+ #146 already reports a note **nothing** claims, and could not see this: it asks
285
+ one question of the whole configuration — does any pack claim this type — and a
286
+ note that compiles its Item into an Item pack answers yes.
287
+
288
+ > a note of type "being" compiles into a JournalEntry as well as an Actor, and
289
+ > `packs:` declares no JournalEntry pack — so the JournalEntry is dropped with
290
+ > no error while the rest of the note compiles. Declare a JournalEntry pack in
291
+ > package-build.config.yaml, or accept the loss deliberately by not authoring
292
+ > what it would have carried.
293
+
294
+ The message names the note, the class with no pack, and the class that did
295
+ compile — the last because it is what tells the two findings apart at a glance:
296
+ one is a `type:` to correct, this one a pack to declare.
297
+
298
+ **Asked per note, not per type**, which is the difference between a useful
299
+ finding and a useless one. `Journals` declines a doc-carrying note whose body is
300
+ empty — an item with no prose gets no doc — so whether an item note produces a
301
+ JournalEntry is decided by the note. `sohl-kethira-basic` declares no
302
+ JournalEntry pack and ships 393 notes whose descriptions are _deliberately_
303
+ empty under the Fan Material Guidelines its configuration explains at length; a
304
+ type-level answer would report every one of them for losing a document none of
305
+ them produces. It reports none.
306
+
307
+ **It names no system**, so a type one system maps and another does not stays
308
+ silent for the system that declines it, per #79. That holds by construction: the
309
+ `Item` and `Actor` rows fold the systems' maps together before this sees them,
310
+ so a type appears once or not at all and no system is ever named.
311
+
312
+ **What it finds today.** `Song-of-Heroic-Lands-FoundryVTT` and `sohl-thalorna`
313
+ report nothing — every document their notes produce already has a pack.
314
+ `harn-ensemble` reports 2,512: it declares two Actor packs and no JournalEntry
315
+ pack, so every one of its beings has been losing the `{#appearance}` and
316
+ `{#dossier}` prose it carries. When the issue was filed no tree authored the
317
+ affected combination; one does now.
318
+
319
+ ### Patch Changes
320
+
321
+ - 6499109: **A build now reads the configuration of the tree it was run in.**
322
+
323
+ The configuration was located by walking up from the installed package's own
324
+ directory. That is the same file as the working directory's in every ordinary
325
+ install — and a different one in a git worktree nested under its parent checkout
326
+ with no `node_modules` of its own. Node's resolution walks parent directories,
327
+ so such a worktree resolves `@heroiclands/package-build` out of the _parent's_
328
+ `node_modules`; the walk started inside the parent and landed on the parent's
329
+ `package-build.config.yaml`. The build then compiled the parent's content tree
330
+ into the parent's `build/`, said so only in absolute paths that are easy to read
331
+ past, and exited 0.
332
+
333
+ Resolution now starts at `process.cwd()` and falls back to the installed
334
+ package's directory only when that finds nothing.
335
+
336
+ | running `content-build package compile` in | before | after |
337
+ | -------------------------------------------- | ----------------------- | ---------------- |
338
+ | a repository, or any directory below it | that repository | unchanged |
339
+ | a nested worktree that has had `npm ci` run | the worktree | unchanged |
340
+ | a nested worktree with **no `node_modules`** | _the parent checkout_ | **the worktree** |
341
+ | a directory outside any repository | the installed package's | unchanged |
342
+ | anywhere, with `PACKAGE_BUILD_CONFIG` set | the file it names | unchanged |
343
+
344
+ Nothing about "a build reads one tree however it was launched" changes: the walk
345
+ climbs, so every directory inside a repository still resolves that repository's
346
+ single configuration.
347
+
348
+ When both walks find a configuration and they disagree, the working directory's
349
+ is read and the ignored one is named in a warning on stderr. The disagreement is
350
+ worth hearing on its own — it is the cheapest signal that this tree is building
351
+ on another checkout's `node_modules`, which is also a masked missing dependency.
352
+ `npm ci` in the worktree silences it properly.
353
+
354
+ **Why this was worth a fix rather than a note.** A silent wrong-tree build does
355
+ not merely fail to prove what was wanted, it produces confident evidence for the
356
+ wrong tree — and on an output-preserving sweep there is no observation that
357
+ distinguishes success from it. The usual tell is a zero diff where a change was
358
+ expected; a sweep that expects zero differences has no tell at all.
359
+
360
+ `resolveConfigFile()` is exported from
361
+ `@heroiclands/package-build/engine/pack-config`, reporting the chosen file and
362
+ each walk's own answer, so a caller can ask which tree it is about to compile
363
+ without re-deriving the resolution and risking disagreement with the loader.
364
+ - c229f2b: Stop shipping a `node_modules` symlink, which had broken every release for a
365
+ day.
366
+
367
+ A worktree's `node_modules` symlink — a 120000 blob holding one developer's
368
+ absolute path — was committed on 2026-09-11. `.gitignore` said
369
+ `/node_modules/`, and a trailing slash matches a directory rather than a
370
+ symlink, so nothing refused it.
371
+
372
+ The release job installs, runs the tests, and then hands over to the changesets
373
+ action, which does `git reset --hard` before versioning. That reset restored the
374
+ symlink over the top of the install, pointing at a path no runner has, so every
375
+ module became unresolvable and the release died on `changeset: not found`. The
376
+ tests had already passed, because they run before the reset.
377
+
378
+ The symlink is untracked, the ignore rule now matches a symlink at any depth,
379
+ and CI refuses a tracked `node_modules` path outright — the release is the only
380
+ thing this breaks, and no pull request check would otherwise notice.
381
+ - 80f40b2: Unblock releasing. Every run of the release workflow had failed at
382
+ `changeset version` with `sh: 1: changeset: not found` since 2026-09-11, so
383
+ nothing reached the registry past 20.0.0 while `main` went on believing itself
384
+ released.
385
+
386
+ The workflow installed `npm@latest` before publishing, to clear an OIDC floor of
387
+ 11.5. That was written when Node 24.0–24.4 bundled npm 11.3–11.4; since 24.5 the
388
+ bundled npm has cleared the floor on its own, and the step became a no-op that
389
+ nobody removed. On 2026-09-11 `latest` became npm 12, which stopped putting
390
+ `node_modules/.bin` on the PATH of a run-script's shell, and a release path
391
+ nobody had touched broke.
392
+
393
+ The install is gone rather than pinned: the npm that publishes is now the one
394
+ Node brings, so its version follows `node-version` instead of a number kept in
395
+ step by hand. A check in its place asserts the floor and fails loudly if a
396
+ future Node pin ever drops below it — an assertion cannot go quietly stale the
397
+ way the comment it replaces did. The version script also resolves its binary
398
+ through `npx`, as the publish script already did.
399
+ - 12061da: Release again. Since 2026-09-11 every run of the release workflow had died at
400
+ `changeset version` with `sh: 1: changeset: not found`, so nothing reached the
401
+ registry past 20.0.0.
402
+
403
+ The workflow set `version-script`, which replaces the action's own invocation of
404
+ the changesets CLI with a shell command run through its exec — and under that
405
+ exec a bare `changeset` does not resolve on a runner. Left unset, the action
406
+ resolves the installed package with `require.resolve` and runs it with `node`,
407
+ depending on no PATH at all. The override is removed.
408
+
409
+ The fault was never in this repository's install. A diagnostic run confirmed
410
+ that after `npm ci` a runner has the package, has the bin linked, puts
411
+ `node_modules/.bin` first on a run-script's PATH, and resolves the bare name
412
+ through `npm run` — all in the same job that then failed.
413
+
414
+ The one thing the override bought, refreshing `package-lock.json`'s root
415
+ `version`, is now #385 rather than a reason to keep a step that does not run.
416
+ - 9572517: Finish unblocking the release. Removing the stale `npm install -g npm@latest`
417
+ fixed the npm-12 half, but the same change also swapped the version script's
418
+ bare `changeset` for `npx changeset`, and that turned the failure into `npm
419
+ error could not determine executable to run`.
420
+
421
+ The npx form was belt-and-braces and it was wrong. `npm run` already puts
422
+ `node_modules/.bin` on the PATH — the same mechanism `npm test` uses to reach
423
+ `vitest` earlier in the same job — so the bare name resolves the pinned local
424
+ copy with no lookup. npx instead consults the registry, which this job
425
+ configures for OIDC publishing rather than for reads.
426
+
427
+ The script is back to the bare binary, and the reasoning is recorded beside it
428
+ so the asymmetry with `publish-script` is not mistaken for an oversight again.
429
+
430
+ ## 20.1.0
431
+
432
+ ### Minor Changes
433
+
434
+ - f6d2a8f: **A fetched item catalogue is read one system at a time** (#58).
435
+
436
+ A pack declaring `system: hm3` already read only this repository's `hm3` and
437
+ system-neutral Item packs. The other half of the same lookup — the catalogue
438
+ fetched from a dependency that declares `itemCatalog: true` — was unscoped, and
439
+ both halves are merged into one address space keyed by `subType:shortcode`. So
440
+ an address that exists in both vocabularies resolved against whichever document
441
+ the dependency's other system happened to supply, and said nothing: `skill:awar`
442
+ is a real address under SoHL and under HM3 and means two different documents.
443
+
444
+ `deps fetch` now records what each extracted pack is, from the dependency's own
445
+ manifest, and `foreignItemCatalogDirs(config, system)` reads only the packs that
446
+ system may see plus the ones declaring no system at all.
447
+
448
+ **What a consumer sees**
449
+
450
+ | | Before | After |
451
+ | ------------------------------------ | ----------------------- | -------------------------------- |
452
+ | a pack with `system: hm3` | reads every cached pack | reads the `hm3` and neutral ones |
453
+ | a single-system build | reads every cached pack | unchanged |
454
+ | a cache filled by an earlier version | used as-is | treated as incomplete |
455
+
456
+ **Refill the cache once.** A cache written before this holds the items but not
457
+ what they are, and neither way of proceeding without that is honest: reading
458
+ every pack is the wrong-document failure above, and reading none fails a build
459
+ that worked. So it is incomplete, and `content-build deps fetch` refills it —
460
+ the command the cold-cache error already names.
461
+
462
+ ### Patch Changes
463
+
464
+ - 2b1157e: **The license header is on every shipped module, and CI refuses a `TODO`.**
465
+
466
+ `engine/foreign-catalog.mjs` and `engine/schema-extract.mjs` shipped without the
467
+ GPL-3.0 header every other module carries — 109 of 111 had one, which is the
468
+ state a rule reaches when nothing checks it.
469
+
470
+ The forbidden-marker check now runs here too, through the org-wide
471
+ `HeroicLands/.github/actions/todos` action the other repositories already call.
472
+ It scans the whole checkout rather than a named list of directories: this
473
+ package's modules sit at its root as well as under `bin/`, `ci/`, `engine/`,
474
+ `hm3/` and `sohl/`, so a list would name sixteen root files today and quietly
475
+ stop covering the seventeenth.
476
+
477
+ Nothing a consumer imports changes.
478
+
3
479
  ## 20.0.0
4
480
 
5
481
  ### Major Changes