@heroiclands/package-build 17.2.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 (152) hide show
  1. package/CHANGELOG.md +1711 -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 +225 -127
  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} +55 -83
  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/manifest.mjs +43 -2
  74. package/package.json +18 -4
  75. package/release.mjs +62 -7
  76. package/sohl/actors.mjs +33 -487
  77. package/sohl/being-info.mjs +16 -7
  78. package/sohl/default-item-art.mjs +14 -3
  79. package/sohl/document-subtypes.mjs +16 -10
  80. package/sohl/item-builders.mjs +14 -5
  81. package/sohl/item-fields.mjs +68 -7
  82. package/sohl/items.mjs +44 -258
  83. package/sohl/note-schemas.mjs +2 -2
  84. package/types/content-config.d.mts +21 -42
  85. package/types/engine/actor-compiler.d.mts +204 -0
  86. package/types/engine/address-charset.d.mts +11 -8
  87. package/types/engine/address-diff.d.mts +53 -5
  88. package/types/engine/anchored-sections.d.mts +21 -0
  89. package/types/engine/anchors.d.mts +20 -0
  90. package/types/engine/base-compiler.d.mts +17 -17
  91. package/types/engine/bundle-notes.d.mts +173 -0
  92. package/types/engine/bundles.d.mts +60 -0
  93. package/types/engine/code-fences.d.mts +43 -0
  94. package/types/engine/compile-corpus.d.mts +32 -0
  95. package/types/engine/content-address.d.mts +205 -5
  96. package/types/engine/content-format-check.d.mts +6 -2
  97. package/types/engine/content-format.d.mts +57 -1
  98. package/types/engine/content-index.d.mts +54 -49
  99. package/types/engine/content-links.d.mts +52 -7
  100. package/types/engine/content-lint.d.mts +10 -1
  101. package/types/engine/content-package.d.mts +2 -1
  102. package/types/engine/content-tables.d.mts +21 -39
  103. package/types/engine/document-subtypes.d.mts +37 -3
  104. package/types/engine/field-spec.d.mts +76 -5
  105. package/types/engine/folder-notes.d.mts +159 -0
  106. package/types/engine/foreign-catalog.d.mts +53 -0
  107. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +2 -43
  108. package/types/engine/frontmatter-lint.d.mts +10 -2
  109. package/types/engine/frontmatter.d.mts +64 -0
  110. package/types/engine/generate.d.mts +38 -0
  111. package/types/engine/helpers.d.mts +94 -30
  112. package/types/engine/ids.d.mts +96 -0
  113. package/types/engine/index-records.d.mts +68 -0
  114. package/types/engine/index.d.mts +9 -3
  115. package/types/engine/item-compiler.d.mts +131 -0
  116. package/types/engine/journals.d.mts +47 -9
  117. package/types/engine/metadata-index.d.mts +226 -0
  118. package/types/engine/note-claims.d.mts +57 -11
  119. package/types/engine/note-ids.d.mts +38 -0
  120. package/types/engine/note-renames.d.mts +102 -0
  121. package/types/engine/note-vocabulary.d.mts +44 -8
  122. package/types/engine/retired-fields.d.mts +75 -0
  123. package/types/engine/scenes.d.mts +3 -2
  124. package/types/engine/schema-check.d.mts +25 -4
  125. package/types/engine/site-build.d.mts +4 -4
  126. package/types/engine/site-index.d.mts +1 -1
  127. package/types/engine/sql-tables.d.mts +185 -0
  128. package/types/engine/subtype-registry.d.mts +49 -0
  129. package/types/engine/system-block.d.mts +40 -1
  130. package/types/engine/systems.d.mts +106 -0
  131. package/types/engine/web-wikilinks.d.mts +4 -2
  132. package/types/engine/wikilink-syntax.d.mts +10 -3
  133. package/types/engine/wikilinks.d.mts +41 -13
  134. package/types/engine/yaml-lint.d.mts +107 -0
  135. package/types/hm3/actors.d.mts +48 -0
  136. package/types/hm3/default-item-art.d.mts +42 -0
  137. package/types/hm3/document-subtypes.d.mts +24 -0
  138. package/types/hm3/index.d.mts +7 -0
  139. package/types/hm3/item-builders.d.mts +11 -0
  140. package/types/hm3/item-fields.d.mts +12 -0
  141. package/types/hm3/items.d.mts +23 -0
  142. package/types/hm3/template-priority.d.mts +21 -0
  143. package/types/manifest.d.mts +22 -1
  144. package/types/release.d.mts +9 -4
  145. package/types/sohl/actors.d.mts +7 -74
  146. package/types/sohl/being-info.d.mts +8 -27
  147. package/types/sohl/default-item-art.d.mts +5 -3
  148. package/types/sohl/items.d.mts +17 -32
  149. package/engine/foreign-manifests.mjs +0 -126
  150. package/engine/kb-manifest.mjs +0 -490
  151. package/types/engine/foreign-manifests.d.mts +0 -43
  152. package/types/engine/kb-manifest.d.mts +0 -241
