@heroiclands/package-build 0.6.0 → 3.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 (127) hide show
  1. package/CHANGELOG-content-build.md +965 -0
  2. package/CHANGELOG.md +95 -0
  3. package/CONTENT.md +824 -0
  4. package/MIGRATING.md +127 -0
  5. package/README.md +29 -17
  6. package/bin/content-build.mjs +1082 -0
  7. package/bin/package-build.mjs +2 -2
  8. package/bin/report.mjs +2 -2
  9. package/config.mjs +21 -18
  10. package/content-config.mjs +1424 -0
  11. package/coverage.mjs +1 -1
  12. package/e2e.mjs +1 -1
  13. package/engine/abbreviations.mjs +0 -0
  14. package/engine/base-compiler.mjs +608 -0
  15. package/engine/code-fences.mjs +311 -0
  16. package/engine/compendiums.mjs +353 -0
  17. package/engine/content-address.mjs +141 -0
  18. package/engine/content-links.mjs +438 -0
  19. package/engine/content-lint.mjs +204 -0
  20. package/engine/content-package.mjs +67 -0
  21. package/engine/content-slug.mjs +169 -0
  22. package/engine/content-tables.mjs +1216 -0
  23. package/engine/content-tree.mjs +56 -0
  24. package/engine/diagnostics.mjs +270 -0
  25. package/engine/field-reference.mjs +242 -0
  26. package/engine/field-spec.mjs +218 -0
  27. package/engine/foreign-catalog.mjs +450 -0
  28. package/engine/foreign-manifests.mjs +129 -0
  29. package/engine/frontmatter-lint.mjs +343 -0
  30. package/engine/frontmatter.mjs +258 -0
  31. package/engine/generate.mjs +305 -0
  32. package/engine/helpers.mjs +809 -0
  33. package/engine/ids.mjs +192 -0
  34. package/engine/index.mjs +142 -0
  35. package/engine/item-docs.mjs +153 -0
  36. package/engine/item-registry.mjs +129 -0
  37. package/engine/journals.mjs +374 -0
  38. package/engine/kb-manifest.mjs +473 -0
  39. package/engine/macros.mjs +333 -0
  40. package/engine/manifest-emit.mjs +339 -0
  41. package/engine/map-notes.mjs +1302 -0
  42. package/engine/pack-config.mjs +479 -0
  43. package/engine/pack-router.mjs +254 -0
  44. package/engine/prose-config.mjs +238 -0
  45. package/engine/prose-lint.mjs +294 -0
  46. package/engine/region-events.mjs +79 -0
  47. package/engine/scene-levels.mjs +420 -0
  48. package/engine/scenes.mjs +528 -0
  49. package/engine/site-build.mjs +778 -0
  50. package/engine/site-index.mjs +332 -0
  51. package/engine/web-wikilinks.mjs +330 -0
  52. package/engine/wikilink-syntax.mjs +105 -0
  53. package/engine/wikilinks.mjs +645 -0
  54. package/index.mjs +42 -25
  55. package/lang.mjs +1 -1
  56. package/manifest.mjs +79 -5
  57. package/markdownlint-config.mjs +45 -0
  58. package/package.json +78 -34
  59. package/prettier-config.mjs +34 -0
  60. package/sohl/actors.mjs +581 -0
  61. package/sohl/affiliation-standings.mjs +44 -0
  62. package/sohl/being-info.mjs +173 -0
  63. package/sohl/default-item-art.mjs +82 -0
  64. package/sohl/index.mjs +55 -0
  65. package/sohl/item-builders.mjs +114 -0
  66. package/sohl/item-fields.mjs +867 -0
  67. package/sohl/items.mjs +197 -0
  68. package/sohl/kb-passes.mjs +207 -0
  69. package/sohl/note-schemas.mjs +322 -0
  70. package/templates.mjs +1 -1
  71. package/types/config.d.mts +1 -1
  72. package/types/content-config.d.mts +571 -0
  73. package/types/engine/abbreviations.d.mts +44 -0
  74. package/types/engine/base-compiler.d.mts +320 -0
  75. package/types/engine/code-fences.d.mts +102 -0
  76. package/types/engine/compendiums.d.mts +77 -0
  77. package/types/engine/content-address.d.mts +61 -0
  78. package/types/engine/content-links.d.mts +87 -0
  79. package/types/engine/content-lint.d.mts +47 -0
  80. package/types/engine/content-package.d.mts +36 -0
  81. package/types/engine/content-slug.d.mts +59 -0
  82. package/types/engine/content-tables.d.mts +131 -0
  83. package/types/engine/content-tree.d.mts +12 -0
  84. package/types/engine/diagnostics.d.mts +152 -0
  85. package/types/engine/field-reference.d.mts +20 -0
  86. package/types/engine/field-spec.d.mts +181 -0
  87. package/types/engine/foreign-catalog.d.mts +103 -0
  88. package/types/engine/foreign-manifests.d.mts +43 -0
  89. package/types/engine/frontmatter-lint.d.mts +74 -0
  90. package/types/engine/frontmatter.d.mts +99 -0
  91. package/types/engine/generate.d.mts +56 -0
  92. package/types/engine/helpers.d.mts +348 -0
  93. package/types/engine/ids.d.mts +126 -0
  94. package/types/engine/index.d.mts +33 -0
  95. package/types/engine/item-docs.d.mts +73 -0
  96. package/types/engine/item-registry.d.mts +57 -0
  97. package/types/engine/journals.d.mts +121 -0
  98. package/types/engine/kb-manifest.d.mts +214 -0
  99. package/types/engine/macros.d.mts +201 -0
  100. package/types/engine/manifest-emit.d.mts +137 -0
  101. package/types/engine/map-notes.d.mts +283 -0
  102. package/types/engine/pack-config.d.mts +77 -0
  103. package/types/engine/pack-router.d.mts +53 -0
  104. package/types/engine/prose-config.d.mts +121 -0
  105. package/types/engine/prose-lint.d.mts +59 -0
  106. package/types/engine/region-events.d.mts +43 -0
  107. package/types/engine/scene-levels.d.mts +36 -0
  108. package/types/engine/scenes.d.mts +48 -0
  109. package/types/engine/site-build.d.mts +214 -0
  110. package/types/engine/site-index.d.mts +141 -0
  111. package/types/engine/web-wikilinks.d.mts +67 -0
  112. package/types/engine/wikilink-syntax.d.mts +101 -0
  113. package/types/engine/wikilinks.d.mts +172 -0
  114. package/types/index.d.mts +3 -0
  115. package/types/manifest.d.mts +52 -4
  116. package/types/markdownlint-config.d.mts +8 -0
  117. package/types/prettier-config.d.mts +1 -0
  118. package/types/sohl/actors.d.mts +65 -0
  119. package/types/sohl/affiliation-standings.d.mts +22 -0
  120. package/types/sohl/being-info.d.mts +78 -0
  121. package/types/sohl/default-item-art.d.mts +28 -0
  122. package/types/sohl/index.d.mts +7 -0
  123. package/types/sohl/item-builders.d.mts +11 -0
  124. package/types/sohl/item-fields.d.mts +20 -0
  125. package/types/sohl/items.d.mts +19 -0
  126. package/types/sohl/kb-passes.d.mts +71 -0
  127. package/types/sohl/note-schemas.d.mts +7 -0
