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