package/CHANGELOG.md CHANGED
@@ -1,5 +1,1716 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 18.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 0d15342: **The cross-package link manifest is gone.** A package now publishes its own
8
+ content index and a consumer fetches the ones it depends on (#239).
9
+
10
+ The manifest was vendored — every repository committed a copy of every other
11
+ repository's file — and that failed three ways, none of them fixable by
12
+ tightening it.
13
+
14
+ **A copy goes stale silently, and one was.**
15
+ `Song-of-Heroic-Lands-FoundryVTT` carried 2,101 `thalorna` entries whose address
16
+ was the old name-derived form, so every cross-package link it rendered pointed
17
+ at a URL the site had stopped publishing. The format-version gate saw nothing,
18
+ because the format had not changed — only the values were wrong.
19
+
20
+ **Mutual vendoring deadlocks.** SoHL vendored thalorna and thalorna vendored
21
+ SoHL, so a format bump stopped both builds until the other had already
22
+ published: neither could go first.
23
+
24
+ **It was a second answer to a settled question.** The content index already
25
+ carried the canonical key, the name, the anchors, the Foundry `uuid` and the web
26
+ address. There was nothing in a manifest entry it did not hold.
27
+
28
+ **What replaces it**
29
+
30
+ - Every package emits `<package>-metadata.jsonl` and advertises it as
31
+ `flags.metadataUrl`, pinned to the version being built.
32
+ - The release publishes it as a third asset, named by that URL so the file
33
+ shipped and the URL advertised cannot disagree.
34
+ - `content-build deps fetch` pulls each dependency's index into
35
+ `build/cache/metadata`, reading the `manifest` URL the relationship already
36
+ declares and taking `flags.metadataUrl` from it. The whole chain is declared;
37
+ nothing holds an address of its own.
38
+ - Cross-package links resolve from that cache.
39
+
40
+ **The dependency set is every declared dependency** — everything in
41
+ `relationships.systems` and `relationships.requires` — and deliberately _not_
42
+ only those declaring `itemCatalog: true`. Citing another package's addresses and
43
+ embedding its items are separate edges: `harn-ensemble` cites no foreign address
44
+ and carries 324,016 embedded item references. `recommends` and `conflicts` are
45
+ not dependencies and are not fetched.
46
+
47
+ **Removed:** `engine/kb-manifest.mjs`, `engine/foreign-manifests.mjs`, the
48
+ `content-build manifest` command, its `--manifests` options, and the
49
+ configuration keys `paths.manifests`, `paths.manifestOut` and
50
+ `publish.manifests`. `engine/manifest-emit.mjs` becomes
51
+ `engine/foundry-entries.mjs`, which is what it always did — `manifest.mjs` and
52
+ `kb-manifest.mjs` both exported `buildManifest` meaning different things, and
53
+ that ends here.
54
+
55
+ **Kept, and moved:** the address grammar — `canonicalKey`, `readCanonicalKey`,
56
+ `CANONICAL_KEY_SEGMENTS`, `PACKAGE_BASE` and the URL helpers — now lives in
57
+ `engine/content-address.mjs` beside `addressSlug`. It was never the manifest's;
58
+ deleting the module without splitting it would have taken the address form too.
59
+
60
+ **Two behaviours moved rather than vanished.** A manifest-completeness gate no
61
+ longer exists: a declared dependency with no fetched index is a hard error
62
+ naming `deps fetch`, so past the load everything is accounted for. And whether a
63
+ package serves pages is now the consumer's `PACKAGE_BASE` rather than the
64
+ producer's entries — a package with no configured base stays citable by UUID and
65
+ simply yields no URL, which is what `kethira` needs.
66
+
67
+ **Consumers must act.** Declare your dependencies in `relationships`, run
68
+ `content-build deps fetch` before a build that resolves cross-package links, and
69
+ delete `assets/manifests/`. A configuration still naming `publish.manifests` or
70
+ `paths.manifests` will be rejected as an unknown key.
71
+ - e5f40c7: **A document's id is derived from its identity, not authored and not keyed on a
72
+ list position.** Three ids changed how they are computed (#270, #268), and every
73
+ compiled id this toolchain emits is affected.
74
+
75
+ **1. A note's document `_id` derives from its canonical address (#270).**
76
+
77
+ ```
78
+ _id = makeId("document", "<package>-<system>-<type>-<shortcode>")
79
+ ```
80
+
81
+ `id:` becomes **optional**. A note used to author one — an opaque 16-character
82
+ string, 6,343 of them across the four content trees — that said nothing its
83
+ address did not, could not be read or reviewed, and was guaranteed by nothing:
84
+ `content-lint` refuses a duplicate **address** across every pack of a document
85
+ type, which is exactly the scope a primary document's id must be unique within,
86
+ and it said nothing at all about a duplicate `id`. The derived id inherits a
87
+ guard that already exists. It is the same principle that turned
88
+ `folder: ONXsqZAIZr2qzxTb` into `packFolder: <path>` (#251, #252).
89
+
90
+ **An authored `id` always wins, and pinning one is how a document keeps its
91
+ identity across a shortcode rename.** That is the pattern the map compiler
92
+ already used (`regionDocId(sceneId, key, pinned)`), now applied to primary
93
+ documents too.
94
+
95
+ **2. An actor's embedded item is keyed on its identity, not its index (#268).**
96
+
97
+ ```
98
+ _id = makeId(<actor id>, "<subType>:<system.shortcode>")
99
+ ```
100
+
101
+ An entry's identity is its **own `system.shortcode`**. The entry's top-level
102
+ `shortcode` is a _selector_ naming the template it is written from, is never
103
+ written to the document, and may repeat. **Two entries resolving to one identity
104
+ are now a build error naming both** — the case a position key used to hide by
105
+ compiling them to two documents denoting one entity.
106
+
107
+ **3. An unanchored journal page is keyed on its heading, not its index (#268).**
108
+
109
+ ```
110
+ _id = makeId("journal-page", "<entry id>:<name>")
111
+ ```
112
+
113
+ Two sibling pages sharing a heading is likewise a build error, matching the
114
+ `MD024` lint rule that already refuses it. An anchored page is unchanged — it
115
+ never took an index, and is the shape the other two now share.
116
+
117
+ **Why position was wrong.** A Foundry id is how a world refers to a document it
118
+ imported. Keying on position meant reordering a being's item list, or inserting
119
+ a heading into a note, silently renumbered every id after the change — so a
120
+ re-import created new documents beside the old ones. Nothing about those
121
+ documents had changed; only their neighbours had.
122
+
123
+ **What this costs, measured against a real corpus** (`sohl`, 1,606 notes →
124
+ 3,094 documents):
125
+
126
+ | id | count | moved |
127
+ | ------------------------- | ----- | ----- |
128
+ | primary documents | 3,094 | **0** |
129
+ | embedded items | 1,337 | 1,337 |
130
+ | journal pages, anchored | 296 | 0 |
131
+ | journal pages, unanchored | 1,648 | 1,648 |
132
+
133
+ No primary id moved **because every note in that tree still authors one**, and a
134
+ pin wins. #270 is inert until a tree drops its authored ids; #268 lands at once.
135
+ Stripping all 1,605 authored ids compiles with no new errors, derives every id
136
+ exactly as the formula above states, and leaves all 5,712 internal `@UUID`
137
+ references as consistent as before.
138
+
139
+ **Consumers must act.**
140
+
141
+ - **Every embedded item id and every unanchored page id changes once.** A world
142
+ that imported these documents will see new ones on re-import.
143
+ - **Removing a tree's authored `id:`s changes every compendium UUID that package
144
+ publishes**, once. Internal references are regenerated in the same build and
145
+ stay consistent with each other; anything outside — a GM's world, a macro, a
146
+ module — will not. Do it deliberately, and per tree.
147
+ - **Fix any duplicate embedded identity first.** Measured across the four
148
+ corpora: 20 entries in 17 notes, every one a defect. `sohl` has 1
149
+ (`Characters/Aldrik_Harvenar.md` carries `skill/swim` twice);
150
+ `sohl-thalorna` has 19; `harn-ensemble` and `sohl-kethira-basic` have none.
151
+
152
+ **One diagnostic narrows.** `content-build addresses` tells a **rename** from a
153
+ **withdrawal** by matching document ids across releases. That rested on the id
154
+ being independent of the shortcode; it no longer is, so for a note that pins no
155
+ `id` both sides move together and a rename is reported as a withdrawal with no
156
+ successor named. It never reports a _wrong_ successor, and stays exact for a
157
+ pinned note.
158
+
159
+ **"Has an id" no longer means "has a compendium document."** Every addressable
160
+ note derives one now, so the types that compile into no _single_ document say so
161
+ themselves rather than resting on an absent `id:`. A **homepage** publishes no
162
+ UUID because it is in no pack, and a **folder** because it may be in several —
163
+ it materialises in every pack holding a document that references it (#276), and
164
+ its id is hashed under the `folder` namespace against its own address. Either
165
+ one would otherwise have published an `Item` UUID at an id no document carries.
166
+
167
+ **Also:** `assertUniqueAnchors` is renamed `assertUniquePages` (the old name
168
+ remains as a deprecated alias) and now checks page names as well as anchors;
169
+ `journalPageId(entryId, page)` no longer takes an index.
170
+ - dcff2f9: **The SoHL passes emit `system.templatePriority`, not `system.archetype`**
171
+ (#266).
172
+
173
+ The read half landed already: `templatePriority` is what a note authors, and
174
+ `archetype` is read only as the retiring spelling. The **emitted** key stayed
175
+ behind, so a compiled document still carried the old name — and that half cannot
176
+ move on its own schedule, because the receiving schema and the emitted key have
177
+ to agree. `Song-of-Heroic-Lands-FoundryVTT#1836` renames the data-model field;
178
+ this is the other side of that single change.
179
+
180
+ **Why it is breaking.** Foundry discards an undeclared `system` key at
181
+ construction _without a warning_, so a build emitting `archetype` into a system
182
+ that declares `templatePriority` reports success and ships documents whose
183
+ priority is silently gone. The emitted-versus-declared check catches exactly this
184
+ and fails the pack build, which is what makes the pairing enforced rather than
185
+ hoped for: **a consumer must take this release together with a SoHL that declares
186
+ `system.templatePriority`** (0.8.4 or later). Taking one without the other fails
187
+ the build with a message naming the field, rather than shipping quietly broken
188
+ packs.
189
+
190
+ `resolveArchetype` and `systemArchetype` are renamed `resolveTemplatePriority`
191
+ and `systemTemplatePriority`; the generated field-reference example authors
192
+ `templatePriority: null`, since the linter now refuses the old spelling in the
193
+ example it tells authors to copy.
194
+
195
+ The HM3 pass is unaffected — it already wrote `flags.hm3.templatePriority`.
196
+ - 79d9503: A top-level `traits:` block is refused (#291).
197
+
198
+ #128 moved a being's description — `gender`, `species`, `age`, `birthday`, `height`,
199
+ `weight`, `frame` and `appearance.*` — out of a top-level `traits:` block and into
200
+ the closed `data:` container the content format declares. **2,533 notes across four
201
+ repositories** have landed, and every content tree now carries zero. This is the third
202
+ and last step of that retirement.
203
+
204
+ **Refusing it matters more than refusing an ordinary dead key.** Top level is
205
+ _deliberately open_: an unrecognised key there is passed straight through to Hugo. So
206
+ a stray `traits:` would not be ignored loudly — it would arrive on the published page
207
+ as a theme parameter, checked by nothing, reading to its author as though it still
208
+ worked. The whole argument for `data:` being closed is the argument for refusing this.
209
+
210
+ The message states the **mapping**, not just the destination, because three of the
211
+ keys reshaped as well as moved: `traits.height.m` → `data.height` (metres),
212
+ `traits.weight.kg` → `data.weight` (kilograms), `traits.build.frame` → `data.frame`.
213
+ A bare _"write `data:` instead"_ would send an author to write `data.height: {m: 1.78}`
214
+ — a declared key holding an undeclared shape.
215
+
216
+ **`sohl.traits` is untouched.** It is a different field that shares the name —
217
+ `projectilegear` declares one and the theme's gear sidebar reads it — so the refusal
218
+ is anchored at column 1 and never reaches inside a system block.
219
+
220
+ Refused from the same two compile paths as `draft:`, `aliases:` and `section:`, and
221
+ reported by the frontmatter lint alongside them.
222
+ - d17dc98: **A `dataview` query that selects no notes is now a build error** (#223).
223
+
224
+ A zero-row table publishes as a bare header and a rule, so a stale query — a
225
+ renamed type, a retired category, a typo'd path — looked exactly like a category
226
+ that is legitimately empty. Both builds emitted it and neither said a word.
227
+
228
+ Where a table is meant to be empty, say so on the fence — `dataview allow-empty`
229
+ in place of `dataview`. The opt-in sits on the fence rather than in the query
230
+ because it is a statement about the directive, not part of the query language.
231
+ The table is still rendered either way: the finding is the point, not
232
+ withholding the output.
233
+
234
+ The finding names the note, the line of the block and **the clause that matched
235
+ nothing**, quoted as authored, because that is the string the author will edit.
236
+
237
+ **The site build's table findings are compiler-parseable too.** They were prose
238
+ with a timestamp where a parser reads the path, so one authored table produced a
239
+ machine-readable diagnostic from the pack build and something ungreppable from
240
+ the site. Both now emit `file:line:column: error: message`.
241
+
242
+ **Major, because a tree carrying a dead table goes red on adoption.**
243
+ `Song-of-Heroic-Lands-FoundryVTT` carries **40**, across eight notes — including
244
+ the eight `type = "creature"` tables in `Rules/Bestiary.md` that the issue was
245
+ filed about, a `sohl.kbcat = "birthsign"` query for a retired concept, and a
246
+ `contains(file.tags, "religous")` that is simply a misspelling. None of them has
247
+ published a row in months.
248
+ - 43a1f4d: **The address grammar is strict, and omission runs left to right** (#59).
249
+
250
+ ```text
251
+ [[[[<package>-]<system>-]<type>-]<shortcode>]
252
+ ```
253
+
254
+ The written forms are exactly the suffixes of the canonical address —
255
+ `type-shortcode`, `system-type-shortcode`, `package-system-type-shortcode` — and
256
+ **`package-type-shortcode` is not one of them**. A link into another package must
257
+ now be fully qualified. That is the price of positional segments, and the
258
+ alternative is a parser that needs a vocabulary to tell a package from a system.
259
+
260
+ `readQualifier` counted at most three segments and never read a system, so it
261
+ accepted a package with its system omitted and every authored target resolved
262
+ system-blind. It now counts segments and assigns each position its field, the
263
+ same rule `readCanonicalKey` follows.
264
+
265
+ **`sohl` is both a package and a system**, and counting is what makes that
266
+ harmless: three segments name a _system_ whatever the first segment could also
267
+ have meant, and four is the full form. Nothing has to guess which sense was
268
+ written.
269
+
270
+ **A stated system is now matched, not merely parsed.** A target naming a system
271
+ resolves against the segments it supplied rather than falling back to the
272
+ system-blind short key, so `[[hm3-skill-clmb]]` no longer silently resolves to
273
+ a `sohl` note.
274
+
275
+ **A hyphenated shortcode is no longer read.** The old parser split at the _first_
276
+ hyphen so a shortcode could contain one (`trauma-self-pro`); #1397 made every
277
+ segment `^[A-Za-z0-9]+$`, and the two rules cannot both hold. The charset rule
278
+ wins — it is enforced, and no tree has used the tolerance: 138,204 authored
279
+ shortcodes across four content trees, none carrying a separator.
280
+
281
+ **Verified against real content, not fixtures.** `content-build links` over
282
+ `Song-of-Heroic-Lands-FoundryVTT` (1,606 notes) and `sohl-thalorna` (1,852 notes,
283
+ which links into SoHL) reports the _same_ findings before and after — 43 and 122
284
+ respectively, identical file sets, no new failure. No authored link in any of the
285
+ four trees uses the retired form.
286
+
287
+ Two messages follow the grammar: an ambiguity now asks for the fully qualified
288
+ form rather than a package-qualified one that would not parse, and a labelled
289
+ non-address names both the local and the cross-package spelling.
290
+ - d17dc98: **A content-index record's Foundry address is keyed by the system that compiles
291
+ it.** `foundry.uuid` becomes `foundry.<system>.uuid` for a document a _game
292
+ system_ defines — an Item or an Actor — while a document the _note format_
293
+ defines keeps the unkeyed form.
294
+
295
+ ```json
296
+ "foundry": { "sohl": { "uuid": "Compendium.sohl.items.Item.…" } } // an item
297
+ "foundry": { "none": { "uuid": "Compendium.sohl.journals.JournalEntry.…" } } // a journal
298
+ ```
299
+
300
+ The vocabulary is the specification's — `sohl`, `hm3`, and **`none`** for a note
301
+ that belongs to no system, the same value the canonical address carries in its
302
+ `<system>` segment (`harnadventures-none-being-grod`, #59). Every record is
303
+ keyed, so "belongs to no system" and "nobody filled this in" are not the same
304
+ shape.
305
+
306
+ **A note may declare more than one system**, and each compiles into its own
307
+ document, of that system's type, in that system's pack: 2,497 of
308
+ `harn-ensemble`'s notes carry both a `sohl:` and an `hm3:` block, and its
309
+ `packs:` declares an `actors-sohl` _and_ an `actors-hm3`. One `uuid` on the
310
+ record cannot name two documents — it named whichever the single shipped map
311
+ produced and said nothing about the other.
312
+
313
+ Only `sohl` can appear today, because `KNOWN_DOCUMENT_SUBTYPE_MAPS` holds one
314
+ map and #139 tracks the missing `hm3/` half. **The shape changes now so that
315
+ adding it is one more key rather than a second breaking change** to an artifact
316
+ consumers have already started reading.
317
+
318
+ **A journal, a macro or a scene is `none`.** Those are the note format's own
319
+ documents rather than a system's, and the documentation journal an item
320
+ compiles beside itself is `none` too — it is one journal however many systems
321
+ the item declares. On `sohl` that is 1,474 records keyed `sohl` and 1,514
322
+ keyed `none`.
323
+
324
+ The uuid _values_ are unchanged: still equal to the link manifest's, verified
325
+ across `sohl`'s 2,988 addresses with no mismatch.
326
+
327
+ **Not synthesized onto the `sohl:`/`hm3:` blocks themselves.** Those are regions
328
+ a note authors, and `DERIVED_KEYS` — which refuses a note that writes over
329
+ derived data — reaches only the top level. A note authoring `sohl.uuid` would
330
+ collide silently, which is the failure this index exists to prevent.
331
+
332
+ ### Minor Changes
333
+
334
+ - ad813e1: **`lint:addresses` reads the content index too** (#243) — the second reader
335
+ converted, and the last of the two lints the issue names.
336
+
337
+ `addresses diff` reads the tree twice: once for the renames notes declare, once
338
+ to place its findings against the note that made them. Those were two
339
+ independent walks, each parsing every note, each answering "which files are the
340
+ corpus?" for itself. They are now **one** derivation — `indexRecordsFor`,
341
+ enumerated once by the command and handed to both — so the two halves of a
342
+ single command cannot disagree about the corpus, or about the ids in it.
343
+
344
+ **A latent defect goes with it, and the id is the reason it mattered.**
345
+ `noteFilesById` joins tree-side ids against ids read out of the _compiled
346
+ packs_. Since #270 an id is derived from the canonical address, whose first
347
+ segment is the content package — and the tree side derived it through
348
+ `resolveNoteId(fm)` with no package, which falls back to `contentPackage()` and
349
+ so to whichever configuration the working directory answers with. The compiled
350
+ side is produced by a compiler running on the configuration the _build_
351
+ resolved. Let those differ — under `PACKAGE_BUILD_CONFIG`, in a worktree, in a
352
+ test — and **every id fails to join**: every rename degrades to a withdrawal, and
353
+ every finding loses the note it should have been reported against. The
354
+ configuration is now passed in and both sides derive from the one that was
355
+ resolved.
356
+
357
+ **It is also faster, which is the shape of the win.** Two whole-tree walks
358
+ became one derivation: over `sohl`'s 1,685 notes the pair of reads goes from
359
+ about 2.8s to about 1.4s. Both maps are byte-identical to what the walks
360
+ produced — 1,685 ids, and the declared predecessors of five renames spanning
361
+ five item types, including the `projectilegear`/`missilegear` pair that a
362
+ declaration keys under both maps.
363
+
364
+ The scope requirement moves to a shared `assertStatedScope`, so a reader of the
365
+ content index refuses an unstated scope in the same words `walkMarkdownTree`
366
+ does. A pass reading the index makes the identical claim about which files it is
367
+ looking at and must be held to the identical rule; a quiet default there would
368
+ reintroduce the second answer #243 removed.
369
+
370
+ `declaredPredecessors` and `noteFilesById` take `config` and already-derived
371
+ `records`. Where the walk yielded nothing for a tree that is not there, so do
372
+ they.
373
+ - 9e4861c: An affiliation records what it answers to, where it sits, and what it holds sway
374
+ over (SoHL#1781).
375
+
376
+ **`relation` is now `relations`.** The field holds a _map_ of standings, one per
377
+ affiliation — its own description said so, and `resolveRelation` has always read it
378
+ that way. The singular named the many as one.
379
+
380
+ The retired spelling is still read, underneath the current one, and reported through
381
+ `RETIRED_FIELD_ALIASES` — the same three steps `img`/`image` and
382
+ `templatePriority`/`archetype` take. `relations` wins wherever a note writes both,
383
+ and the thrown message names whichever spelling the note actually used. Only
384
+ `affiliation` declares the field, so `relation` stays an ordinary unknown key on
385
+ every other type.
386
+
387
+ **Three fields are new**, and each was unexpressible before: the content format
388
+ specified them and no declaration could receive them.
389
+
390
+ | authored | emitted | |
391
+ | --------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
392
+ | `parents` | `system.parents` | The bodies it is subordinate to. A list, because an affiliation may sit under more than one at once. |
393
+ | `seat` | `system.seat` | Where its authority sits. |
394
+ | `domains` | `system.domain` | The places it holds sway over. |
395
+
396
+ `seat` is deliberately not `capital` or `headquarters` — each fits about half the
397
+ eleven subTypes, while a seat covers a polity, a guild, an order and a faith alike.
398
+
399
+ `domains` → `domain` is **authored plural, emitted singular**, which is what the
400
+ format's own mapping row states. The declaration carries both spellings so neither
401
+ side has to guess, and a note that authored `domain` would otherwise have compiled
402
+ to nothing.
403
+
404
+ `parents` and `domains` are separate because they are different relations —
405
+ _subordinate to_ against _holds sway over_ — and an earlier `parent.regions` grouped
406
+ the geographic one under the organisational one while also naming regions where a
407
+ guild's domain may be a single town.
408
+
409
+ Both lists ship `[]` rather than null: _refers to nothing_ is a value here — a
410
+ sovereign polity answers to nobody — not an absence. Blank rows, which a cleared
411
+ property editor leaves behind, are dropped rather than emitted.
412
+
413
+ **This needs the schema half to land with it.** SoHL declares `system.relations`,
414
+ `system.parents`, `system.seat` and `system.domain` in the same wave; an emitted key
415
+ the data model does not define is discarded at construction without a warning.
416
+ - 1274ff8: **The markdown note vocabulary drops the `gear` suffix** — `armorgear` → `armor`,
417
+ `concoctiongear` → `concoction`, `projectilegear` → `projectile` (#78).
418
+
419
+ Those three named the **SoHL document subtype** a note happened to compile into rather
420
+ than the thing the note is about. A note's `type` sits outside the `sohl:` and `hm3:`
421
+ blocks precisely because it belongs to no system, and HM3 already compiles a projectile
422
+ into a `missilegear` — so the suffix was never a fact about the note. `weapongear` keeps
423
+ its name: both systems call that document a `weapongear`, so it says nothing
424
+ system-specific. `armorlocation`, which maps to an HM3 Item and to nothing in SoHL at
425
+ all, is a first-class note type: the vocabulary is system-agnostic, not a list of what
426
+ SoHL happens to define.
427
+
428
+ **Nothing in a compiled pack moves.** The emitted document subtypes are unchanged — an
429
+ `armor` note still compiles into an `armorgear` Item — so no world, no compendium and no
430
+ `_id` is affected. Verified by recompiling `Song-of-Heroic-Lands-FoundryVTT` (331
431
+ `armorgear` and 18 `projectilegear` notes) and `sohl-thalorna` (71 `concoctiongear`) with
432
+ the released toolchain and with this one, and diffing every emitted document: 3,091 and
433
+ 2,605 files respectively, byte-identical, with the content trees untouched.
434
+
435
+ **Both spellings are read, and the retired one is reported — not refused.** This is the
436
+ first of the three steps `package:` took, and the rule `RETIRED_FIELD_ALIASES` already
437
+ states for a renamed _field_: a note carrying the old spelling compiles into exactly the
438
+ document it always did, so failing a build over it would red a tree that has done nothing
439
+ wrong. There are some 31,000 references to move, overwhelmingly `(type, shortcode)`
440
+ entries inside a being's `items:` list, and a consumer must be able to adopt the new
441
+ toolchain before its content does. `content-build lint` reports each one as a **warning**
442
+ naming the file, the line and what to write instead; the sweep and the refusal come after.
443
+
444
+ **Both sides of a reference resolve.** A being's embedded `(type, shortcode)` reference
445
+ carries the note vocabulary too, and those outnumber notes' own `type:` keys by roughly a
446
+ thousand to one — so `referencedSubtype` normalises alongside the note's own type. A
447
+ window that resolved notes but not references would have dropped 30,000 embedded items in
448
+ silence.
449
+
450
+ **Two lookups where the vocabularies now genuinely differ**, and both are translated
451
+ rather than joined by name:
452
+
453
+ | lookup | keyed by | what changed |
454
+ | --------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
455
+ | `sohl/default-item-art.mjs` | document subtype | `SohlItem.getDefaultArtwork` reads it with a Foundry `Item`'s own `type`, so the map stays on the document side and `sohl/item-builders.mjs` translates before asking |
456
+ | the published-schema check | document subtype | `compareFields` gets the `subtypeOf` seam it was given for exactly this, so `armorgear`'s findings still fire |
457
+
458
+ **Planning a sweep? Two things to move with the frontmatter.** A note's canonical address
459
+ — and therefore its document `_id` — carries its `type` as authored, so renaming a note's
460
+ type moves the address it publishes at. Every wikilink into it and every content-table
461
+ query naming the type (`WHERE type = "armorgear"`) has to move in the same change, and a
462
+ package that _links into_ another has to move with it.
463
+ - d17dc98: **A `#section` link is checked by the build that emits it, not only by the
464
+ checker** (#193). `content-build links` reported a dead anchor as an error while
465
+ the pack compilers hashed _any_ slug into a `JournalEntryPage` id and emitted a
466
+ `@UUID` for it — so a link the checker refused still compiled, and dead-ended
467
+ for the reader.
468
+
469
+ A **foreign** anchor was already checked, because a vendored manifest publishes
470
+ an `anchors` map. A local one was not, for the reason #193 gives: neither index
471
+ held the set. The walk that builds the link index yields each note's body and
472
+ nothing read it.
473
+
474
+ It reads it now, through the one anchor reader, and reports `unknown-anchor`
475
+ with the message the foreign path already used — nothing new is named.
476
+
477
+ An index built without anchors still says nothing, which is deliberate: it
478
+ cannot answer the question, and answering it wrongly is what this fixes.
479
+
480
+ `collectAnchors` moves to `engine/anchors.mjs`, a module that imports nothing.
481
+ It has to: the link checker, the content index and the compilers all ask this
482
+ question, and `helpers.mjs` is imported by the compilers while the index imports
483
+ the manifest emitter, which imports them back. A leaf is what lets all three
484
+ share one reader instead of two disagreeing ones.
485
+ - b67a786: **A `bundle` note compiles into a Foundry `Adventure`** (#259).
486
+
487
+ #263 specified and declared the type and left it uncompiled — authoring one said
488
+ so, in as many words — because two decisions came first, neither answerable from
489
+ the specification. Both are settled here, and the pass exists.
490
+
491
+ **Which pack.** Not the `adventures` **companion**. The scenes pass already
492
+ writes one Adventure per pinned place into that pack, and a companion is written
493
+ by its parent pass rather than routed to — the router refuses a note that
494
+ addresses one, and is right to. So a bundle lands in an ordinary Adventure pack,
495
+ routed and defaulted exactly as items and actors are, and the place-adventures
496
+ companion is left alone. A repository that authors bundles declares an Adventure
497
+ pack of its own; one that declares none is now told so by name, because `bundle`
498
+ is in the type table the unclaimed-note check reads.
499
+
500
+ **What a `contents` address names: the note's own document.** That is already
501
+ the router's rule for `pack:`, so there is one answer and not two. A note that
502
+ compiles into _two_ documents — an item and the JournalEntry its prose became —
503
+ puts the second in a bundle only when the bundle names it by its own `doc…`
504
+ address. Nothing is inferred; an address that resolves to nothing fails the
505
+ build, and a `folder` address is refused with a message of its own, since a
506
+ folder materialises in every pack holding something filed in it and so has no
507
+ single copy to take.
508
+
509
+ **An Adventure holds copies, not references**, so `contents` resolves against
510
+ _compiled output_ rather than against the content tree. The pass therefore reads
511
+ every other one, and **says so** — `Bundles.readsPackOutputOf` names Item,
512
+ Actor, JournalEntry, Macro and Scene, from which the generator derives the
513
+ compile order. An Adventure pack declared first in `packs:` still compiles last.
514
+
515
+ **A pack's `system:` constrains what its Adventures may hold**, and the
516
+ constraint is read from what the pack can see rather than computed from a
517
+ member's type — which has no single answer for the types both systems map, most
518
+ of them. A pack declaring `system: hm3` reads the HM3 packs and the neutral
519
+ ones, so a member publishing no HM3 document is **left out rather than
520
+ failing**, with a warning naming it. A pack declaring no system scopes nothing
521
+ away, so a member it cannot find is a dead address and fails.
522
+
523
+ **The note's prose becomes the Adventure's `description`** — the `HTMLField`
524
+ Foundry renders on the import card. A bundle is something you hand someone, so
525
+ its prose belongs on the document itself, which is why it earns no separate
526
+ documentation journal the way an item does. That was #263's third open question.
527
+
528
+ **A prebuilt pack is passed over rather than compiled.** Its per-document JSON
529
+ is checked in, so it has no pass and no note is routed into it — which
530
+ `content-config.mjs` already said by refusing `default: true` beside `prebuilt`.
531
+ It could not matter before: the only prebuilt pack in the wild holds Adventures,
532
+ and no compiler was registered for that document type, so it failed with "no
533
+ compiler for document type" whatever it was asked. Now one is registered, and
534
+ running a pass over it would wipe its generated JSON directory, write nothing,
535
+ and then report the empty pass as an error.
536
+ - 458fd89: **A note declares the shortcode it used to be published under, so a rename stops
537
+ reading as a withdrawal (#278).**
538
+
539
+ `(type, shortcode)` is a published interface — every satellite declaring
540
+ `itemCatalog: true` assembles its beings out of those addresses — and
541
+ `content-build addresses diff` exists to report what a build stopped publishing
542
+ before a release does. To be useful it has to name where an address _went_, and
543
+ it told a **rename** from a **withdrawal** by matching document ids across two
544
+ releases.
545
+
546
+ #270 removed the property that rested on. An id is now derived from the
547
+ canonical address, which carries the shortcode, so renaming a shortcode moves the
548
+ id too: both sides of the join move together, the match finds nothing, and the
549
+ rename is reported as a withdrawal with no successor named. It stayed exact for a
550
+ note that **pins** an `id` — but a pin has to be written _before_ the rename, by
551
+ an author who does not yet know they will make one.
552
+
553
+ An author who has just renamed a shortcode does know, so they say so:
554
+
555
+ ```yaml
556
+ type: weapongear
557
+ shortcode: Taburi
558
+ renamedFrom: Tabri
559
+ ```
560
+
561
+ **One shortcode or a list**, because renames chain and a released baseline may
562
+ know an address by a name two renames ago. **Transient**: once every baseline a
563
+ build is compared against post-dates the rename, the declaration may be deleted —
564
+ which is what separates it from an `id:` pin, which is permanent. **One key per
565
+ note, at the top level**, however many systems the note compiles into, since a
566
+ shortcode is the note's rather than a system block's.
567
+
568
+ **The diagnostic reports which join it had**, because the two are not equally
569
+ checkable — a matched id is a fact a reader can verify in both artefacts, while a
570
+ declaration is the author's word:
571
+
572
+ ```text
573
+ since sohl@0.8.2, weapongear:Tabri is no longer published; the note now
574
+ published as weapongear:Taburi declares it was renamed from Tabri. Every
575
+ package that resolves weapongear:Tabri breaks when it moves past sohl@0.8.2
576
+ ```
577
+
578
+ Three joins are tried, in that order of authority: the document id, then a
579
+ declaration, then nothing — which remains **withdrawn**. Nothing infers a
580
+ successor from a similar-looking string; a wrong one sends the reader to the
581
+ wrong fix.
582
+
583
+ **`content-lint` holds a declaration to the rules a current address is held to.**
584
+ An entry must be a well-formed shortcode, must not be the note's own, and must
585
+ name an address the package actually vacated: an entry naming an address some
586
+ note still publishes is refused, as are two notes claiming one predecessor, since
587
+ an address had one holder and so has one successor. A repeated entry is a
588
+ warning — the declaration still works.
589
+
590
+ **Also fixed: `addresses diff` threw whenever it had a finding to place.** It
591
+ called `noteFilesById` without `skipDirectories`, which `walkMarkdownTree`
592
+ refuses (#243), so the command worked only when it had nothing to report — the
593
+ one path nobody notices. Both of its tree reads now state the scope from the
594
+ resolved configuration.
595
+ - 4926fb8: **The `bundle` note type is specified and declared** (#259).
596
+
597
+ A bundle is a set of documents taken as a unit — Foundry's `Adventure`, named for
598
+ what it is rather than what Foundry calls it. It declares one property of its
599
+ own, `contents`: the documents it holds.
600
+
601
+ **How many Adventures a bundle makes is decided by its system blocks**, as for
602
+ every other type, rather than by a property of its own. With no system block it
603
+ is one Adventure holding only the `none` documents; with one or more it is one
604
+ Adventure per system, each holding every `none` document plus that system's own,
605
+ and a document of neither is silently left out.
606
+
607
+ Each Adventure is written to the pack the note's `pack` names — **the shared
608
+ routing field, not a property of the bundle**, so there is one spelling and not
609
+ two. It differs only in its default, `adventures`; `<system>.pack` overrides it
610
+ per system exactly as it does everywhere else.
611
+
612
+ That follows from Foundry rather than from taste: **an `Adventure` has no
613
+ `system` field**. A bundle spanning two systems cannot be one document that knows
614
+ it spans them, so it is one document per system and the pack each is written to
615
+ is what carries the system.
616
+
617
+ **It is not a folder**, and the difference is the whole point: an Adventure
618
+ carries **copies**, and importing one creates or updates each document in the
619
+ world, after which they live independently. A folder is a live grouping, by
620
+ reference, that persists in the pack.
621
+
622
+ **Nothing compiles a bundle yet**, and authoring one says so. Two decisions come
623
+ first: the scenes pass already writes an Adventure per place into a _companion_
624
+ pack and the router refuses a note naming a companion in `pack:`, so the
625
+ `adventures` default cannot be that pack as things stand; and since an Adventure
626
+ holds compiled documents rather than references, `contents` has to resolve after
627
+ the passes that produce them.
628
+ - 0df2870: **Every declared note type is routed, or excused for a stated reason** (#243,
629
+ #241) — asserted statically, of the toolchain, so it no longer depends on some
630
+ repository happening to author the type.
631
+
632
+ This is the check #241 needed and nobody had. `place`, `lore` and `scenario`
633
+ were declared, validated, and claimed by no pass; every gate reported success,
634
+ and the only thing that noticed was a downstream repository failing to compile
635
+ 450 notes. The claim table was already cross-checked against each pass's
636
+ `selects`, but that agreement holds just as well when **both** say nobody claims
637
+ a type — which was exactly the broken state. The missing property is not
638
+ agreement, it is **coverage**.
639
+
640
+ A declared type must now be one of four things, and the four name different
641
+ reasons rather than being interchangeable: claimed by a pass; **never packed**
642
+ (compiles to no document — `homepage`); **derived packed** (materialises by
643
+ reference in every pack that references it, so no one pass owns it — `folder`);
644
+ or declared by a shipped **system map**, so a configuration carrying that
645
+ system's packs claims it (`armorlocation`, which is HM3's).
646
+
647
+ **`UNIMPLEMENTED_TYPES` is new, and it is stated rather than inferred.** An
648
+ unimplemented type and a forgotten one look identical from outside: documented,
649
+ validating, reaching no pass. Only intent separates them, so intent is written
650
+ down. The obvious inference — "declared, but absent from the configured
651
+ vocabulary" — reads correctly and is worthless, because that vocabulary is
652
+ _derived from the routing_: take a type's route away and it leaves the
653
+ vocabulary too, so the inference excuses precisely the mistake it was meant to
654
+ catch. That was verified by putting `place` back into its #241 state, where the
655
+ inferred form passed and the stated form fails.
656
+
657
+ `vehicle` is its one member, and `unclaimedNoteFindings` now chooses its
658
+ "specified, not implemented" wording from the same set rather than from a second
659
+ reading of the same fact.
660
+
661
+ _No behaviour change: over `sohl`'s tree the compile emits the same 3,085
662
+ documents with identical diagnostics, and `lint` reports the same 354 findings._
663
+ - adb2e90: **A folder is a note** (#256), and `packFolder:` names one by **address** (#255).
664
+
665
+ ```yaml
666
+ ---
667
+ type: folder
668
+ shortcode: possessionscooking
669
+ name:
670
+ full: Cooking
671
+ data:
672
+ parent: possessionsmiscgear
673
+ color: "#7a4b2a"
674
+ ---
675
+ ```
676
+
677
+ ```yaml
678
+ packFolder: possessionscooking # on any note that files itself there
679
+ ```
680
+
681
+ A `Folder` was the last document this package compiled from bespoke
682
+ configuration — `*-folders.yaml`, five files per tree — rather than from a note.
683
+ That was the one hole in the rule #243 establishes, _the compiler follows the
684
+ index_: a pass cannot follow the index for things the index does not contain.
685
+
686
+ **`parent` is an address**, so a dangling one is an ordinary dead-address finding
687
+ rather than a special-cased `Unknown folder id`, and a cycle is refused. Both are
688
+ reported when the tree is read, not when something happens to reference the
689
+ folder that carries them. A folder is addressed `<package>-none-folder-<shortcode>`
690
+ — `none`, because a `Folder` is a core Foundry document like a `JournalEntry`,
691
+ not a system's.
692
+
693
+ **`parent` may be a map keyed by pack.** A folder's identity is one thing and its
694
+ hierarchy is another: the same folder is deliberately filed under different
695
+ parents in different packs, and both large trees rely on it. This repository
696
+ files its three item roots one level deeper in the journals pack (under
697
+ `Rules/Descriptions`, beside `Rules/Combat`); `sohl-thalorna` groups the items
698
+ pack by document kind and the journals pack by setting geography, and 46 of its
699
+ 75 shared folders differ. A scalar — the everyday spelling — is exactly
700
+ `{ default: <value> }`, and the folder keeps one id across every pack whatever
701
+ its parent there.
702
+
703
+ **Where a folder materialises is derived from what references it** (#257). Every
704
+ pack holding a document that names a folder gets that folder, and its ancestors
705
+ with it; a folder nothing references materialises nowhere.
706
+
707
+ That removes a live defect rather than reporting it. A documentation journal is
708
+ filed beside the item it describes by putting the item's folder id into the
709
+ _journals_ pack — which only worked where a second folder file mirrored the
710
+ first, and it mirrored in one tree of three:
711
+
712
+ | tree | item folders | in journal folders | missing |
713
+ | --------------------------------- | -----------: | -----------------: | -------------: |
714
+ | `Song-of-Heroic-Lands-FoundryVTT` | 57 | 57 | 0 |
715
+ | `sohl-thalorna` | 132 | 75 | **57** |
716
+ | `sohl-kethira-basic` | 6 | — | **6**, no file |
717
+
718
+ Both emitted documentation journals into folders their own pack never declared,
719
+ silently. With one folder note and one address there is no second file to
720
+ disagree with the first, so the failure is unrepresentable rather than merely
721
+ caught.
722
+
723
+ **A folder's Foundry `_id` is derived from its address** (#258), stable across
724
+ runs, so a new folder needs no invented id. An **authored `id` is kept** where
725
+ one is present — which is what lets a tree sweep its folder YAML into notes
726
+ without a world that already holds those folders losing them, making this a build
727
+ change rather than a world migration. Two folders claiming one id is a build
728
+ error.
729
+
730
+ **`packFolder` was a path for one release and never shipped as one.** #252 landed
731
+ `Possessions/Misc_Gear/Cooking` and its changeset is still pending, so no
732
+ released version ever read a path. A path encoded the hierarchy _in the value_,
733
+ so reparenting a folder rewrote every note naming it; an address is stable under
734
+ reparenting, which is why a note is addressed by `(type, shortcode)` and never by
735
+ `file.path`. The path form is removed rather than deprecated — it had no authors
736
+ to migrate, which is the whole reason the change was cheap enough to make.
737
+
738
+ **`folder:` is untouched**, and every tree still compiles from its
739
+ `*-folders.yaml` exactly as before: the SoHL tree's 3,094 compiled documents are
740
+ byte-identical across this change. Retiring the id spelling and the YAML schema
741
+ is #260, after each tree has swept.
742
+ - 72ce6c2: **The HM3 pass reads the template priority wherever a note states it** (#266).
743
+
744
+ `flags.hm3.templatePriority` was resolved as an ordinary declared field, whose
745
+ shared source is a **single** position — so only a bare top-level
746
+ `templatePriority` ever answered. The one position that did not work was
747
+ `data.templatePriority`: the specified home, the target of the settled mapping
748
+ table, and the home this pass's own docstring already claimed to read.
749
+
750
+ **It failed silently, and could only fail silently.** A note that is not a
751
+ template writes no flag, so an omitted flag is how "not a template" is spelled —
752
+ which makes a priority that was lost and a priority that was deliberately
753
+ withheld the same output. There is no tri-state left for a diagnostic to notice,
754
+ and nothing downstream can tell the two apart.
755
+
756
+ The priority is a **shared, note-level fact** — one statement both systems
757
+ record, SoHL as `system.templatePriority` and HM3 as
758
+ `flags.hm3.templatePriority` — so it is now read through the same resolver the
759
+ SoHL passes use, against the block being compiled: `data:`, this system's block,
760
+ the top level, and the retiring `archetype` spelling in the latter two. A note
761
+ carrying both spellings with different values is refused here exactly as it is
762
+ for SoHL, so the two systems cannot disagree about what a note said.
763
+
764
+ **It is read against _this_ block, not the `sohl:` one.** A tree still stating
765
+ the priority in `sohl:` writes no HM3 flag — `harn-ensemble` is that tree, on
766
+ 2,502 notes — and gets one when it sweeps to `data:`, which is step 2 of #266's
767
+ migration. This change is the prerequisite for that sweep rather than a
768
+ substitute for it: without it, a tree that swept to the specified home would
769
+ have gone from a flag that worked by accident to no flag at all.
770
+
771
+ `resolveTemplatePriority` takes the block as an option, and
772
+ `statedTemplatePriority` is its tolerant sibling for a system that treats an
773
+ unstated priority as "not a template" rather than as an authoring error.
774
+ - 209e633: **The `hm3/` half of the toolchain** — a note can now compile an HM3 document (#139).
775
+
776
+ `sohl/` was the only system half this package had, so `itemBuilders: [sohl, hm3]` — an
777
+ arrangement `CONTENT.md` already documented — named a registry that did not exist, and no
778
+ note could produce an HM3 Actor or Item however its frontmatter was written. `hm3/` is now
779
+ a sibling of `sohl/`: its own item vocabulary and builders, its own default art, its own
780
+ note-type → document-subtype map declared through the same `defineDocumentSubtypes`, and
781
+ its own Item and Actor compilers. The two halves import nothing from each other; the only
782
+ thing they share is the engine between them.
783
+
784
+ **A pack's `system:` now selects the compiler.** It already selected the `_stats` stamp, the
785
+ item catalogue a being resolves against and the `itemBuilders` lookup; the Item and Actor
786
+ passes were still SoHL's whatever a pack declared. So a note carrying both a `sohl:` and an
787
+ `hm3:` block compiles **one document in each system**, each shaped by its own builders and
788
+ stamped with its own system version — and a note carrying only one block is passed over by
789
+ the other system's pass rather than failed for a block it was never going to have.
790
+
791
+ **Four HM3 rows are one-to-many, and the note says which.** `mysticalability` becomes a
792
+ `psionic`, a `spell` or an `invocation`; `trauma` an `injury` or a `trait`; `weapongear` a
793
+ `weapongear` or a `missilegear`; `being` a `character` or a `creature`. The note writes
794
+ `hm3.type`; nothing is inferred from its `subType`, and a note that says nothing is an error
795
+ naming the note and listing the permitted values. `docs/content-format.md` is corrected to
796
+ match — it described the `mysticalability` split as derived from a `subType` vocabulary that
797
+ no longer contains the values the derivation named.
798
+
799
+ **The five shared names cannot cross over.** `skill`, `weapongear`, `armorgear`,
800
+ `containergear` and `miscgear` exist in both systems with different data models, so each
801
+ resolves through its own system's map, is built by its own system's registry, and is
802
+ field-checked against **its own** system's published `schema.json`. That last one is new:
803
+ `resolveSchemaArtifact` took the package-wide `stats.systemId`, which is deliberately unset
804
+ in a two-system build — so every schema check in such a build was skipped in silence.
805
+
806
+ **Shared machinery moved to `engine/`, unchanged.** `engine/item-compiler.mjs` and
807
+ `engine/actor-compiler.mjs` now hold what is note-format knowledge rather than game-system
808
+ knowledge, and `engine/anchored-sections.mjs` holds the `{#appearance}` / `{#dossier}`
809
+ convention. `sohl/items.mjs` and `sohl/actors.mjs` are what is left: SoHL's map, and the
810
+ `system` block SoHL's data model wants. Compiled output is byte-identical — verified by
811
+ recompiling `Song-of-Heroic-Lands-FoundryVTT`, `sohl-thalorna` and `sohl-kethira-basic`
812
+ before and after and diffing every emitted document.
813
+
814
+ `npm run lint` now also checks HM3's column of the content format: the specification's
815
+ `→ hm3` mapping claims against HM3's published `schema.json`, and its per-type tables
816
+ against the new field declarations.
817
+ - f2e6e45: **The link check reads the content index instead of walking the tree itself**
818
+ (#243) — the first reader converted, and the one #243 nominated.
819
+
820
+ `buildLinkIndex` answered "which files are the content?" for itself: its own
821
+ walk, its own frontmatter parse, its own address derivation. That is the shape
822
+ #243 is closing — ten passes each deriving the corpus independently, agreeing
823
+ only by inspection. It now reads
824
+ {@link module:engine/content-index.indexRecordsFor}, the same derivation the
825
+ published artifact, the `sql` tables and the compilers already run on.
826
+
827
+ **It still opens each note — for its prose, and nothing else.** The index
828
+ deliberately carries no body, and a link lives in the body. Everything _about_
829
+ the note is in the record. That is one read per note rather than two: the walk
830
+ read the file, and this module then read it again for the raw text.
831
+
832
+ **Two defects go with it.**
833
+
834
+ - _The package a local address carries came from the ambient configuration._
835
+ `buildLinkIndex` was handed a `config` and then called `contentPackage()`,
836
+ which resolves `loadPackConfig()` from the working directory. The two are the
837
+ same object in an ordinary build and different ones under
838
+ `PACKAGE_BUILD_CONFIG`, in a worktree, or in a test — so the checker could
839
+ build canonical addresses for one package while the manifest it was checking
840
+ built them for another, and every cross-package link would resolve nowhere for
841
+ no visible reason. The package now comes from the configuration the caller
842
+ passed.
843
+ - _Each command derived the corpus twice._ `lint`, `links` and `reachability`
844
+ each built a link index _and_ prepared their `sql` tables, and both walked. The
845
+ records are now derived once per command and handed to both, so a table and a
846
+ wikilink cannot disagree about which notes exist. `reachability` also resolved
847
+ its configuration twice and passed neither to the passes below it; it resolves
848
+ one and passes it on.
849
+
850
+ **Findings are unchanged, and their order is now stable.** Over `sohl`'s 1,685
851
+ notes the `links` findings are identical as a set and `lint`'s output is
852
+ identical byte for byte; what moved is that diagnostics now come out in content
853
+ path order rather than directory-read order, which was never a fact about the
854
+ content.
855
+
856
+ `indexRecordsFor` takes the walk's scope as an argument, so a caller that was
857
+ handed one passes it on rather than having it replaced by whichever its
858
+ configuration carries; `prepareTreeSqlTables` and `buildLinkIndex` take
859
+ already-derived `records`. `authoredFrontmatter` and `isNoteRecord` are exported
860
+ beside `DERIVED_KEYS`: a record is a note's frontmatter _plus_ the derived keys,
861
+ and a note authoring one of them fails the walk, so recovering what the author
862
+ wrote is exact rather than best-effort — which is what lets a pass read the
863
+ corpus from the index and still lint what was typed.
864
+ - d17dc98: **`package-build yaml` lints note frontmatter and every YAML file** (#248).
865
+
866
+ Frontmatter carries a note's type, shortcode, address and system blocks, and
867
+ nothing checked it _as YAML_. Worse than unchecked: `parseMarkdownFile` caught a
868
+ parse failure, logged it at `warn`, and returned `{frontmatter: null}` — which is
869
+ not a note with bad frontmatter but, to every pass downstream, a file with no
870
+ frontmatter. A duplicate key did not fail a build; it removed a note from the
871
+ corpus while the build reported success. The parser had detected it all along.
872
+
873
+ **Frontmatter reaches ESLint through a processor**, the mechanism
874
+ `eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is its easy
875
+ case — the block is always at the top of the file, so a finding maps back with a
876
+ constant `+1` for the opening `---` and no offset table.
877
+
878
+ **The rule set is deliberately narrow**, as the markdown and stylesheet ones are.
879
+ Prettier already owns YAML's whitespace, quoting and line breaks, including
880
+ inside a fence, so what is left is the class a formatter cannot see: text that
881
+ parses to something other than what it looks like. Parse errors, plus
882
+ `no-empty-mapping-value`, `no-irregular-whitespace`, `no-empty-key` and
883
+ `no-empty-document`. `folder:` and `folder: null` are one value and two opposite
884
+ statements — a decision, or a key somebody began and did not finish — and a key
885
+ with a block under it is not empty.
886
+
887
+ **GitHub workflows are exempt from the empty-value rule.** `on:` `push:` carries
888
+ its meaning by being present; `push: null` would be worse YAML, not better.
889
+
890
+ **A consumer changes one line** — `"lint:yaml": "package-build yaml"` — and needs
891
+ no `eslint` dependency, no `eslint.config.js` and no rule configuration.
892
+ package-build owns the tool and the config exactly as it owns markdownlint's, and
893
+ `overrideConfigFile: true` leaves a repository's own ESLint unconsulted.
894
+
895
+ Adoption costs 92 findings in total: 65 in `sohl-thalorna`, 20 in
896
+ `sohl-kethira-basic`, 5 in `harn-ensemble`, 2 in
897
+ `Song-of-Heroic-Lands-FoundryVTT`, none in `harn-adventures`. No live content
898
+ tree carries a parse error — every one found was in a `nogit/` archive — so this
899
+ is a missing guard rather than an overdue one.
900
+ - 6368283: **The template priority is read as `templatePriority`, and `archetype` is
901
+ retiring** (#266).
902
+
903
+ The number deciding which of several competing templates the Create dialog
904
+ offers was called `archetype` — one letter from `archetypes`, which is a list of
905
+ what _sort_ a character is. A priority and a taxonomy cannot be told apart by a
906
+ plural `s`.
907
+
908
+ Both spellings are read, `templatePriority` winning, and **the retiring one is a
909
+ lint error**. Unlike the other retired alias, `archetype` is not a field of its
910
+ own — it is `templatePriority` under its prior name, and both sit one letter from
911
+ `archetypes`, which means something else entirely. A tree still on it is one
912
+ where a priority and a taxonomy are told apart by a plural `s`, which is worth
913
+ stopping rather than mentioning.
914
+
915
+ The compile is unaffected — both spellings are read, so every tree keeps
916
+ compiling — but `content-build lint` refuses a tree until it is swept, and
917
+ **5,727 notes across four trees** author the old key.
918
+
919
+ It is read from `data.templatePriority` first, which is where the specification
920
+ puts it and where `sohl-thalorna` already writes it on 941 notes — so a tree that
921
+ has authored forward is read from the key it authored.
922
+
923
+ **A note declaring both spellings with different values is refused.** That is not
924
+ hypothetical: **145 of those 941 say `templatePriority: null` where `archetype:
925
+ 0` says the opposite** — "not a template" against "a template at priority 0".
926
+ Preferring either silently would decide that for the author, so the build names
927
+ both values and asks. `0` and `null` are distinct and both valid, which is
928
+ exactly why a note cannot claim both.
929
+
930
+ The emitted field is unchanged: SoHL's data model still declares
931
+ `system.archetype`, and `Song-of-Heroic-Lands-FoundryVTT#1836` renames it there.
932
+ - 18bb812: **The walk's scope is stated by its caller, never resolved from the working
933
+ directory** (#243).
934
+
935
+ `walkMarkdownTree` defaulted `skipDirectories` to `loadPackConfig()`'s — so an
936
+ unscoped caller read whichever configuration resolved from the working directory
937
+ rather than the one it was working under. **Six of its twelve callers were on
938
+ that default**, which means two passes over one tree could disagree about which
939
+ files they were reading. In an ordinary build those are the same object and
940
+ nothing shows; they are not the same when a test injects a configuration, when
941
+ `PACKAGE_BUILD_CONFIG` names one, or when a command runs from a worktree.
942
+
943
+ This is the defect class #240 fixed for `entriesForNote` reading `docEntryTypes`
944
+ from the ambient config — found only because a fixture had been passing on the
945
+ leak for as long as it existed.
946
+
947
+ The parameter is now **required**, so the omission is an error rather than a
948
+ quiet second answer, and `BasePackCompiler` requires it too: a pass that omitted
949
+ it would walk whatever the working directory said, including — in a tree
950
+ configured to skip `Templates`, as `Song-of-Heroic-Lands-FoundryVTT` is — the
951
+ template notes that configuration exists to keep out of the packs.
952
+
953
+ `Scenes` was dropping it between its own constructor and `super`, which is
954
+ exactly the kind of silent gap a default hides and a requirement does not.
955
+
956
+ _No consumer calls `walkMarkdownTree`, so this is internal despite the signature
957
+ change._
958
+ - a5922b7: **The five declarations that re-read the note now see `<system>.system`**
959
+ (#126) — the last mechanism gap before the corpus can move.
960
+
961
+ Most fields take the value `resolveFieldValue` hands them, so they already
962
+ resolve at `<system>.system.<to>` first. Five do not. `subType`, `charges`, a
963
+ mystery's `skillAptitudes`, an affiliation's `relations` and a projectile's
964
+ impact die each validate a **shape spread over several keys**, so their `read`
965
+ re-reads the frontmatter — through `sohlField`, which sees `sohl.<key>` and the
966
+ note's top level and **never inside `sohl.system`**.
967
+
968
+ That was equivalent while every note authored in the block. The moment a note
969
+ authors at the destination instead, those five read as unset: a missing
970
+ `subType` is a thrown build error, and `charges`, `skillAptitudes` and
971
+ `relations` ship empty — in silence, which is the failure class the passthrough
972
+ exists to prevent.
973
+
974
+ `sohlSystemField` reads the destination first and falls back to the legacy
975
+ position, so both spellings work while the corpus moves. It takes an optional
976
+ `legacyKey` for the one pair spelled differently at the two positions — a
977
+ projectile authors `impact.die` and stores `impactBase.die` — which is the same
978
+ split `FieldSpec.name`/`legacyKey` makes, for the same reason: one name cannot
979
+ key two positions.
980
+
981
+ The retired-alias probe in `resolveRelation` moves with it. It asked
982
+ `sohlField` whether `relations` was authored in order to choose between the
983
+ current and retired spelling; a note that had moved to `sohl.system.relations`
984
+ carried the current name where the probe could not see it, so it fell through to
985
+ `relation` and read `{}`.
986
+
987
+ Nothing else changes: every note authoring in the block reads exactly as before,
988
+ which the suite pins alongside the new position.
989
+ - f2bf737: **A note the content index cannot record is reported, not thrown** (#243) —
990
+ with a line, a column, and the right correction.
991
+
992
+ Converting the link check (#290) and the address diff (#292) to read the index
993
+ handed those passes the index's one hard failure. A note authoring a key the
994
+ index derives — {@link DERIVED_KEYS} — aborted the whole pass, so **one
995
+ malformed note took every other finding in the tree with it**, and the reader
996
+ got a bare `Error` where the project's other diagnostics give
997
+ `file:line:column: severity: message`. This is precisely what #243 lists as the
998
+ thing that must not regress, and it did.
999
+
1000
+ Concretely, over a tree holding a legacy `package:` note and an unrelated dead
1001
+ link:
1002
+
1003
+ | | reported |
1004
+ | ----------------------- | -------------------------------------------------------------------- |
1005
+ | _before the conversion_ | the dead link; nothing about `package:` |
1006
+ | _after it_ | ``Skills/Legacy.md: `package:` is derived …`` — and **nothing else** |
1007
+ | _now_ | both, each with its position |
1008
+
1009
+ **A reader collects; the emitter still refuses.** `collectContentIndex` and
1010
+ `indexRecordsFor` take a `problems` array: given one, a note that cannot be
1011
+ recorded is pushed as a diagnostic and skipped, and the derivation continues.
1012
+ Given none they throw exactly as before — which is the contract
1013
+ `emitContentIndex` needs, since an index quietly missing a note asserts that the
1014
+ note does not exist. `buildLinkIndex`, `declaredPredecessors` and
1015
+ `noteFilesById` pass the array through, and `lint`, `links`, `reachability` and
1016
+ `addresses diff` emit what it collects and exit non-zero — an error whatever the
1017
+ command's own strictness flag says, because the note is absent from every answer
1018
+ those commands give.
1019
+
1020
+ **`package:` gets its own words back.** It is on the derived list, but it is not
1021
+ a name collision — it is a **retired field** (#56), and the fix is to delete it,
1022
+ not to rename it. `assertNoDeclaredPackage` has said so, correctly and with a
1023
+ position, since the field was retired, and had **no caller**: the generic
1024
+ "rename the frontmatter field" was the only message anyone saw, and it was
1025
+ wrong. The index now defers to it, so one mistake has one message rather than
1026
+ two that disagree about the fix.
1027
+
1028
+ _No change to any tree that has no such note: `sohl`'s 1,685 notes produce
1029
+ byte-identical `links`, `lint` and address-map output._
1030
+ - d17dc98: **`place`, `lore` and `scenario` compile into JournalEntries.** They are in the
1031
+ published content format and #233 declared them for validation, but nothing
1032
+ routed them: `PACK_BY_TYPE` did not name them, so the open-set default sent
1033
+ them to the items pack, and the journals pass did not select them. A note of
1034
+ one lint-ed clean and then compiled into nothing (#241).
1035
+
1036
+ `sohl-thalorna` could not compile a single pack for exactly this reason — 450
1037
+ errors, and _the same 450_ the linter had reported before it learned the types.
1038
+ The count being identical is the tell: nothing about the content changed, only
1039
+ which gate noticed. It now compiles 642 actors, and what remains are unrelated
1040
+ content faults.
1041
+
1042
+ The three are declared once, as `JOURNAL_TYPES` in `engine/ids.mjs`, and read
1043
+ from there by the pack router, the journals pass and the claim table — so the
1044
+ three cannot disagree about what a journal type is. The drift guard that
1045
+ asserts the claim table and each pass's `selects` agree is what caught them
1046
+ disagreeing while this was written.
1047
+
1048
+ **They carry no synthesized `doc<type>` entry**, which is the distinction the
1049
+ new name makes explicit: a journal type's whole document _is_ the journal, so
1050
+ there is no second document to address and nothing spells `docplace`. That is
1051
+ different from an item, a macro or a map, whose prose becomes a journal
1052
+ _beside_ another document and is addressed as `doc<type>`.
1053
+ - 10522bd: **State the shared mapping rows, and let the checker reach them (#275).**
1054
+
1055
+ `docs/content-format.md` § _Mappings every type shares_ promised eight rows that
1056
+ every one of the sixteen per-type tables omits "on the stated grounds that they
1057
+ appear here", and then stated none: both tables were header-only, from the commit
1058
+ that first committed the specification. The fields they cover were therefore
1059
+ specified nowhere, and the note pointing at them pointed at nothing.
1060
+
1061
+ Both tables now carry their rows. The shared table states `name.full`, `img`,
1062
+ `id`, `packFolder` / `folder`, `shortcode`, `data.templatePriority`, `actionDefs`
1063
+ and `notes`; the actor-types table states `data.portrait`. `being` and `vehicle`
1064
+ had restated `data.portrait` and `data.templatePriority` in their own tables, and
1065
+ no longer do — that duplication is what the section exists to remove.
1066
+
1067
+ **The rows are confirmed rather than asserted.** `parseContentFormat` read
1068
+ mapping tables only inside a `### type:` section, so a table standing before the
1069
+ first one was invisible and its rows were checked by nothing. A mapping table in
1070
+ that position is now the shared one — position is the whole distinction, since
1071
+ the document's own argument for stating these once is that they belong to no type
1072
+ in particular — and its rows become claims like any other.
1073
+ `npm run lint:content-format:schema` confirms 86 where it confirmed 84, the seven
1074
+ new ones being the shared tables' `system.*` targets.
1075
+
1076
+ A shared claim carries `shared: true` and is scoped to `the shared mappings`
1077
+ rather than to a type, which keeps it out of the per-type field-drift check (it
1078
+ has no field declaration to drift from) and reads as prose in a diagnostic:
1079
+ _the format maps `shortcode` in the shared mappings to `system.notAField` in
1080
+ sohl_.
1081
+
1082
+ Two asymmetries the rows exposed are stated beside them rather than smoothed
1083
+ over: `actionDefs` and `notes` are declared on every SoHL Item subtype and on no
1084
+ SoHL Actor, and SoHL's `system.docHtml` is an Item mapping with no shared source
1085
+ to be a row of. A third — HM3 carrying `flags.hm3.templatePriority` on an Actor
1086
+ and not on an Item — is a gap in the pass rather than in the table, and is
1087
+ tracked as #283.
1088
+ - 941b672: **A field's shared source and its legacy in-block key are two declarations**
1089
+ (#305) — so a field can move into `data:` without a flag day.
1090
+
1091
+ `FieldSpec.name` carried both jobs, and they came apart the moment `data:`
1092
+ (#128) put every type-specific fact under a container. `resolveFieldValue` reads
1093
+ four positions, and steps 2 and 3 were **both keyed on `name`**:
1094
+
1095
+ | step | position |
1096
+ | ---- | --------------------------- |
1097
+ | 1 | `<block>.system.<to>` |
1098
+ | 2 | `<block>.<name>` — in-block |
1099
+ | 3 | the shared source `<name>` |
1100
+ | 4 | the field's default |
1101
+
1102
+ So `name: "species"` reached `hm3.species` and could not see `data.species`,
1103
+ while `name: "data.species"` reached the shared source and could not see
1104
+ `hm3.species` — and each yielded the field's **default** wherever only the other
1105
+ position was authored. Silently: the field compiles, the document is emitted,
1106
+ and the value is simply gone.
1107
+
1108
+ Two things followed. The specification's shared→system mapping was implemented
1109
+ _nowhere_ — `grep 'name: "data\.'` returned zero hits across all 58 declarations,
1110
+ so rows the format states (`data.species`, `data.gender`, `data.occupation`)
1111
+ were read by nothing. And no safe transition existed: every other retirement here
1112
+ — `package:`, `image`, `archetype`, `relation` — works because **both spellings
1113
+ are read while the corpus moves**, and one property could not offer that.
1114
+
1115
+ **`legacyKey` separates them.** `name` is the shared source; `legacyKey` is the
1116
+ key the system block still carries, and step 2 keys on it. Absent, it falls back
1117
+ to `name`, so every declaration written before this resolves unchanged.
1118
+
1119
+ - **Both are read, the current position wins.** A note that has not been swept
1120
+ is still saying what it means.
1121
+ - **The legacy read is _reported_**, as a warning — at compile through
1122
+ `legacyKeyMessage`, and in the frontmatter lint — so a sweep has a progress
1123
+ signal, matching `RETIRED_FIELD_ALIASES`. A field that declares no `legacyKey`
1124
+ is not mid-sweep and is never reported; that would put a finding on every
1125
+ field of every note.
1126
+ - The lint accepts the legacy key as a key of the block, rather than reporting
1127
+ `sohl.species` as a property no `being` has against exactly the notes the
1128
+ sweep has not reached.
1129
+ - The format check normalizes `data.` on **both** sides, so a moved declaration
1130
+ still pairs with the specification row that states it.
1131
+
1132
+ **HM3's three actor rows are declared as the sources the format names** —
1133
+ `data.species`, `data.gender`, `data.occupation` — while keeping the in-block
1134
+ key every note writes. Measured over the four content trees: 2,512 `being` notes
1135
+ compile byte-identical to before, and all 2,512 read the legacy position, which
1136
+ is the count #126 has to take to zero.
1137
+
1138
+ This is the mechanism the `data:` half of #126 and all of #129 were blocked on.
1139
+ - 4926fb8: **A note whose type the format specifies but nothing compiles gets a finding of
1140
+ its own.**
1141
+
1142
+ The unclaimed-type check had two messages, and a third thing can be wrong — the
1143
+ only one that is not the author's fault. `docs/content-format.md` documents the
1144
+ type and the vocabulary declares its properties, so a note written against the
1145
+ published specification is correct; this toolchain simply has not implemented it
1146
+ yet.
1147
+
1148
+ It earns its own wording because the other two both mislead there. Naming a
1149
+ missing pack or registry sends an author to `package-build.config.yaml`, where
1150
+ nothing they can write will help; saying the type is unknown flatly contradicts
1151
+ the specification they read it in.
1152
+
1153
+ The message is chosen from the vocabulary rather than from a list of types, so
1154
+ each specified-but-unimplemented type is covered as it is declared.
1155
+ - 28ae4b3: **A content table can be written in SQL, queried over the content index** (#246).
1156
+
1157
+ Tables were written in Dataview's query language, chosen when the corpus lived in
1158
+ an Obsidian vault so a table rendered live while authoring. The vault is gone, and
1159
+ what remained was a hand-written parser and evaluator for someone else's language,
1160
+ kept faithful to semantics nothing checked it against.
1161
+
1162
+ The query is **real SQL, run by DuckDB** — not a dialect maintained here. That is
1163
+ the point: a partial reimplementation would accept some valid SQL and silently
1164
+ misread the rest, which is worse than an unfamiliar language because the boundary
1165
+ is invisible.
1166
+
1167
+ ```sql
1168
+ SELECT address.slug AS _ref,
1169
+ sohl.kbcat AS _section,
1170
+ name.full AS "Name",
1171
+ sohl.weight AS "Weight"
1172
+ FROM notes
1173
+ WHERE type = 'miscgear'
1174
+ ORDER BY sohl.kbcat, name.full
1175
+ ```
1176
+
1177
+ **`sohl.weight` and `name.full` read in a query exactly as a note authors them.**
1178
+ DuckDB reads the index as JSON and infers a `STRUCT` per nested object; a
1179
+ column-per-path table would force `"sohl.weight"` in quotes and a JSON column
1180
+ `sohl->>'weight'`. `union_by_name` is what makes it work across a corpus where
1181
+ every note type's system block differs.
1182
+
1183
+ **What SQL cannot say, the projection says.** Which column links, and where a
1184
+ section breaks, are decisions about output rather than relational operations, so
1185
+ they ride as underscore-prefixed aliases — `_ref` and `_section` — which are
1186
+ ordinary SQL, need no fence options, and sit where the author is already looking.
1187
+ `_section` is why one query replaces the forty near-identical blocks
1188
+ `Rules/Gear.md` needs today: it emits a headed table per distinct value, in the
1189
+ order the authored `ORDER BY` produced.
1190
+
1191
+ `sql allow-empty` and `sql section-level=3` ride on the fence, as `dataview
1192
+ allow-empty` already does, because both are statements about the directive rather
1193
+ than part of the query.
1194
+
1195
+ **Both spellings work.** Every one of the 177 tables in the corpus is still
1196
+ `dataview`; those now also report a warning naming this issue, and nothing else
1197
+ changes for them. Nothing is opened for a tree with no `sql` directive, so this
1198
+ costs a tree that has not converted one walk and no database.
1199
+
1200
+ _DuckDB is a **build** dependency, not merely a development one — every
1201
+ consumer's CI runs a compile. It adds about 114MB to an install._
1202
+ - 9d89f31: **A `sql` content table takes org-babel header arguments, and a dependency's
1203
+ notes are a schema** (#246).
1204
+
1205
+ **Header arguments.** Statements _about the directive_ — as opposed to the query
1206
+ — were an ad-hoc bare word (`allow-empty`) and a `key=value`
1207
+ (`section-level=3`), each matched by its own regex: a grammar only in the sense
1208
+ that two regexes are one, with no room for a third property that did not also
1209
+ invent a third spelling. They are now org-babel header args, written after the
1210
+ language:
1211
+
1212
+ ````
1213
+ ​```sql :section-level 3 :allow-empty
1214
+ ````
1215
+
1216
+ **The language word stays first and stays plain**, so GitHub, Prettier and every
1217
+ other markdown reader still highlight the block as SQL and ignore what follows.
1218
+
1219
+ The grammar is org's, which is a real one with a specification and an editor
1220
+ that completes it: a key is `:name` starting a word (so `:caption Gear: the
1221
+ tables` is one argument); a value runs to the next key, spaces included; a
1222
+ valueless key is `true`; a value may be `"quoted"` to hold a key-like word; a
1223
+ repeated key takes its last value. `parseHeaderArgs` lives in `code-fences.mjs`
1224
+ and every argument reaches the caller, so a property this module makes no use of
1225
+ is still readable — the point of taking a grammar rather than a regex per
1226
+ property.
1227
+
1228
+ The `sql` fence has never shipped, so both old spellings are simply gone rather
1229
+ than retired. `dataview` keeps its bare `allow-empty`: it is the retiring
1230
+ language and its grammar is frozen.
1231
+
1232
+ **A dependency is a schema.** Each package this one depends on is attached as a
1233
+ schema named after it, so a satellite can tabulate what it builds on:
1234
+
1235
+ ```sql
1236
+ SELECT name.full AS "Name" FROM sohl.notes WHERE type = 'skill'
1237
+ ```
1238
+
1239
+ This package's own notes stay at the unqualified `notes`, and one query may read
1240
+ both — joining your beings against the skills they cite is a `FROM` clause. It
1241
+ costs no fetch and no configuration: every dependency's published index is
1242
+ already in the metadata cache when a compile starts, because resolving addresses
1243
+ across packages needs it.
1244
+
1245
+ Which dataset a query reads is `FROM`'s job, not a fence property naming a file.
1246
+ A path in authored content writes a build artifact's name into the corpus, so
1247
+ renaming the artifact would mean sweeping every note that cited it — and _which
1248
+ dataset_ is exactly what SQL already has a clause for, the same rule that keeps
1249
+ `_ref` and `_section` ordinary SQL rather than fence options.
1250
+ - 3bf9020: **The address lint reads the content index too** (#243) — so
1251
+ `content-build lint` is now **one** derivation of the corpus rather than two.
1252
+
1253
+ The command already derived the index: its link check is built from it and its
1254
+ `sql` tables select over it. Then it walked the whole tree a second time to
1255
+ reach `lintContentTree`. One command, two answers to "which files are the
1256
+ corpus?", and findings reported side by side that were drawn from different
1257
+ ones. The records are now derived once by the command and handed to every pass
1258
+ below it, this one included.
1259
+
1260
+ **It lints what the author wrote.** A record is the note's frontmatter plus the
1261
+ keys the index derives, so the frontmatter is recovered with
1262
+ `authoredFrontmatter` — handing a lint the derived keys would have it reasoning
1263
+ about `address:` and `anchors:` as though someone had typed them.
1264
+
1265
+ **Faster, for the same reason it is more correct.** Over `sohl`'s 1,685 notes
1266
+ `content-build lint` goes from about 1.9s to about 1.3s: the second whole-tree
1267
+ walk is gone. Findings are byte-identical.
1268
+
1269
+ `lintContentTree` takes `config`, already-derived `records`, and the `problems`
1270
+ collector, so a note the index cannot record is reported with its position and
1271
+ the rest of the tree is still linted — the contract every converted reader now
1272
+ shares. It refuses an unstated scope in the same words the others do.
1273
+ - 9972484: **A compile read every note twenty times. Now it reads it twelve** (#243) —
1274
+ and the whole compile runs on one corpus rather than one per pass.
1275
+
1276
+ Measured over `sohl`'s 1,685 notes: **33,700 note reads**, exactly twenty each.
1277
+ Four per pass — the content-wide link index, the table-search corpus, the `sql`
1278
+ directive scan, and the pass's own walk — across five passes that convert
1279
+ wikilinks. Every one of those four is a pure function of the same three things:
1280
+ the tree, the scope, and the pack router. **None of the three varies between the
1281
+ passes of a single compile**, because `generatePacksJson` resolves one router
1282
+ and hands it to all of them. So the passes were computing the same answers over
1283
+ and over, and — the part that matters — each was free to compute a _different_
1284
+ one.
1285
+
1286
+ They are derived once now, in `buildCompileCorpus`, and every pass is handed the
1287
+ result. The compile loop enumerates the index rather than walking, then reads
1288
+ each note for its **prose**: the index deliberately carries no body, and none of
1289
+ the `bodyLine`/`bodyColumn` a diagnostic needs, so that read stays — it is a
1290
+ read the pass was already making. What it no longer does is decide for itself
1291
+ which files to make it over.
1292
+
1293
+ | over `sohl`'s tree | before | after |
1294
+ | ------------------ | -------------------- | ------------------------- |
1295
+ | note reads | 33,700 (20 per note) | **20,220 (12 per note)** |
1296
+ | compile wall time | ~10.5s | **~6.7s** |
1297
+ | emitted documents | 3,091 | **3,091, byte-identical** |
1298
+ | diagnostics | 33 | **33, identical** |
1299
+
1300
+ **The record accessors move to `engine/index-records.mjs`.** Deriving the index
1301
+ reaches the pack router and the manifest emitter, and those reach the compilers
1302
+ — so `engine/helpers.mjs`, which the compilers load, cannot import
1303
+ `content-index.mjs` without closing a cycle. Nothing about _reading_ a record
1304
+ needs that machinery: `noteFile`, `authoredFrontmatter`, `isNoteRecord` and
1305
+ `DERIVED_KEYS` are pure functions over a plain object. `content-index.mjs`
1306
+ re-exports them, so the split is an implementation detail of the import graph
1307
+ rather than a second place to look.
1308
+
1309
+ `buildContentLinkIndex` and `collectContentDocs` now **require** the corpus
1310
+ their caller holds, refused by `assertSuppliedCorpus` the way an unstated scope
1311
+ is refused by `assertStatedScope`. That is not a workaround for the cycle: a
1312
+ compile runs several passes over one tree, and requiring the answer to be handed
1313
+ in makes the sharing structural rather than remembered.
1314
+
1315
+ **Two ambient-configuration reads go with it**, the same class as every other
1316
+ one #243 has turned up: `buildContentLinkIndex` derived each note's id through
1317
+ `resolveNoteId(fm)` with no package, and `collectContentDocs` synthesised each
1318
+ row's `package` through `searchableFrontmatter(fm)` with none — both falling
1319
+ back to whichever configuration the working directory answers with, rather than
1320
+ the one the build resolved.
1321
+
1322
+ A note the index cannot record is reported and counted by the compile exactly as
1323
+ the compile loop reported it when the loop was the first to see it — it is the
1324
+ same refusal, deferring to the same `assertNoDeclaredPackage`; only which pass
1325
+ meets the note first has changed.
1326
+ - 5a476cd: **The rest of the compile reads the index too** (#243). With #300 this takes a
1327
+ compile from **20 note reads each to 9**, and from ~10.5s to ~5.4s over `sohl`'s
1328
+ 1,685 notes — with all **3,091 emitted documents byte-identical** and the 33
1329
+ diagnostics unchanged.
1330
+
1331
+ Four whole-tree walks go:
1332
+
1333
+ - **The scenes pass** collected every note to find map notes and item effects.
1334
+ It reads the shared corpus, and opens a file only for a map note's prose —
1335
+ three files in `sohl` rather than 1,685.
1336
+ - **The unclaimed-type check** (`note-claims`) walked before any pass ran, so
1337
+ the check that reports "no pack claims this type" was answering about a
1338
+ different corpus from the one the passes then compiled.
1339
+ - **The `sql` directive scan** discovered which notes carry a directive by
1340
+ walking. It still reads each body — the index carries no note text — but
1341
+ _which files_ is no longer a second answer.
1342
+ - **The folder-note index**, which is the interesting one; see below.
1343
+
1344
+ **Three more ambient-configuration reads**, the same class as every one #243 has
1345
+ turned up: the scenes pass built a fresh `packRouter()` twice rather than using
1346
+ the compile's, and the folder-note index took its package from
1347
+ `contentPackage()` rather than from the configuration the build resolved.
1348
+
1349
+ **A trap worth naming, because the next conversion will meet it.** A record's
1350
+ `id` is not "the id the author wrote". The index fills one in for every
1351
+ addressable note (#270), and `collectFolderNotes` treats `fm.id` as an
1352
+ **authored pin** that wins over the id it derives under the folder namespace —
1353
+ so handing it records would make every folder look pinned and file each one
1354
+ under an id the packs do not address it by. `pack-folder` caught it. The index
1355
+ cannot tell a pin from a derivation, so the folder notes are read from disk:
1356
+ selected by the index, frontmatter from the file. That is the settled rule of
1357
+ #298 doing real work — the file carries what the index deliberately normalises
1358
+ away — and it is 79 reads, not 1,685.
1359
+
1360
+ `unclaimedNoteFindings` now requires the corpus its caller holds, like
1361
+ `buildContentLinkIndex` and `collectContentDocs`.
1362
+
1363
+ _`collectFoundryEntries` keeps its walk deliberately: it has no production
1364
+ caller — the link manifest it served was deleted in #271 — so converting it
1365
+ would change an exported signature for no compile it takes part in._
1366
+ - e0228f5: **The site build reads the content index, which completes #243's conversion.**
1367
+ Every pass that reads this repository's content tree — four checks, the whole
1368
+ compile, and now the site — runs on one derivation of the corpus.
1369
+
1370
+ The site walked the tree **twice**, once for homepages and once for content
1371
+ pages, and each answered "which files are the content?" for itself. It derives
1372
+ the corpus once and hands it to both. The note is still read for its
1373
+ `{fm, body}`: the index carries no note text, and a page _is_ its text.
1374
+
1375
+ **The reordering everyone was warned about does not exist.** This module kept
1376
+ directory order deliberately — "a site's emitted pages should not reorder for no
1377
+ reason" — and index records are content-path ordered, so converting it looked
1378
+ like a change to shipped output. It is not, and this was measured rather than
1379
+ argued: over `sohl`'s tree the emitted mount is **byte-identical, all 1,749
1380
+ files**, and the build's 82 diagnostics are identical **including their order**.
1381
+ Emission order never reaches the mount — each page is written to its own file at
1382
+ an address derived from its frontmatter, and the first-writer-wins fallbacks
1383
+ that once made order load-bearing went with the bare `[[Name]]` form (#180).
1384
+
1385
+ A content-path order is also the better of the two: directory-read order is a
1386
+ fact about the filesystem rather than about the content, so it can differ
1387
+ between two checkouts of one tree.
1388
+
1389
+ **`collectTreePages` is deliberately not converted.** It walks an auxiliary tree
1390
+ (`site.trees`, the developer documentation) which is not the content tree and
1391
+ appears in no record — converting it would be reading the wrong index.
1392
+
1393
+ _The two content-tree walks that remain are both right where they are: the
1394
+ content index's own derivation, and `collectFoundryEntries`, which has had no
1395
+ production caller since the link manifest was deleted in #271._
1396
+ - d17dc98: **`title: ""` is now a warning** (#218).
1397
+
1398
+ The two art fields follow a rule — `null` falls back, `""` is blank on purpose —
1399
+ and `title` was kept off it because the top-level key simultaneously fed an
1400
+ affiliation's `system.title`, so `title: null` compiled the literal `"null"`.
1401
+ That collision is gone: the field declares `topLevelMeans`, and the two spellings
1402
+ no longer meet.
1403
+
1404
+ What is left is the page heading. The emitter is `fm.title ?? name`, so `""`
1405
+ survives, the page publishes with no heading, and it sorts to the front of its
1406
+ section landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
1407
+ in exactly that state.
1408
+
1409
+ A _warning_ rather than an error: the value is legal under the rule, and a page
1410
+ that genuinely wants no heading may keep it — it just has to mean it.
1411
+
1412
+ ### Patch Changes
1413
+
1414
+ - 72ce6c2: **`CONTENT.md` names the template priority by its settled name** (#266).
1415
+
1416
+ The shipped specification still described the field as `archetype`, written to
1417
+ `system.archetype`, and its worked example authored `archetype: 1` inside the
1418
+ `sohl:` block. All three were wrong in the same direction, and the example was
1419
+ the worst of them: it told an author to write the exact key the frontmatter
1420
+ linter now refuses, in a position that is no longer the field's home.
1421
+
1422
+ It now describes what the build does — `data.templatePriority` is the shared
1423
+ home, reaching `system.templatePriority` in SoHL and `flags.hm3.templatePriority`
1424
+ in HM3, with the legacy in-block and top-level positions still read and
1425
+ `archetype` read last and refused by the linter. The emitted-keys list names
1426
+ `templatePriority`, and the example authors it under `data:`.
1427
+
1428
+ The specification's one remaining use of `archetype` to mean the priority — an
1429
+ aside comparing a map's misplaced fields to it — says "the template priority"
1430
+ instead, since `archetypes` now means a different thing one letter away.
1431
+
1432
+ The schema fixture's comment no longer reads as though
1433
+ `Song-of-Heroic-Lands-FoundryVTT#1836` were outstanding. It is merged; the gap it
1434
+ describes closes when SoHL 0.8.4 publishes, since the newest published artifact
1435
+ (0.8.3) still declares `archetype`.
1436
+ - f97036b: **`kbcat` is documented** (#264), the last of the five universal keys the
1437
+ specification had never mentioned.
1438
+
1439
+ `pack` and the template priority were described in the previous release; `kbcat`
1440
+ was the sharpest of the three gaps and the one left. It is read 51 times across
1441
+ SoHL's knowledgebase layouts and authored on more than 1,300 notes, and
1442
+ `docs/content-format.md` said nothing about it at all — while already using
1443
+ `sohl.kbcat AS _section` as the worked example of a content table, so the
1444
+ specification demonstrated the key without ever defining it.
1445
+
1446
+ It is now described beside the compendium folder, as the one key in that section
1447
+ that answers _where does this appear_ for the web rather than for Foundry:
1448
+ `pack` and `packFolder` place a document in a compendium, `kbcat` places a page
1449
+ in a list. Nothing compiles it — it reaches a published page because frontmatter
1450
+ is copied onto that page, where a layout groups by it.
1451
+
1452
+ Three things an author cannot infer from the corpus are stated:
1453
+
1454
+ - **It is editorial, and independent of `subType`.** Neither is derived from the
1455
+ other, and `kbcat` both subdivides a subtype — `trauma`/`physcond` lists as
1456
+ `physdisability`, `physfeature` or `physprivations` — and renames one for
1457
+ display, `trauma`/`fear` listing under `phobias`. Most notes carrying a `kbcat`
1458
+ declare no `subType` at all, so a disagreement between the two is not an error
1459
+ to correct.
1460
+ - **The value is free-form and nothing validates it.** There is no configured
1461
+ list of categories, and the frontmatter check knows only that `kbcat` is a key
1462
+ every type may write. So a misspelled category is not a build error and is not
1463
+ dropped — it silently becomes a group of one.
1464
+ - **A note that writes none is dropped from the list entirely.** Grouping is by
1465
+ the key, so a page with no value falls in no group and is simply absent from
1466
+ the list page — not last, not under a fallback heading — with nothing reported
1467
+ at either build. Every note of a listed type in SoHL's tree carries one today
1468
+ and nothing here enforces that, which is one of the questions the content index
1469
+ exists to answer.
1470
+ - 09a2112: **`pack` and the template priority are documented** (#264).
1471
+
1472
+ `docs/content-format.md` is the published statement of what a note may write, and
1473
+ `pack` — one of the five universal keys, which every note type may write and the
1474
+ router has always read — appeared in it only as one incidental sentence inside
1475
+ another type's section.
1476
+
1477
+ It is now described where it belongs, beside the compendium folder: what it
1478
+ names, that `<system>.pack` overrides it for one system, that an unstated one
1479
+ falls back to the pack of its type marked `default: true`, and the three
1480
+ declarations that are refused — a companion pack, a pack nothing answers to, and
1481
+ a pack of another document type.
1482
+
1483
+ The **template priority** is documented too, and it needed more than a field
1484
+ description: it is a priority, and priorities only mean something against the
1485
+ ranges that divide them. Opening a Create dialog gathers candidates from the
1486
+ world and every matching compendium — other modules' included — filters them to
1487
+ the `(type, subType)` being created, dedups by `shortcode`, and takes the highest
1488
+ priority, breaking ties by nearest source and then a stable UUID. So the
1489
+ specification now states the reserved ranges: `0`–`98` for SoHL and HM3, `99`–`999`
1490
+ for other HeroicLands packages, `1000`+ for everyone else. Since the highest wins,
1491
+ anyone else's template always beats content shipped from here — which is the point.
1492
+
1493
+ It also records what the tri-state costs: the field is **required** on every note
1494
+ SoHL compiles into an Item or an Actor, because "not a template" has to be said
1495
+ rather than left out, and `0` is a real priority — the one SoHL's own templates
1496
+ ship at — rather than an absence. And that HM3 keeps it in `flags.hm3`, its data
1497
+ model having no field for it, where SoHL keeps it in `system`.
1498
+
1499
+ **The name is now `templatePriority` on all three sides** — the authored key,
1500
+ `system.templatePriority` and `flags.hm3.templatePriority`. It had been three
1501
+ different things: a note writes `archetype`, the mapping table said
1502
+ `data.templatePriority`, and the SoHL target said `system.template`. The
1503
+ specification and the schema fixture are normalized here; the build reads both
1504
+ spellings through the transition (#266), and the system's own rename is
1505
+ `Song-of-Heroic-Lands-FoundryVTT#1836`. That is more than a rename — it frees the
1506
+ word, because `archetype` is being repurposed for a different idea entirely: the
1507
+ _sort_ a character is, which is a kind and not a priority.
1508
+
1509
+ `kbcat` remains, and #264 tracks it: read 51 times across SoHL's knowledgebase
1510
+ layouts, and the specification has never mentioned it.
1511
+ - 0e8b414: **A folder's `parent` may be a map keyed by pack, and the lint now agrees**
1512
+ (#288). `folderFields()` has read both forms since #276 — a folder's _identity_
1513
+ is one thing and its _hierarchy_ another, and both large trees file the same
1514
+ folder under a different parent in the items pack and the journals pack. The
1515
+ vocabulary typed the field as a bare `LINK`, so `content-build lint` required a
1516
+ scalar and rejected every note using the form the specification prescribes: 46
1517
+ findings against `sohl-thalorna` and 3 here, exactly the notes #276 documents as
1518
+ its motivating cases and no others. Every note using the form was a finding, and
1519
+ no note using it was not.
1520
+
1521
+ `parent` is declared `scalar-or-map` now, and a map written in that form is
1522
+ checked **entry by entry** rather than as one value — the correction an author
1523
+ has to make is one pack's address, not the whole map, and quoting the map back
1524
+ named every entry that was right alongside the one that was not. An explicit `~`
1525
+ under a pack key still means _at the root there_, which is a different statement
1526
+ from saying nothing.
1527
+
1528
+ **A pack key naming no declared pack is a finding of its own.** Nothing checked
1529
+ it before, because the whole value was rejected before it was read. It is not a
1530
+ harmless surplus: the compile asks the map for the pack it is writing and falls
1531
+ back to `default` when there is no such key, so a mistyped `journal:` filed the
1532
+ folder wherever the default put it — exactly the hierarchy the key was written
1533
+ to override, and silently. `content-build lint` passes the configured pack names
1534
+ (companions included) for the check; a caller that supplies none makes no claim
1535
+ about the keys, as it already does for the vocabulary itself.
1536
+
1537
+ The specification's `### type: folder` table types `parent` as the scalar-or-map
1538
+ it is, so the two cannot disagree again from opposite sides of one field.
1539
+ - 655d901: **`content-format notes` reads the content index, and with it every check
1540
+ does** (#243).
1541
+
1542
+ It was the last one measuring the tree for itself, and it is the check whose
1543
+ whole output is a _count_ — 3,812 findings across `sohl`'s 1,685 notes. A report
1544
+ that measures the corpus against the declared vocabulary has to be looking at
1545
+ the corpus the compile will build, or its counts describe a tree nobody ships.
1546
+
1547
+ It measures what the author wrote, recovered with `authoredFrontmatter`: this
1548
+ compares a note's fields against a declared vocabulary, and `address:` is in no
1549
+ vocabulary. It also joins the shared contract the other checks now have — a note
1550
+ the index cannot record is reported with its position and the rest of the tree
1551
+ is still measured.
1552
+
1553
+ `bin/content-build.mjs` no longer walks a content tree at all.
1554
+
1555
+ _Findings are unchanged: 3,812 across 1,685 notes, identical._
1556
+ - 0651b1a: **An HM3 item records its template priority, as an HM3 actor already did**
1557
+ (#283).
1558
+
1559
+ `data.templatePriority` is the shared statement that a note is a starting
1560
+ template, and the specification states it as a row every type maps:
1561
+ `system.templatePriority` in SoHL, `flags.hm3.templatePriority` in HM3. Only
1562
+ HM3's **Actor** pass made that mapping. Its Item pass emitted whatever `flags`
1563
+ the note itself authored and nothing more, so an item note declaring the
1564
+ priority compiled into a SoHL item that knew it was a template and an HM3 item
1565
+ that did not.
1566
+
1567
+ **It was silent on both sides of the build.** The note is well-formed and the
1568
+ pack compiles; an omitted flag is exactly how this system says _not a template_,
1569
+ so a lost priority and a deliberate one are the same output. Nor could the
1570
+ emitted-key check see it — that compares what a pass writes against the
1571
+ receiving **schema**, and a flag is declared by no schema. Every gear, skill and
1572
+ trauma note in a tree carrying an `hm3:` block was affected, which is most of
1573
+ them.
1574
+
1575
+ **The rule now lives in one place.** `hm3/template-priority.mjs` holds it and
1576
+ both passes call it, rather than each carrying a copy — two copies being two
1577
+ chances to diverge again, which is the failure being fixed. `SystemItemCompiler`
1578
+ gains a `commonFlags()` hook alongside `commonSystem()`, defaulting to the
1579
+ authored flags alone, so a system that keeps a shared fact in flags has a seam
1580
+ to say so at.
1581
+ - e4c16ad: **The item catalogue picks the newest cached version numerically, not by string
1582
+ sort** (#272).
1583
+
1584
+ `foreignItemCatalogDirs` chose among several cached versions of a dependency
1585
+ with a plain `sort()` over directory names of the form `<id>@<version>`. That is
1586
+ a **string** comparison, so `sohl@0.8.10` sorts _before_ `sohl@0.8.2` and the
1587
+ build resolved its embedded item references against the older catalogue.
1588
+
1589
+ Nothing reported it, and nothing could: both caches are complete and stamped,
1590
+ and the older one is a perfectly valid catalogue — it simply answers for the
1591
+ wrong version. Several versions coexist whenever a pinned version is raised
1592
+ without clearing `build/cache/foreign`, which is the ordinary case, since a
1593
+ fetch writes the newly declared version beside the old one rather than replacing
1594
+ it.
1595
+
1596
+ The content-index cache already compared version segments numerically for
1597
+ exactly this reason. That comparison is now shared as `newestVersionDir` rather
1598
+ than written once per cache: two copies were two chances to get it wrong, and
1599
+ this is the copy that was wrong.
1600
+ - d17dc98: **One reader for a note's anchors.** `engine/content-links.mjs` kept its own,
1601
+ and it disagreed with the content index's: it matched `{#([a-z0-9-]+)}` where
1602
+ `collectAnchors` matches `{#([^}]+)}`. So an anchor with a capital in it —
1603
+ `{#CalendarFormat}`, three of them in `sohl`'s own content — existed for the
1604
+ index and for the compilers, and did not exist for the check of them.
1605
+
1606
+ Nothing links to one today, so the disagreement was latent. The first link to
1607
+ one would have been reported dead against a heading plainly present in the
1608
+ file, which is the worst shape a finding can take.
1609
+
1610
+ The specification puts no charset on the id — "`#id` represents an id anchor
1611
+ named `id`" — so the narrower pattern was this module's invention rather than a
1612
+ rule it was enforcing. That is the argument for one reader rather than a
1613
+ well-chosen one, and the first thing #243 asks for: the corpus and everything
1614
+ derived from it answered in one place.
1615
+ - e242701: **#241's closing note, checked and pinned.** It asked whether `vehicle` and
1616
+ `armorlocation` were second instances of the trap it reported — a type declared
1617
+ and validated that no pack can route. Neither is, and they are not the same case
1618
+ as each other:
1619
+
1620
+ - **`armorlocation` is HM3's.** The specification says "HM3 only", it has no SoHL
1621
+ form, and `hm3/document-subtypes.mjs` maps it. A SoHL configuration claiming
1622
+ it would be wrong, so its absence from `SOHL_DOCUMENT_SUBTYPES` is the answer
1623
+ rather than a gap. Nothing asserted that, so nothing would have noticed a row
1624
+ appearing there by mistake; now something does.
1625
+ - **`vehicle` is specified but not yet implemented, and says so.** A note of that
1626
+ type is already reported as _"the content format specifies `vehicle`, so the
1627
+ note is not wrong — this toolchain has not implemented the type yet … do not
1628
+ author the type until a release compiles it"_. That is the opposite of #241,
1629
+ where the failure was silent and misattributed to the note.
1630
+
1631
+ No behaviour change: this adds the two assertions and the reasoning, so the
1632
+ next reader does not have to re-derive it from four files.
1633
+ - dadbec3: **`content-build lint` and `content-build site` reach the content again** — they
1634
+ threw on the first note for every consumer.
1635
+
1636
+ #243 made `walkMarkdownTree`'s scope a required argument, which was the point:
1637
+ the default it removed read whichever configuration resolved from the working
1638
+ directory rather than the one the caller was working under. Two CLI callers had
1639
+ been living on that default and were not converted with the rest — so both
1640
+ commands failed immediately, reporting nothing about the tree.
1641
+
1642
+ This repository ships no content, so nothing in its suite had ever _run_ those
1643
+ commands over a tree; every test called the engine directly with arguments it
1644
+ supplied. A test now builds a small content tree and runs `lint`, `links` and
1645
+ `site` against it, asserting they reach the notes at all — and that they honour
1646
+ the configured `skipDirectories` rather than reading a directory the tree said
1647
+ to skip.
1648
+ - ae9ef89: **#243's two open questions, settled** — one by measurement, one by finding it
1649
+ had already been answered.
1650
+
1651
+ **1. The index does not record frontmatter-key positions, and should not.** The
1652
+ question was whether a pass reading the index could report a field defect
1653
+ without opening the note. The numbers are not close: over `sohl`'s 1,685 notes
1654
+ the index is **3.0 MB** and holds **50,598 leaf values**, so a `{line, column}`
1655
+ on each would add roughly **1.6 MB — a 54% larger artifact** — for data read
1656
+ only on the _failing_ path.
1657
+
1658
+ The rule that replaces it is the one the module was already built on: _the index
1659
+ carries what is **about** a note; the file carries the note's text and every
1660
+ position within it._ A pass needing either opens the file the record already
1661
+ names, which costs nothing it was not already paying — a check reads each note
1662
+ once for its body, and a compiler must read the prose regardless, so while it
1663
+ holds the bytes a position is free. An anchor's `line` is the exception that
1664
+ proves it: an anchor is structure a consumer addresses, not a locator for a
1665
+ diagnostic.
1666
+
1667
+ `noteFile(contentBase, record)` is the one composition of a record's absolute
1668
+ path. There were **four** copies of it — one in each reader converted by #290,
1669
+ #292, #294 and #296 — which is the duplication #243 exists to remove, arriving
1670
+ by the back door. The index records the path relatively on purpose (an absolute
1671
+ one is a fact about the build machine, and would put a home directory in a
1672
+ published artifact), so composing it is a real step and belongs in one place.
1673
+
1674
+ **2. "Not everything is a note" no longer holds.** The issue lists folder
1675
+ documents (`item-folders.yaml`) and the adventures that bundle scenes as
1676
+ configuration appearing in no record. Both became notes after it was written —
1677
+ folders in #260/#276, bundles in #263/#286 — and both are indexed today, a
1678
+ folder with its address and id, a bundle with the `Adventure` UUID it compiles
1679
+ into.
1680
+
1681
+ What a folder record does not carry is a Foundry address, and that is the right
1682
+ answer rather than a gap: a folder materialises in **every** pack holding a
1683
+ document that references it, so no one UUID identifies it, and emitting one
1684
+ would publish an `Item` UUID for a `Folder` at an id no document carries. A
1685
+ homepage carries none for the opposite reason — it compiles into no document at
1686
+ all. Both are now pinned at the **record**, which is the level a pass driven by
1687
+ the index reads, with a bundle as the positive control.
1688
+
1689
+ _No behaviour change: `lint`, `links`, `content-format notes` and both address
1690
+ maps are identical over `sohl`'s tree._
1691
+ - 2669b57: **`undeclaredPaths` stops descending at a declared leaf** — a declared path with
1692
+ no children holds _values_, not fields (#126).
1693
+
1694
+ A schema declares a path that has nothing beneath it for two ordinary reasons: a
1695
+ map with **dynamic keys** (a mystery's `skillAptitudes` is skill selector →
1696
+ modifier, an affiliation's `relations` is shortcode → standing) and a
1697
+ **TypedSchemaField** (`strikeModes`, discriminated by `type`). In both, what sits
1698
+ under the path is data an author wrote, not paths the schema names — so walking
1699
+ into one reports every entry as an undeclared `system` key.
1700
+
1701
+ It stayed invisible because those maps are authored _outside_ `<system>.system`
1702
+ today, where nothing walks them. The moment a note authors one at the
1703
+ destination — which is what #126's corpus move does — each entry becomes a
1704
+ finding: `sohl.system.skillAptitudes.zepharis`, `…strikeModes.impale`, one per
1705
+ key. Measured on a migrated `sohl-thalorna`: **324 findings, none of them a
1706
+ defect**, and they would have made the migration look like it had broken 62
1707
+ notes.
1708
+
1709
+ Descent is now conditional on the schema declaring something _beneath_ the path.
1710
+ `body.structure` declares `parts` and `zones`, so it is a real container and an
1711
+ undeclared `adjacent` under it is still reported; `skillAptitudes` declares
1712
+ nothing beneath it, so its contents are a value.
1713
+
3
1714
  ## 17.2.0
4
1715
 
5
1716
  ### Minor Changes