@heroiclands/package-build 9.0.0 → 10.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +721 -0
- package/CONTENT.md +273 -13
- package/bin/content-build.mjs +437 -7
- package/content-config.mjs +259 -28
- package/docs/content-format.md +1418 -0
- package/engine/address-charset.mjs +62 -0
- package/engine/alias-index.mjs +153 -0
- package/engine/base-compiler.mjs +194 -4
- package/engine/content-address.mjs +4 -4
- package/engine/content-format-check.mjs +570 -0
- package/engine/content-format.mjs +253 -0
- package/engine/content-links.mjs +132 -56
- package/engine/content-lint.mjs +8 -1
- package/engine/diagnostics.mjs +33 -0
- package/engine/document-subtypes.mjs +440 -0
- package/engine/field-spec.mjs +49 -43
- package/engine/frontmatter-lint.mjs +351 -28
- package/engine/generate.mjs +32 -4
- package/engine/helpers.mjs +41 -29
- package/engine/ids.mjs +19 -1
- package/engine/index.mjs +15 -0
- package/engine/item-registry.mjs +72 -5
- package/engine/kb-manifest.mjs +36 -7
- package/engine/map-notes.mjs +34 -18
- package/engine/note-claims.mjs +383 -0
- package/engine/note-vocabulary.mjs +678 -0
- package/engine/pack-config.mjs +39 -22
- package/engine/pack-router.mjs +17 -6
- package/engine/prose-lint.mjs +55 -3
- package/engine/retired-fields.mjs +117 -3
- package/engine/scenes.mjs +19 -1
- package/engine/schema-check.mjs +347 -3
- package/engine/site-build.mjs +1 -1
- package/engine/site-index.mjs +38 -21
- package/engine/system-block.mjs +513 -0
- package/engine/web-wikilinks.mjs +112 -80
- package/engine/wikilink-syntax.mjs +30 -0
- package/engine/wikilinks.mjs +67 -51
- package/package.json +6 -2
- package/sohl/actors.mjs +249 -36
- package/sohl/document-subtypes.mjs +82 -0
- package/sohl/index.mjs +3 -0
- package/sohl/items.mjs +110 -14
- package/sohl/note-schemas.mjs +11 -7
- package/types/content-config.d.mts +48 -4
- package/types/engine/address-charset.d.mts +45 -0
- package/types/engine/alias-index.d.mts +122 -0
- package/types/engine/base-compiler.d.mts +132 -4
- package/types/engine/content-address.d.mts +2 -2
- package/types/engine/content-format-check.d.mts +163 -0
- package/types/engine/content-format.d.mts +101 -0
- package/types/engine/content-links.d.mts +16 -1
- package/types/engine/content-lint.d.mts +6 -0
- package/types/engine/diagnostics.d.mts +29 -0
- package/types/engine/document-subtypes.d.mts +233 -0
- package/types/engine/field-spec.d.mts +76 -23
- package/types/engine/frontmatter-lint.d.mts +47 -2
- package/types/engine/generate.d.mts +14 -1
- package/types/engine/helpers.d.mts +21 -13
- package/types/engine/ids.d.mts +10 -0
- package/types/engine/index.d.mts +5 -0
- package/types/engine/item-registry.d.mts +21 -2
- package/types/engine/kb-manifest.d.mts +35 -8
- package/types/engine/map-notes.d.mts +21 -11
- package/types/engine/note-claims.d.mts +113 -0
- package/types/engine/note-vocabulary.d.mts +251 -0
- package/types/engine/pack-config.d.mts +4 -3
- package/types/engine/pack-router.d.mts +4 -4
- package/types/engine/prose-lint.d.mts +6 -2
- package/types/engine/retired-fields.d.mts +73 -2
- package/types/engine/schema-check.d.mts +182 -0
- package/types/engine/system-block.d.mts +281 -0
- package/types/engine/web-wikilinks.d.mts +23 -12
- package/types/engine/wikilink-syntax.d.mts +29 -0
- package/types/sohl/actors.d.mts +62 -6
- package/types/sohl/document-subtypes.d.mts +14 -0
- package/types/sohl/index.d.mts +1 -0
- 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
|
+
}
|