@@ -0,0 +1,129 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Whether a vendored manifest can still be *addressed*, as distinct from read.
16
+ *
17
+ * A consuming build vendors each foreign package's link manifest and resolves
18
+ * cross-package links through it. The lookup is by canonical key, so it needs
19
+ * both sides to agree on the key's shape: when they agree the link resolves,
20
+ * and when they drift apart the lookup cannot match on *any* input — while the
21
+ * page still reads correctly, because an unresolved wikilink falls through to
22
+ * its own display text. That is how a v3 key change left one repository reading
23
+ * 2,367 entries through a lookup that could never hit one of them (#1499).
24
+ *
25
+ * Bridging the lookup is not enough on its own: it fails silently again the next
26
+ * time either side moves. So the shapes are *checked* rather than merely
27
+ * converted, and a manifest that yields no addressable key at all fails the
28
+ * build. A lookup that cannot match anything reports nothing, which is the one
29
+ * failure a dead-link check can never catch.
30
+ *
31
+ * This guards {@link kbManifest}'s own key format, which is why it lives beside
32
+ * it rather than in whichever consumer happens to load a manifest.
33
+ *
34
+ * @module
35
+ */
36
+
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+
40
+ import { formatDiagnostic, positionOfLiteral } from "./diagnostics.mjs";
41
+ import { readCanonicalKey } from "./kb-manifest.mjs";
42
+
43
+ /**
44
+ * Every foreign package whose manifest entries a build cannot address.
45
+ *
46
+ * A package is reported only when it contributes entries and **none** of them
47
+ * yields a readable canonical key — the total, silent failure described above.
48
+ * Partial drift is deliberately not reported here: it resolves something, and
49
+ * whatever it fails to resolve surfaces as an ordinary dead address, pointed at
50
+ * the note that cites it. A package contributing no entries at all is likewise
51
+ * not a finding; a pack-only package publishes no addressable pages by design
52
+ * (#1516), and one being brought up publishes nothing yet.
53
+ *
54
+ * @param {Map<string, {package?: string}>} foreignIndex - `foreign.index` as
55
+ * returned by `loadForeignManifests`, keyed by canonical key.
56
+ * @returns {Array<{package: string, entries: number, sampleKey: string}>} One
57
+ * finding per drifted package, in the order the index first names each.
58
+ */
59
+ export function unaddressableForeignPackages(foreignIndex) {
60
+ const byPackage = new Map();
61
+ for (const [key, value] of foreignIndex ?? new Map()) {
62
+ // The package is read from the entry rather than the key, since the key
63
+ // is the very thing under suspicion — deriving it from a shape that may
64
+ // not parse would report the finding against `undefined`.
65
+ const pkg = value?.package;
66
+ if (!pkg) continue;
67
+ const seen = byPackage.get(pkg) ?? {
68
+ entries: 0,
69
+ readable: 0,
70
+ sampleKey: key,
71
+ };
72
+ seen.entries += 1;
73
+ if (readCanonicalKey(key)) seen.readable += 1;
74
+ byPackage.set(pkg, seen);
75
+ }
76
+
77
+ const findings = [];
78
+ for (const [pkg, seen] of byPackage) {
79
+ if (seen.entries > 0 && seen.readable === 0) {
80
+ findings.push({
81
+ package: pkg,
82
+ entries: seen.entries,
83
+ sampleKey: seen.sampleKey,
84
+ });
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+
90
+ /**
91
+ * One finding, in the standard `file:line:column: severity: message` form.
92
+ *
93
+ * The position is recovered by locating the offending key in the manifest text:
94
+ * the finding is about a literal the reader can see in the file, so its position
95
+ * is implicit rather than absent. When the file cannot be read, or the key is
96
+ * not in it, the locator degrades to the file alone — a dropped field, never a
97
+ * guessed `1:1` that would send the reader to the top of a 500 KB manifest for a
98
+ * finding that is not there.
99
+ *
100
+ * @param {{package: string, entries: number, sampleKey: string}} finding - One
101
+ * finding from {@link unaddressableForeignPackages}.
102
+ * @param {string} manifestDir - The directory the manifests were loaded from.
103
+ * @returns {string} The formatted diagnostic, path first on the line.
104
+ */
105
+ export function formatUnaddressableFinding(finding, manifestDir) {
106
+ const file = path.join(manifestDir, `${finding.package}.json`);
107
+ let at = {};
108
+ try {
109
+ at = positionOfLiteral(
110
+ fs.readFileSync(file, "utf8"),
111
+ `"${finding.sampleKey}"`,
112
+ );
113
+ } catch {
114
+ // Unreadable here is not itself the finding — `loadForeignManifests`
115
+ // already reports that as a stale manifest. The file is simply all that
116
+ // is known about where this one is.
117
+ }
118
+ return formatDiagnostic({
119
+ file,
120
+ ...at,
121
+ severity: "error",
122
+ message:
123
+ "no key in this manifest is a canonical " +
124
+ `\`package-type-shortcode\` address (${finding.entries} ` +
125
+ `${finding.entries === 1 ? "entry" : "entries"}, none addressable; ` +
126
+ `first is \`${finding.sampleKey}\`) — every cross-package link to ` +
127
+ `${finding.package} would resolve to nothing, silently`,
128
+ });
129
+ }
@@ -0,0 +1,343 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Checking a note's **frontmatter** against the schema its `type` declares
16
+ * (#19).
17
+ *
18
+ * Until now nothing did. A note's type decides which properties are required,
19
+ * what shape each value has, and which name another note — but that knowledge
20
+ * existed only implicitly, spread across the readers that happen to consume
21
+ * each field. So an authoring mistake was never reported where it was made:
22
+ *
23
+ * - a **missing required property** surfaced as a downstream failure whose
24
+ * message was about something else, or as a silently empty field;
25
+ * - a **wrong type** threw deep inside a builder, or was coerced;
26
+ * - a **misspelled property** was simply *ignored* — the builders are an
27
+ * allow-list, so an unrecognised `sohl:` key is dropped without a word. That
28
+ * is how 204 kethira mystical abilities shipped with no affiliation (#3), and
29
+ * why the silence mattered more than the missing field: an author could not
30
+ * tell a builder that forgot the field apart from a field that does
31
+ * not belong on the type at all;
32
+ * - a **dead shortcode reference** in frontmatter was not checked at all, only
33
+ * wikilinks in the body were.
34
+ *
35
+ * **This module is vocabulary-agnostic.** It validates a note against whatever
36
+ * schema its type declares and knows no type names of its own; the SoHL
37
+ * vocabulary lives in `sohl/note-schemas.mjs`. That line is between knowledge
38
+ * of the *game system* and knowledge of the *note format* — not a permission
39
+ * boundary between consumers. Every content project authors the full type
40
+ * vocabulary (an adventure module ships skills, beings and magic swords), so
41
+ * every consumer loads all of it.
42
+ *
43
+ * **It takes a built link index rather than walking itself.** The dead-
44
+ * reference check has to resolve exactly as a wikilink does, cross-package
45
+ * manifests and all, and the way to guarantee that is to call the same
46
+ * resolver rather than write a second one.
47
+ *
48
+ * @module
49
+ */
50
+
51
+ import { authoredFields } from "./field-spec.mjs";
52
+ import { positionInFrontmatter } from "./diagnostics.mjs";
53
+ import { RETIRED_TYPES } from "./ids.mjs";
54
+
55
+ /**
56
+ * `sohl:` keys every type accepts, whatever its schema says.
57
+ *
58
+ * Neither is part of a type's vocabulary — both are read for *any* note, by
59
+ * passes that run before a builder sees it — so neither appears in a field
60
+ * declaration and both would otherwise be reported as unknown on every note
61
+ * that uses them.
62
+ *
63
+ * - `folder` — the compendium folder the document is filed in.
64
+ * - `pack` — which compendium of the note's document type receives it, for a
65
+ * repository shipping several (#1566).
66
+ * - `archetype` — the archetype flag, read for any note by the walk itself.
67
+ * - `kbcat` — the knowledgebase category a note is grouped under.
68
+ *
69
+ * `kbcat` is the one that matters for what this check *is*. **The pack build is
70
+ * not the only reader of a note's frontmatter.** A note also feeds a
71
+ * knowledgebase and a website, and those surfaces consume classification the
72
+ * compiler never emits — `kbcat` alone is read 51 times across SoHL's
73
+ * knowledgebase layouts. A check that equated "the vocabulary" with "what the
74
+ * builder compiles" would report every one of those as an unknown property and
75
+ * turn a correct tree red, which is exactly what it did on first run: 4,241
76
+ * findings against SoHL's own content, none of them a defect.
77
+ *
78
+ * So a type's schema declares what a note **may write**, which is broader than
79
+ * what any one consumer reads.
80
+ *
81
+ * @type {ReadonlySet<string>}
82
+ */
83
+ export const UNIVERSAL_KEYS = Object.freeze(
84
+ new Set(["folder", "pack", "archetype", "kbcat"]),
85
+ );
86
+
87
+ /**
88
+ * Edit distance, capped — enough to answer "did you mean".
89
+ *
90
+ * A misspelled property is the failure class this check exists for, and a
91
+ * finding that names the key the author *meant* turns a hunt through the
92
+ * reference into a one-character fix.
93
+ *
94
+ * @param {string} a - One string.
95
+ * @param {string} b - The other.
96
+ * @returns {number} The Levenshtein distance.
97
+ */
98
+ function distance(a, b) {
99
+ const rows = a.length + 1;
100
+ const cols = b.length + 1;
101
+ let prev = Array.from({ length: cols }, (_, j) => j);
102
+ for (let i = 1; i < rows; i += 1) {
103
+ const row = [i];
104
+ for (let j = 1; j < cols; j += 1) {
105
+ row[j] = Math.min(
106
+ prev[j] + 1,
107
+ row[j - 1] + 1,
108
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
109
+ );
110
+ }
111
+ prev = row;
112
+ }
113
+ return prev[cols - 1];
114
+ }
115
+
116
+ /**
117
+ * The declared key an unknown one was most likely meant to be.
118
+ *
119
+ * @param {string} key - The unknown key.
120
+ * @param {Iterable<string>} candidates - The declared keys.
121
+ * @returns {string|undefined} The nearest, when it is near enough to suggest.
122
+ */
123
+ function nearest(key, candidates) {
124
+ let best;
125
+ let bestAt = Infinity;
126
+ for (const candidate of candidates) {
127
+ const d = distance(key.toLowerCase(), candidate.toLowerCase());
128
+ if (d < bestAt) {
129
+ bestAt = d;
130
+ best = candidate;
131
+ }
132
+ }
133
+ // A third of the key's length, so a suggestion is a plausible typo rather
134
+ // than the least-bad of a list of unrelated words.
135
+ return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
136
+ }
137
+
138
+ /**
139
+ * Whether a value satisfies a declared {@link FieldSpec.kind}.
140
+ *
141
+ * Deliberately lenient about the spellings YAML makes ambiguous: `"12"` is a
142
+ * number, because a quoted scalar is how a number arrives from many editors,
143
+ * and a map authored as an empty list is a map, because Obsidian's property
144
+ * editor serialises an emptied map that way (#8). What it rejects is a value
145
+ * that cannot mean what the field is for — `weight: heavy`.
146
+ *
147
+ * @param {unknown} value - The authored value.
148
+ * @param {string} kind - The declared kind.
149
+ * @returns {boolean} Whether it is acceptable.
150
+ */
151
+ export function matchesKind(value, kind) {
152
+ switch (kind) {
153
+ case "number":
154
+ return typeof value === "number" ?
155
+ Number.isFinite(value)
156
+ : typeof value === "string" &&
157
+ value.trim() !== "" &&
158
+ Number.isFinite(Number(value));
159
+ case "boolean":
160
+ return (
161
+ typeof value === "boolean" ||
162
+ value === "true" ||
163
+ value === "false"
164
+ );
165
+ case "string":
166
+ return typeof value !== "object" || value === null;
167
+ case "list":
168
+ return Array.isArray(value);
169
+ case "map":
170
+ // An emptied map arrives as `[]` from the property editor, and
171
+ // means "this note authors no entries" — the same thing `{}` means.
172
+ return (
173
+ (typeof value === "object" &&
174
+ value !== null &&
175
+ !Array.isArray(value)) ||
176
+ (Array.isArray(value) && value.length === 0)
177
+ );
178
+ default:
179
+ return true;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The `sohl:` block a note authored, or an empty one.
185
+ *
186
+ * @param {object} fm - The note's frontmatter.
187
+ * @returns {object} The block.
188
+ */
189
+ function sohlBlock(fm) {
190
+ const block = fm?.sohl;
191
+ return block && typeof block === "object" && !Array.isArray(block) ?
192
+ block
193
+ : {};
194
+ }
195
+
196
+ /**
197
+ * Check one note against its type's schema.
198
+ *
199
+ * @param {object} note - A note from the link index (`{fm, file, raw, type}`).
200
+ * @param {object} opts
201
+ * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
202
+ * @param {object} [opts.index] - The link index, for the reference check. Its
203
+ * absence skips that check rather than reporting every reference as dead.
204
+ * @returns {object[]} Findings, each with a locator where one is obtainable.
205
+ */
206
+ export function lintNote(note, { schemas, index }) {
207
+ const findings = [];
208
+ const fm = note.fm ?? {};
209
+ const type = String(fm.type ?? "");
210
+ const raw = () => note.raw ?? "";
211
+ const at = (key, literal) =>
212
+ positionInFrontmatter(raw(), key, literal ?? undefined);
213
+
214
+ const replacement = RETIRED_TYPES[type];
215
+ if (replacement) {
216
+ findings.push({
217
+ file: note.file,
218
+ ...at("type", type),
219
+ severity: "error",
220
+ message:
221
+ `content type "${type}" was retired in favour of ` +
222
+ `"${replacement}"; both compiled to the same document, so the ` +
223
+ `fix is mechanical: write "${replacement}"`,
224
+ });
225
+ return findings;
226
+ }
227
+
228
+ const schema = schemas[type];
229
+ if (!schema) {
230
+ findings.push({
231
+ file: note.file,
232
+ ...at("type", type),
233
+ severity: "error",
234
+ message:
235
+ `no schema is declared for content type "${type}", so nothing ` +
236
+ `can say what this note may write; declare it, or correct the type`,
237
+ });
238
+ return findings;
239
+ }
240
+
241
+ const fields = authoredFields(schema);
242
+ const block = sohlBlock(fm);
243
+ /** First segment of each declared name — `impact.die` is authored as `impact`. */
244
+ const declared = new Set(fields.map((f) => f.name.split(".")[0]));
245
+
246
+ for (const key of Object.keys(block)) {
247
+ if (declared.has(key) || UNIVERSAL_KEYS.has(key)) continue;
248
+ const guess = nearest(key, declared);
249
+ findings.push({
250
+ file: note.file,
251
+ ...at(key),
252
+ severity: "error",
253
+ message:
254
+ `"${key}" is not a property of a ${type}; it is discarded at ` +
255
+ `compile with no warning` +
256
+ (guess ? `. Did you mean "${guess}"?` : ""),
257
+ });
258
+ }
259
+
260
+ for (const field of fields) {
261
+ // Only top-level names are read here: a nested one (`impact.die`) is
262
+ // reached through its parent, and reporting the parent twice — once as
263
+ // itself and once as its child — helps nobody.
264
+ const [head, ...rest] = field.name.split(".");
265
+ let value = block[head];
266
+ for (const segment of rest) {
267
+ value =
268
+ value && typeof value === "object" ? value[segment] : undefined;
269
+ }
270
+ const absent = value === undefined || value === null;
271
+
272
+ if (field.required && absent) {
273
+ findings.push({
274
+ file: note.file,
275
+ ...at("type", type),
276
+ severity: "error",
277
+ message: `a ${type} must declare \`sohl.${field.name}\` — ${field.describe}`,
278
+ });
279
+ continue;
280
+ }
281
+ if (absent) continue;
282
+
283
+ if (field.kind && !matchesKind(value, field.kind)) {
284
+ findings.push({
285
+ file: note.file,
286
+ ...at(head),
287
+ severity: "error",
288
+ message:
289
+ `\`sohl.${field.name}\` should be ${field.shape ?? field.kind}, ` +
290
+ `but reads ${JSON.stringify(value)}`,
291
+ });
292
+ continue;
293
+ }
294
+
295
+ // A reference names another note by shortcode. Resolved through the
296
+ // link index's own resolver, so a cross-package reference answered by a
297
+ // vendored manifest lands exactly as the same address in a wikilink
298
+ // would — rather than through a second, subtly different rule.
299
+ if (field.ref && index && typeof value === "string" && value) {
300
+ const target = `${field.ref}-${value}`;
301
+ if (!index.resolve(note, target) && !index.manifestHit(target)) {
302
+ findings.push({
303
+ file: note.file,
304
+ ...at(head, value),
305
+ severity: "error",
306
+ message:
307
+ `\`sohl.${field.name}\` names ${field.ref} ` +
308
+ `"${value}", and no note or vendored manifest declares it`,
309
+ });
310
+ }
311
+ }
312
+ }
313
+
314
+ return findings;
315
+ }
316
+
317
+ /**
318
+ * Check every note in a built index against its type's schema.
319
+ *
320
+ * @param {object} index - From `buildLinkIndex`.
321
+ * @param {object} opts
322
+ * @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
323
+ * @param {boolean} [opts.references=true] - Whether to check references.
324
+ * @returns {{findings: object[], notes: number}} The findings, and how many
325
+ * notes were inspected.
326
+ */
327
+ export function lintFrontmatter(index, { schemas, references = true }) {
328
+ const findings = [];
329
+ const notes = [...index.notes].sort((a, b) =>
330
+ a.file < b.file ? -1
331
+ : a.file > b.file ? 1
332
+ : 0,
333
+ );
334
+ for (const note of notes) {
335
+ findings.push(
336
+ ...lintNote(note, {
337
+ schemas,
338
+ index: references ? index : undefined,
339
+ }),
340
+ );
341
+ }
342
+ return { findings, notes: notes.length };
343
+ }