@heroiclands/package-build 8.1.0 → 10.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. package/types/sohl/items.d.mts +21 -0
@@ -0,0 +1,570 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The things the content format asserts that can be checked (#130, #136).
16
+ *
17
+ * `content-format.mjs` reads the specification as data; this compares it
18
+ * against the three worlds it makes claims about — the systems it maps onto,
19
+ * the notes it governs, and the field declarations that compile them.
20
+ *
21
+ * ## The specification against a system's published schema
22
+ *
23
+ * **The format does not define the `sohl:` or `hm3:` schemas.** Each system
24
+ * defines its own, and its published `schema.json` is the authoritative
25
+ * statement of it (#127). So a mapping row is a *claim*: `data.weight` reaches
26
+ * `system.weightBase` in SoHL. If SoHL declares no such field the two disagree,
27
+ * and which of them is wrong is a question for a person — but that they
28
+ * disagree is a fact a build can establish.
29
+ *
30
+ * This is {@link module:engine/schema-check}'s idea pointed at prose instead of
31
+ * code. That module compares what a *builder* emits; this compares what the
32
+ * *specification* says a builder should emit, which is the half no build
33
+ * touches and therefore the half that drifts unobserved.
34
+ *
35
+ * **A target is resolved against the union of the system's subtypes.** The
36
+ * mapping tables say which system field a shared source reaches; *which
37
+ * document subtype receives it* is the note-type → subtype map, which is #79's
38
+ * to declare and does not exist yet. Resolving per subtype before that map
39
+ * exists would mean inferring it from the prose around each table, which is
40
+ * precisely the transcription this whole module avoids. So the question asked
41
+ * here is the one #130 states — "does any schema declare this field?" — and it
42
+ * narrows to the subtype when #79 lands.
43
+ *
44
+ * ## The corpus against the declared vocabulary
45
+ *
46
+ * Every authored note is measured against the per-type `data` tables. Three
47
+ * classes of finding come out of them, and each corresponds to a slice of #127:
48
+ *
49
+ * | class | what it means |
50
+ * | --- | --- |
51
+ * | `unknown-type` | the format declares no section for this note's `type` |
52
+ * | `unknown-data-key` | a key in `data:`, which is closed, that the type does not declare |
53
+ * | `top-level-data-key` | a declared `data` property written at top level instead |
54
+ * | `system-block-data-key` | a declared shared source written straight into a system block |
55
+ *
56
+ * **It reports; it does not fail.** All ~6,210 authored notes predate the
57
+ * format, so a failing check would be red on day one in every repository and
58
+ * would stay red for the length of the epic — which is a check nobody can act
59
+ * on and everybody learns to skip. The counts are the migration's progress bar
60
+ * instead, and `--strict` turns them fatal. #127 turns the flag on slice by
61
+ * slice, as each class reaches zero.
62
+ *
63
+ * **What it deliberately does not check.** A key inside a `sohl:` or `hm3:`
64
+ * block that the format says nothing about is left alone: those regions are
65
+ * closed against *the system's* schema, not against this document, and
66
+ * `frontmatter-lint.mjs` already checks them against the declared fields. This
67
+ * module only reports a key whose home the format actually states.
68
+ *
69
+ * ## The specification against the declarations that compile it
70
+ *
71
+ * The specification hand-writes a `data` table under most of its type sections,
72
+ * which is the ground {@link module:engine/field-reference} already generates
73
+ * from the `fields` on each `itemBuilders` entry — the duplication that module's
74
+ * docstring exists to prevent, one document over (#136).
75
+ *
76
+ * **Checked rather than generated, because a merge is not available.** The
77
+ * document's vocabulary spans note types that produce Scenes, Macros and
78
+ * JournalEntries — `place`, `scenario`, `map`, `lore`, `doc`, `macro`,
79
+ * `homepage` — and no `itemBuilders` entry covers any of them, so there is no
80
+ * whole the two halves could be folded into.
81
+ *
82
+ * **What they can be held to is agreement where they both speak.** A mapping row
83
+ * saying `data.weight` reaches `system.weightBase`, and a declaration writing
84
+ * `weight` to `weightBase`, are one statement made twice; a rename that moves
85
+ * only one of them is a defect in one of the two, and that is a fact a build can
86
+ * establish. {@link checkDeclaredFields} fails on exactly that.
87
+ *
88
+ * **The rest is reported, not asserted.** The two vocabularies are not the same
89
+ * set and are not meant to be: the document names the *shared* source a field is
90
+ * written as, while a declaration names every key the system's own block accepts
91
+ * — including the system-specific ones (`heft`, `strikeModes`) that the document
92
+ * correctly never maps. Until #127 has moved the corpus into `data:`, holding the
93
+ * sets equal would report the migration itself as a defect on every run. So the
94
+ * fields only one side names come back as *coverage*, and the types only one side
95
+ * describes come back **named** rather than skipped in silence — a check that
96
+ * quietly compared nine of twenty-three types would read as one that covered
97
+ * them all.
98
+ *
99
+ * @module
100
+ */
101
+
102
+ import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
103
+ import { authoredFields } from "./field-spec.mjs";
104
+ import { positionInFrontmatter } from "./diagnostics.mjs";
105
+
106
+ /**
107
+ * Every field path any subtype of a published schema declares.
108
+ *
109
+ * `own` and `inherited` are collapsed here, unlike
110
+ * {@link module:engine/schema-check}, and for the same reason that module keeps
111
+ * them apart: it asks two questions of one artifact and only one of them wants
112
+ * the inherited set. This asks the single question "is this field defined
113
+ * anywhere in the system", for which an inherited field is defined.
114
+ *
115
+ * @param {object} artifact - A `version: 1` schema artifact.
116
+ * @returns {Set<string>} The paths, `system.` prefix stripped as the artifact
117
+ * stores them.
118
+ */
119
+ export function declaredPaths(artifact) {
120
+ const out = new Set();
121
+ for (const subtypes of Object.values(artifact?.documents ?? {})) {
122
+ for (const entry of Object.values(subtypes ?? {})) {
123
+ for (const field of entry?.own ?? []) out.add(field);
124
+ for (const field of entry?.inherited ?? []) out.add(field);
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * What an author is told about a target no schema declares.
132
+ *
133
+ * Names the version, because a field may be perfectly well defined on the
134
+ * system's `main` and simply unreleased — the same distinction
135
+ * {@link undeclaredMessage} draws, and the difference between "the
136
+ * specification is wrong" and "the schema has not caught up".
137
+ *
138
+ * @param {object} finding - `{system, systemVersion, noteType, source, target}`.
139
+ * @returns {string} The message.
140
+ */
141
+ export function undeclaredTargetMessage({ system, systemVersion, noteType, source, target }) {
142
+ return (
143
+ `the format maps \`${source}\` on a \`${noteType}\` to \`${target}\` in ` +
144
+ `${system}, which ${system}@${systemVersion} does not declare on any ` +
145
+ `document subtype — the specification and the system disagree, and one ` +
146
+ `of the two is wrong`
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Check every `system.*` target the specification names.
152
+ *
153
+ * @param {object} opts
154
+ * @param {import("./content-format.mjs").ContentFormat} opts.format - The
155
+ * parsed specification.
156
+ * @param {Record<string, object>} opts.schemas - System id → its published
157
+ * `version: 1` schema artifact. A system absent from this map is counted
158
+ * `unchecked` rather than passed.
159
+ * @returns {{findings: object[], checked: number, unchecked: Record<string, number>}}
160
+ * Findings ready for `emitDiagnostic`, how many claims were resolved, and how
161
+ * many were left unresolved per system.
162
+ */
163
+ export function checkSchemaTargets({ format, schemas }) {
164
+ /** @type {Record<string, Set<string>>} */
165
+ const declared = {};
166
+ for (const [system, artifact] of Object.entries(schemas ?? {})) {
167
+ if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
168
+ throw new Error(
169
+ `package-build: ${system} schema artifact version ` +
170
+ `${artifact?.version ?? "(absent)"}, expected ` +
171
+ `${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
172
+ `shape would report confidently in both directions, so the ` +
173
+ `check stops rather than resolving anyway.`,
174
+ );
175
+ }
176
+ declared[system] = declaredPaths(artifact);
177
+ }
178
+
179
+ const findings = [];
180
+ /** @type {Record<string, number>} */
181
+ const unchecked = {};
182
+ let checked = 0;
183
+
184
+ for (const claim of format.claims) {
185
+ const paths = declared[claim.system];
186
+ if (!paths) {
187
+ // Named rather than skipped in silence: a check that quietly does
188
+ // nothing reads exactly like one that passed, and HM3 publishes no
189
+ // artifact today, so this branch is the ordinary case for half the
190
+ // document.
191
+ unchecked[claim.system] = (unchecked[claim.system] ?? 0) + 1;
192
+ continue;
193
+ }
194
+ checked += 1;
195
+ if (paths.has(claim.target.replace(/^system\./, ""))) continue;
196
+ findings.push({
197
+ file: format.file,
198
+ line: claim.line,
199
+ ...(claim.column === undefined ? {} : { column: claim.column }),
200
+ severity: "error",
201
+ message: undeclaredTargetMessage({
202
+ ...claim,
203
+ systemVersion: schemas[claim.system]?.systemVersion ?? "(unversioned)",
204
+ }),
205
+ });
206
+ }
207
+
208
+ return { findings, checked, unchecked };
209
+ }
210
+
211
+ /**
212
+ * Edit distance, capped — enough to answer "did you mean".
213
+ *
214
+ * The same arithmetic `frontmatter-lint.mjs` carries. Duplicated rather than
215
+ * shared because that module keeps it private and this one is the second
216
+ * caller; the third is the moment to lift it out, not the second.
217
+ *
218
+ * @param {string} a - One string.
219
+ * @param {string} b - The other.
220
+ * @returns {number} The Levenshtein distance.
221
+ */
222
+ function distance(a, b) {
223
+ const cols = b.length + 1;
224
+ let prev = Array.from({ length: cols }, (_, j) => j);
225
+ for (let i = 1; i <= a.length; i += 1) {
226
+ const row = [i];
227
+ for (let j = 1; j < cols; j += 1) {
228
+ row[j] = Math.min(
229
+ prev[j] + 1,
230
+ row[j - 1] + 1,
231
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
232
+ );
233
+ }
234
+ prev = row;
235
+ }
236
+ return prev[cols - 1];
237
+ }
238
+
239
+ /**
240
+ * The declared key an unknown one was most likely meant to be.
241
+ *
242
+ * @param {string} key - The unknown key.
243
+ * @param {Iterable<string>} candidates - The declared keys.
244
+ * @returns {string|undefined} The nearest, when it is near enough to suggest.
245
+ */
246
+ function nearest(key, candidates) {
247
+ let best;
248
+ let bestAt = Infinity;
249
+ for (const candidate of candidates) {
250
+ const d = distance(key.toLowerCase(), candidate.toLowerCase());
251
+ if (d < bestAt) {
252
+ bestAt = d;
253
+ best = candidate;
254
+ }
255
+ }
256
+ return bestAt <= Math.max(1, Math.floor(key.length / 3)) ? best : undefined;
257
+ }
258
+
259
+ /**
260
+ * Top-level keys the format names as the note's own, whatever its type.
261
+ *
262
+ * Top level is otherwise **open** — an unrecognised key there is a Hugo or
263
+ * theme parameter this build has no standing to refuse — so this set exists
264
+ * only to keep a note's own identity fields from being mistaken for a
265
+ * misplaced `data` property where the two share a name. `type` and `subType`
266
+ * are the pair that matters: several mapping tables name `subType` as a shared
267
+ * source, and it is authored at top level by design.
268
+ *
269
+ * @type {ReadonlySet<string>}
270
+ */
271
+ export const NOTE_LEVEL_KEYS = Object.freeze(
272
+ new Set(["id", "type", "subType", "shortcode", "description", "tags", "name", "aliases"]),
273
+ );
274
+
275
+ /** Whether a value is a plain object a block could be written as. */
276
+ function isBlock(value) {
277
+ return typeof value === "object" && value !== null && !Array.isArray(value);
278
+ }
279
+
280
+ /** @type {WeakMap<object, ReadonlySet<string>>} */
281
+ const systemCache = new WeakMap();
282
+
283
+ /**
284
+ * The systems the specification names.
285
+ *
286
+ * Taken from the columns of its mapping tables rather than from a list written
287
+ * here, so a system added to the document is measured with no code change.
288
+ * Cached per parsed document, since the answer is the same for every note.
289
+ *
290
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
291
+ * specification.
292
+ * @returns {ReadonlySet<string>} The system ids.
293
+ */
294
+ export function systemsNamed(format) {
295
+ let known = systemCache.get(format);
296
+ if (!known) {
297
+ known = new Set(format.claims.map((claim) => claim.system));
298
+ systemCache.set(format, known);
299
+ }
300
+ return known;
301
+ }
302
+
303
+ /**
304
+ * Measure one note against the vocabulary the format declares for its type.
305
+ *
306
+ * @param {object} note - `{file, raw, fm}` — as the link index hands one over.
307
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
308
+ * specification.
309
+ * @param {object} [opts]
310
+ * @param {"warning"|"error"} [opts.severity="warning"] - What the findings are.
311
+ * A report by default; `--strict` raises it.
312
+ * @returns {object[]} Findings, each carrying the `class` it belongs to
313
+ * alongside the fields `emitDiagnostic` reads.
314
+ */
315
+ export function measureNote(note, format, { severity = "warning" } = {}) {
316
+ const findings = [];
317
+ const fm = note?.fm ?? {};
318
+ const type = String(fm.type ?? "");
319
+ const raw = () => note?.raw ?? "";
320
+ const at = (key, value) => positionInFrontmatter(raw(), key, value);
321
+ const add = (cls, key, message, value) =>
322
+ findings.push({
323
+ file: note?.file,
324
+ ...at(key, value),
325
+ severity,
326
+ class: cls,
327
+ message,
328
+ });
329
+
330
+ const spec = format.types.get(type);
331
+ if (!spec) {
332
+ add(
333
+ "unknown-type",
334
+ "type",
335
+ `the content format declares no \`### type: ${type}\` section, so ` +
336
+ `nothing says what this note may write`,
337
+ type,
338
+ );
339
+ return findings;
340
+ }
341
+
342
+ // `data:` is closed: every key it carries must be one the type declares.
343
+ const data = isBlock(fm.data) ? fm.data : undefined;
344
+ for (const key of Object.keys(data ?? {})) {
345
+ if (spec.dataKeys.has(key)) continue;
346
+ const guess = nearest(key, spec.dataKeys);
347
+ add(
348
+ "unknown-data-key",
349
+ key,
350
+ `\`data.${key}\` is not a property of a ${type}` +
351
+ (guess ? `. Did you mean "${guess}"?` : ""),
352
+ );
353
+ }
354
+
355
+ // The two regions a shared source is written in today, and neither is where
356
+ // the format puts it. Top level is open in general — but a key the type's
357
+ // own table declares is not an unrecognised one, it is one whose home the
358
+ // format states.
359
+ for (const key of spec.dataKeys) {
360
+ if (NOTE_LEVEL_KEYS.has(key)) continue;
361
+ if (Object.hasOwn(fm, key)) {
362
+ add(
363
+ "top-level-data-key",
364
+ key,
365
+ `\`${key}\` is a declared property of a ${type} and belongs in ` +
366
+ `\`data.${key}\`; at top level it reaches the web page and no ` +
367
+ `Foundry document`,
368
+ );
369
+ }
370
+ for (const system of systemsNamed(format)) {
371
+ const block = fm[system];
372
+ if (!isBlock(block) || !Object.hasOwn(block, key)) continue;
373
+ add(
374
+ "system-block-data-key",
375
+ key,
376
+ `\`${system}.${key}\` is a declared shared source and belongs in ` +
377
+ `\`data.${key}\`; a system states an exception under ` +
378
+ `\`${system}.system\`, not by holding the shared value itself`,
379
+ );
380
+ }
381
+ }
382
+
383
+ return findings;
384
+ }
385
+
386
+ /**
387
+ * Measure a corpus, and count what it finds by class.
388
+ *
389
+ * The counts are the point as much as the findings: #127 promotes a class to
390
+ * fatal when its count reaches zero, so a run that prints them is the epic's
391
+ * progress bar.
392
+ *
393
+ * @param {Iterable<object>} notes - `{file, raw, fm}` for each authored note.
394
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
395
+ * specification.
396
+ * @param {object} [opts]
397
+ * @param {boolean} [opts.strict=false] - Report the findings as errors rather
398
+ * than warnings. #127 turns this on one slice at a time.
399
+ * @returns {{findings: object[], notes: number, byClass: Record<string, number>}}
400
+ */
401
+ export function measureCorpus(notes, format, { strict = false } = {}) {
402
+ const findings = [];
403
+ let count = 0;
404
+ for (const note of notes) {
405
+ count += 1;
406
+ findings.push(...measureNote(note, format, { severity: strict ? "error" : "warning" }));
407
+ }
408
+ /** @type {Record<string, number>} */
409
+ const byClass = {};
410
+ for (const finding of findings) {
411
+ byClass[finding.class] = (byClass[finding.class] ?? 0) + 1;
412
+ }
413
+ return { findings, notes: count, byClass };
414
+ }
415
+
416
+ /**
417
+ * Where one declared field sends one authored value.
418
+ *
419
+ * @typedef {object} FieldPair
420
+ * @property {string} name - The authored key, as the declaration spells it.
421
+ * @property {string} to - The emitted path, `system.` prefix stripped.
422
+ */
423
+
424
+ /**
425
+ * `path` read as `prefix`, or as a key beneath it.
426
+ *
427
+ * Matching on whole segments rather than on characters, so `charge` is not a
428
+ * prefix of `charges` — a substring match would pair two unrelated fields and
429
+ * then report their destinations as a contradiction.
430
+ *
431
+ * @param {string} path - The dotted path.
432
+ * @param {string} prefix - The candidate prefix.
433
+ * @returns {string|undefined} The remainder — `""` for an exact match, `.value`
434
+ * for a key beneath it — or `undefined` when `path` does not sit under it.
435
+ */
436
+ function under(path, prefix) {
437
+ if (path === prefix) return "";
438
+ if (path.startsWith(`${prefix}.`)) return path.slice(prefix.length);
439
+ return undefined;
440
+ }
441
+
442
+ /**
443
+ * What an author is told when the specification and the declaration disagree.
444
+ *
445
+ * @param {object} finding - `{noteType, source, target, name, to}`.
446
+ * @returns {string} The message.
447
+ */
448
+ export function fieldDriftMessage({ noteType, source, target, name, to }) {
449
+ return (
450
+ `the format maps \`${source}\` on a \`${noteType}\` to \`${target}\`, ` +
451
+ `but the \`${noteType}\` field declaration writes \`${name}\` to ` +
452
+ `\`system.${to}\` — the specification and the declaration that compiles ` +
453
+ `it disagree, and one of the two is wrong`
454
+ );
455
+ }
456
+
457
+ /**
458
+ * The specification's mapping rows for one type and one system, as field paths.
459
+ *
460
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
461
+ * specification.
462
+ * @param {string} noteType - The type whose section to read.
463
+ * @param {string} system - The system column to read.
464
+ * @returns {import("./content-format.mjs").MappingClaim[]} Its claims.
465
+ */
466
+ function claimsFor(format, noteType, system) {
467
+ return format.claims.filter((c) => c.noteType === noteType && c.system === system);
468
+ }
469
+
470
+ /**
471
+ * Check the specification's per-type tables against the field declarations that
472
+ * compile them (#136).
473
+ *
474
+ * @param {object} opts
475
+ * @param {import("./content-format.mjs").ContentFormat} opts.format - The
476
+ * parsed specification.
477
+ * @param {Record<string, readonly object[]>} opts.itemFields - Item type → its
478
+ * `fields` declaration, as `itemBuilders` carries it.
479
+ * @param {string} opts.system - Which system column of the mapping tables these
480
+ * declarations compile. Supplied by the caller rather than assumed here: the
481
+ * declarations belong to one system, and the document maps onto several.
482
+ * @param {"warning"|"error"} [opts.severity="error"] - What a contradiction is.
483
+ * @returns {{findings: object[], coverage: object[], checked: string[],
484
+ * skipped: {spec: string[], registry: string[]}, fields: number}}
485
+ * Contradictions ready for `emitDiagnostic`, the per-type coverage, the types
486
+ * compared, the types out of reach on each side, and how many field pairs
487
+ * were compared.
488
+ */
489
+ export function checkDeclaredFields({ format, itemFields, system, severity = "error" }) {
490
+ const declared = itemFields ?? {};
491
+ const findings = [];
492
+ const coverage = [];
493
+ const checked = [];
494
+ const skippedSpec = [];
495
+ const skippedRegistry = [];
496
+ let fields = 0;
497
+
498
+ for (const noteType of format.types.keys()) {
499
+ if (!declared[noteType]) {
500
+ skippedSpec.push(noteType);
501
+ continue;
502
+ }
503
+ checked.push(noteType);
504
+
505
+ const authored = authoredFields(declared[noteType]);
506
+ for (const claim of claimsFor(format, noteType, system)) {
507
+ // A shared source is written `data.<path>`; a note-level one — the
508
+ // `subType` several tables name — carries no prefix.
509
+ const source = claim.source.replace(/^data\./, "");
510
+ const target = claim.target.replace(/^system\./, "");
511
+ // The longest declared name that the source sits under: a field
512
+ // declared `impact.die` claims `data.impact.die` ahead of any
513
+ // field declared `impact`.
514
+ let match;
515
+ let rest;
516
+ for (const field of authored) {
517
+ const remainder = under(source, field.name);
518
+ if (remainder === undefined) continue;
519
+ if (match && field.name.length <= match.name.length) continue;
520
+ match = field;
521
+ rest = remainder;
522
+ }
523
+ // No declaration names it. That is coverage, not a contradiction —
524
+ // the specification maps fields no builder emits yet, which is the
525
+ // ordinary mid-migration state (#127) and what `schema-check.mjs`
526
+ // already reports as unemitted.
527
+ if (!match) continue;
528
+ fields += 1;
529
+ if (under(target, match.to) === rest) continue;
530
+ findings.push({
531
+ file: format.file,
532
+ line: claim.line,
533
+ ...(claim.column === undefined ? {} : { column: claim.column }),
534
+ severity,
535
+ class: "field-drift",
536
+ message: fieldDriftMessage({
537
+ noteType,
538
+ source: claim.source,
539
+ target: claim.target,
540
+ name: match.name,
541
+ to: match.to,
542
+ }),
543
+ });
544
+ }
545
+
546
+ // The two vocabularies, side by side. Reported rather than asserted
547
+ // equal: the document names the *shared* source a field is written as,
548
+ // and a declaration names every key the system's own block accepts, so
549
+ // the sets legitimately differ until #127 has moved the corpus.
550
+ const registryKeys = new Set(authored.map((field) => field.name.split(".")[0]));
551
+ const specKeys = format.types.get(noteType).dataKeys;
552
+ coverage.push({
553
+ type: noteType,
554
+ specOnly: [...specKeys].filter((key) => !registryKeys.has(key)).sort(),
555
+ registryOnly: [...registryKeys].filter((key) => !specKeys.has(key)).sort(),
556
+ });
557
+ }
558
+
559
+ for (const type of Object.keys(declared)) {
560
+ if (!format.types.has(type)) skippedRegistry.push(type);
561
+ }
562
+
563
+ return {
564
+ findings,
565
+ coverage,
566
+ checked,
567
+ skipped: { spec: skippedSpec, registry: skippedRegistry },
568
+ fields,
569
+ };
570
+ }