@heroiclands/package-build 7.0.0 → 8.1.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.
- package/CHANGELOG.md +345 -0
- package/bin/content-build.mjs +76 -0
- package/bin/package-build.mjs +106 -0
- package/config.mjs +37 -0
- package/engine/foreign-catalog.mjs +47 -0
- package/engine/schema-check.mjs +332 -0
- package/engine/schema-extract.mjs +664 -0
- package/package.json +1 -1
- package/sohl/item-fields.mjs +0 -35
- package/types/engine/foreign-catalog.d.mts +15 -0
- package/types/engine/schema-check.d.mts +176 -0
- package/types/engine/schema-extract.d.mts +61 -0
|
@@ -97,6 +97,51 @@ export function catalogDir(config, id, version) {
|
|
|
97
97
|
*/
|
|
98
98
|
const itemsDir = (dir) => path.join(dir, "items");
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The file a system publishes its `system` field sets as (#60).
|
|
102
|
+
*
|
|
103
|
+
* @type {string}
|
|
104
|
+
*/
|
|
105
|
+
export const SCHEMA_ARTIFACT_FILE = "schema.json";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Where a cached dependency's published schema sits, if it shipped one.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} config - The resolved configuration.
|
|
111
|
+
* @param {string} id - The dependency's package id.
|
|
112
|
+
* @param {string} version - Its resolved version.
|
|
113
|
+
* @returns {string} The path, whether or not it exists.
|
|
114
|
+
*/
|
|
115
|
+
export function cachedSchemaPath(config, id, version) {
|
|
116
|
+
return path.join(catalogDir(config, id, version), SCHEMA_ARTIFACT_FILE);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Keep the dependency's published schema beside its extracted items.
|
|
121
|
+
*
|
|
122
|
+
* **Copied to one known place rather than read from where it landed.** The two
|
|
123
|
+
* fetch paths leave the unpacked archive in different states — a download
|
|
124
|
+
* unzips into `<cache>/package/` and keeps it, while `--from` unzips into a
|
|
125
|
+
* temporary directory and deletes it — so a reader that went looking in the
|
|
126
|
+
* unpacked tree would find the schema for one and not the other, which is the
|
|
127
|
+
* kind of difference that shows up as an unexplained skipped check.
|
|
128
|
+
*
|
|
129
|
+
* Absent is not an error: a system that has not adopted the artifact yet is
|
|
130
|
+
* simply unchecked, and saying so is {@link module:engine/schema-check}'s job
|
|
131
|
+
* rather than the fetch's.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} root - The unpacked package root.
|
|
134
|
+
* @param {string} dir - The dependency's cache directory.
|
|
135
|
+
* @returns {boolean} Whether one was published.
|
|
136
|
+
*/
|
|
137
|
+
function cacheSchemaArtifact(root, dir) {
|
|
138
|
+
const src = path.join(root, SCHEMA_ARTIFACT_FILE);
|
|
139
|
+
if (!fs.existsSync(src)) return false;
|
|
140
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
141
|
+
fs.copyFileSync(src, path.join(dir, SCHEMA_ARTIFACT_FILE));
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
100
145
|
/**
|
|
101
146
|
* Whether a dependency's cache is present and complete.
|
|
102
147
|
*
|
|
@@ -288,6 +333,7 @@ export async function fetchCatalog(config, rel) {
|
|
|
288
333
|
await downloadAndUnzip(download, raw);
|
|
289
334
|
|
|
290
335
|
await extractItemPacks(rel.id, version, manifest, raw, dir);
|
|
336
|
+
cacheSchemaArtifact(raw, dir);
|
|
291
337
|
return dir;
|
|
292
338
|
}
|
|
293
339
|
|
|
@@ -385,6 +431,7 @@ export async function fetchCatalogFromPath(config, rel, source) {
|
|
|
385
431
|
const dir = catalogDir(config, rel.id, version);
|
|
386
432
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
387
433
|
await extractItemPacks(rel.id, version, manifest, root, dir);
|
|
434
|
+
cacheSchemaArtifact(root, dir);
|
|
388
435
|
log.info(`${rel.id}@${version}: cached from ${source}`);
|
|
389
436
|
return dir;
|
|
390
437
|
} finally {
|
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
* What a builder **emits** into `system`, against what the receiving DataModel
|
|
16
|
+
* **declares** (#60).
|
|
17
|
+
*
|
|
18
|
+
* Foundry discards an unknown `system` key when a document is constructed, and
|
|
19
|
+
* says nothing: the value is simply absent at load, while the build that wrote
|
|
20
|
+
* it reported success. Both directions of that mismatch have already happened
|
|
21
|
+
* here and both compiled clean:
|
|
22
|
+
*
|
|
23
|
+
* - **Emitted, not declared.** `mysticalability` emitted `assocMysteryCode`,
|
|
24
|
+
* which no DataModel defined — 0.8.x had replaced it with
|
|
25
|
+
* `assocAffiliationCode` (#35). And `affiliation.subType`, authored on all 21
|
|
26
|
+
* of `sohl-kethira-basic`'s deities, is not defined at the version that module
|
|
27
|
+
* targets, so the divine/arcane split evaporates on load.
|
|
28
|
+
* - **Declared, not emitted.** The mirror image, fixed by hand in
|
|
29
|
+
* content-build#3.
|
|
30
|
+
*
|
|
31
|
+
* Neither was found by tooling. Both were found by set-subtracting compiled
|
|
32
|
+
* documents' `system` keys against `defineSchema()` **by hand**, which is how
|
|
33
|
+
* the next one would have to be found too.
|
|
34
|
+
*
|
|
35
|
+
* **The emitted half needs no compilation and no parsing.** A builder *is* its
|
|
36
|
+
* field list — {@link module:engine/field-spec} makes `buildFromFields` the only
|
|
37
|
+
* statement of the mapping — so every `system` path a type can emit is
|
|
38
|
+
* `field.to`, known statically. Nothing here compiles a document to find out.
|
|
39
|
+
*
|
|
40
|
+
* **The declared half is the consumer's, and arrives as data.** `defineSchema()`
|
|
41
|
+
* lives in the target system's `src/`, so the system publishes its field sets as
|
|
42
|
+
* a build artifact and this reads it — the shape the link manifest already uses
|
|
43
|
+
* for addresses, rather than reaching into a sibling checkout.
|
|
44
|
+
*
|
|
45
|
+
* **Pinned to the declared version, never the system's `main`.** This is the
|
|
46
|
+
* whole of the kethira case: `subType` *is* defined on sohl `main` and simply
|
|
47
|
+
* has not been released, while the module declares `verified: 0.8.2`. A check
|
|
48
|
+
* run against `main` passes and the field still evaporates for every user, so
|
|
49
|
+
* the comparison is against the schema of the declared
|
|
50
|
+
* `compatibility.verified`.
|
|
51
|
+
*
|
|
52
|
+
* @module
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import fs from "node:fs";
|
|
56
|
+
import path from "node:path";
|
|
57
|
+
|
|
58
|
+
import { cachedSchemaPath, SCHEMA_ARTIFACT_FILE } from "./foreign-catalog.mjs";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The artifact version this module reads.
|
|
62
|
+
*
|
|
63
|
+
* A mismatch stops the check rather than resolving anyway: a schema read under
|
|
64
|
+
* the wrong shape would report confident nonsense in both directions, and a
|
|
65
|
+
* silently skipped check is the state #60 exists to leave.
|
|
66
|
+
*
|
|
67
|
+
* @type {number}
|
|
68
|
+
*/
|
|
69
|
+
export const SCHEMA_ARTIFACT_VERSION = 1;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A system's published field sets.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {object} SchemaArtifact
|
|
75
|
+
* @property {number} version - {@link SCHEMA_ARTIFACT_VERSION}.
|
|
76
|
+
* @property {string} system - The system id the schemas belong to.
|
|
77
|
+
* @property {string} systemVersion - The system version they were read from.
|
|
78
|
+
* @property {Record<string, Record<string, {own: string[], inherited: string[]}>>} documents
|
|
79
|
+
* Document type → subtype → its field paths, dotted for nested schema fields.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why `own` and `inherited` are recorded apart.
|
|
84
|
+
*
|
|
85
|
+
* A subtype's schema spreads its parent's — `MysticalAbilityDataModel` spreads
|
|
86
|
+
* `SohlItemDataModel`, which spreads the common one — so `notes`, `docHtml` and
|
|
87
|
+
* the rest arrive on every subtype. Those are the system's own runtime
|
|
88
|
+
* concerns, filled by the system rather than by a content builder, and a
|
|
89
|
+
* builder is not expected to emit them.
|
|
90
|
+
*
|
|
91
|
+
* Collapsing the two sets would make the *declared, not emitted* direction
|
|
92
|
+
* report every inherited field on every type: a wall of findings that are all
|
|
93
|
+
* correct and none actionable, which is the shape of report people learn to
|
|
94
|
+
* skip. So the two directions read different sets:
|
|
95
|
+
*
|
|
96
|
+
* | direction | read against | severity |
|
|
97
|
+
* | --- | --- | --- |
|
|
98
|
+
* | emitted, not declared | `own` ∪ `inherited` — the field must exist *somewhere* | error |
|
|
99
|
+
* | declared, not emitted | `own` only — what this subtype adds is what its builder answers for | report |
|
|
100
|
+
*
|
|
101
|
+
* @param {SchemaArtifact} artifact - The published schemas.
|
|
102
|
+
* @param {string} documentType - `Item`, `Actor`, …
|
|
103
|
+
* @param {string} subtype - The document subtype.
|
|
104
|
+
* @returns {{own: Set<string>, all: Set<string>}|null} The sets, or `null` when
|
|
105
|
+
* the artifact declares no such subtype.
|
|
106
|
+
*/
|
|
107
|
+
export function declaredFields(artifact, documentType, subtype) {
|
|
108
|
+
const entry = artifact?.documents?.[documentType]?.[subtype];
|
|
109
|
+
if (!entry) return null;
|
|
110
|
+
const own = new Set(entry.own ?? []);
|
|
111
|
+
const all = new Set([...own, ...(entry.inherited ?? [])]);
|
|
112
|
+
return { own, all };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every `system` path a field declaration can emit.
|
|
117
|
+
*
|
|
118
|
+
* `buildFromFields` writes each field at its `to`, so the declaration is the
|
|
119
|
+
* emitted key set. A nested `to` (`charges.value`) is recorded whole, and its
|
|
120
|
+
* parents are recorded too: a schema declares `charges` as a `SchemaField` and
|
|
121
|
+
* the path beneath it separately, so a comparison that knew only the leaf would
|
|
122
|
+
* report the container as unemitted and the leaf as undeclared.
|
|
123
|
+
*
|
|
124
|
+
* @param {readonly {to: string}[]} fields - A type's field declaration.
|
|
125
|
+
* @returns {Set<string>} The paths, parents included.
|
|
126
|
+
*/
|
|
127
|
+
export function emittedFields(fields) {
|
|
128
|
+
const out = new Set();
|
|
129
|
+
for (const field of fields ?? []) {
|
|
130
|
+
if (typeof field?.to !== "string" || !field.to) continue;
|
|
131
|
+
const parts = field.to.split(".");
|
|
132
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
133
|
+
out.add(parts.slice(0, i).join("."));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a declared path is written by a field the builder already emits.
|
|
141
|
+
*
|
|
142
|
+
* The emitted set records a path's parents ({@link emittedFields}), so the
|
|
143
|
+
* *undeclared* direction needs no such walk — `charges.value` emitted implies
|
|
144
|
+
* `charges` emitted. The reverse is not symmetric: a builder may write a whole
|
|
145
|
+
* object at `charges` and never name the leaves the schema declares beneath it,
|
|
146
|
+
* and those leaves are populated all the same.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} path - A declared field path.
|
|
149
|
+
* @param {ReadonlySet<string>} emitted - What the builder writes.
|
|
150
|
+
* @returns {boolean} Whether the path, or any ancestor of it, is written.
|
|
151
|
+
*/
|
|
152
|
+
function coveredByAncestor(path, emitted) {
|
|
153
|
+
const parts = path.split(".");
|
|
154
|
+
for (let i = parts.length; i >= 1; i--) {
|
|
155
|
+
if (emitted.has(parts.slice(0, i).join("."))) return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Compare one system's builders against one system's published schemas.
|
|
162
|
+
*
|
|
163
|
+
* Pure: field declarations in, findings out. The caller supplies both halves so
|
|
164
|
+
* that a system checking itself and a module checking against a vendored
|
|
165
|
+
* artifact run the identical comparison.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} opts
|
|
168
|
+
* @param {Record<string, readonly {to: string}[]>} opts.builders - Type →
|
|
169
|
+
* field declaration, as `ITEM_FIELDS` holds it.
|
|
170
|
+
* @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
|
|
171
|
+
* @param {string} [opts.documentType="Item"] - Which document type the builders
|
|
172
|
+
* compile into.
|
|
173
|
+
* @param {(type: string) => string} [opts.subtypeOf] - Maps a builder's type to
|
|
174
|
+
* the document subtype it emits. Defaults to identity, which is what the
|
|
175
|
+
* coincidence of names amounts to today (#79) — stated as a seam so that the
|
|
176
|
+
* explicit map replaces a default rather than a hard-coded assumption.
|
|
177
|
+
* @returns {{undeclared: object[], unemitted: object[], skipped: string[]}}
|
|
178
|
+
* `undeclared` fails a build; `unemitted` is reported; `skipped` names the
|
|
179
|
+
* types the artifact says nothing about.
|
|
180
|
+
*/
|
|
181
|
+
export function compareFields({
|
|
182
|
+
builders,
|
|
183
|
+
artifact,
|
|
184
|
+
documentType = "Item",
|
|
185
|
+
subtypeOf = (type) => type,
|
|
186
|
+
}) {
|
|
187
|
+
if (artifact?.version !== SCHEMA_ARTIFACT_VERSION) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`package-build: schema artifact version ${artifact?.version ?? "(absent)"}, ` +
|
|
190
|
+
`expected ${SCHEMA_ARTIFACT_VERSION}. A schema read under the wrong ` +
|
|
191
|
+
`shape would report confidently in both directions, so the check ` +
|
|
192
|
+
`stops rather than resolving anyway.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const undeclared = [];
|
|
197
|
+
const unemitted = [];
|
|
198
|
+
const skipped = [];
|
|
199
|
+
|
|
200
|
+
for (const [type, fields] of Object.entries(builders ?? {})) {
|
|
201
|
+
const subtype = subtypeOf(type);
|
|
202
|
+
const declared = declaredFields(artifact, documentType, subtype);
|
|
203
|
+
if (!declared) {
|
|
204
|
+
// Not a finding: a builder may compile into a type this system does
|
|
205
|
+
// not define at all, which is a routing question (#79) rather than a
|
|
206
|
+
// field one. Named so the count is never mistaken for coverage.
|
|
207
|
+
skipped.push(type);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const emitted = emittedFields(fields);
|
|
212
|
+
for (const path of emitted) {
|
|
213
|
+
if (declared.all.has(path)) continue;
|
|
214
|
+
undeclared.push({
|
|
215
|
+
type,
|
|
216
|
+
subtype,
|
|
217
|
+
documentType,
|
|
218
|
+
field: path,
|
|
219
|
+
systemVersion: artifact.systemVersion,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
for (const path of declared.own) {
|
|
223
|
+
// A builder that writes a whole object writes everything beneath
|
|
224
|
+
// it: `charges` emitted covers the schema's `charges.value` and
|
|
225
|
+
// `charges.max`. Checking the leaf alone reported both as unwritten
|
|
226
|
+
// on a type that populates them correctly — two findings, both
|
|
227
|
+
// false, on the first real schema this was run against.
|
|
228
|
+
if (coveredByAncestor(path, emitted)) continue;
|
|
229
|
+
unemitted.push({
|
|
230
|
+
type,
|
|
231
|
+
subtype,
|
|
232
|
+
documentType,
|
|
233
|
+
field: path,
|
|
234
|
+
systemVersion: artifact.systemVersion,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { undeclared, unemitted, skipped };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The published schema this build should check itself against, or `null`.
|
|
244
|
+
*
|
|
245
|
+
* **Which system, and which version, are already settled.** `stats.systemId`
|
|
246
|
+
* and `stats.systemVersion` are derived rather than authored (#48) — a system
|
|
247
|
+
* package is its own system, and a module takes the one it requires — and the
|
|
248
|
+
* version is the `compatibility.verified` it pins. So the question "whose
|
|
249
|
+
* schema, at what version" has one answer here rather than a second set of
|
|
250
|
+
* configuration to disagree with the first.
|
|
251
|
+
*
|
|
252
|
+
* Two places to find it, because a system checks itself against source it owns
|
|
253
|
+
* while a module checks against a dependency it fetched:
|
|
254
|
+
*
|
|
255
|
+
* - **A system**: its own `schema.json`, generated from its `src/` and
|
|
256
|
+
* committed beside it.
|
|
257
|
+
* - **A module**: the copy cached by `content-build deps fetch`, from the
|
|
258
|
+
* archive of the version it pins — which is what makes the comparison happen
|
|
259
|
+
* at `verified` rather than against whatever the system's `main` holds today.
|
|
260
|
+
* That distinction is the whole of the `affiliation.subType` case.
|
|
261
|
+
*
|
|
262
|
+
* `null` where there is nothing to check against: a system-agnostic module
|
|
263
|
+
* stamps no system at all, and a system that has not adopted the artifact yet
|
|
264
|
+
* is simply unchecked. Neither is an error, and the caller says which it was.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} config - The resolved build configuration.
|
|
267
|
+
* @returns {{artifact: SchemaArtifact, source: string}|null} The schema and
|
|
268
|
+
* where it was read from.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveSchemaArtifact(config) {
|
|
271
|
+
const systemId = config?.stats?.systemId;
|
|
272
|
+
if (!systemId) return null;
|
|
273
|
+
|
|
274
|
+
const read = (file) => ({
|
|
275
|
+
artifact: JSON.parse(fs.readFileSync(file, "utf8")),
|
|
276
|
+
source: file,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// The system checking itself, against the schema its own build published.
|
|
280
|
+
if (
|
|
281
|
+
config.packageKind === "systems" &&
|
|
282
|
+
config.foundryPackage === systemId
|
|
283
|
+
) {
|
|
284
|
+
const own = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
|
|
285
|
+
return fs.existsSync(own) ? read(own) : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const version = config?.stats?.systemVersion;
|
|
289
|
+
if (!version) return null;
|
|
290
|
+
const cached = cachedSchemaPath(config, systemId, version);
|
|
291
|
+
return fs.existsSync(cached) ? read(cached) : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What an author is told about a field the target system does not define.
|
|
296
|
+
*
|
|
297
|
+
* Names the version, because the same field may be perfectly well defined on
|
|
298
|
+
* the system's `main` and simply unreleased — which is exactly the kethira case,
|
|
299
|
+
* and the difference between "you typed it wrong" and "you are ahead of your
|
|
300
|
+
* pin".
|
|
301
|
+
*
|
|
302
|
+
* @param {object} finding - One entry from `undeclared`.
|
|
303
|
+
* @returns {string} The message.
|
|
304
|
+
*/
|
|
305
|
+
export function undeclaredMessage(finding) {
|
|
306
|
+
return (
|
|
307
|
+
`\`${finding.type}\` emits \`system.${finding.field}\`, which ` +
|
|
308
|
+
`${finding.documentType} subtype "${finding.subtype}" does not define at ` +
|
|
309
|
+
`${finding.systemVersion} — Foundry discards an unknown \`system\` key ` +
|
|
310
|
+
`when the document is constructed, without a warning, so the value is ` +
|
|
311
|
+
`lost at load while the build reports success`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* What an author is told about a declared field no builder writes.
|
|
317
|
+
*
|
|
318
|
+
* Advisory rather than fatal: a field the system fills at runtime, or one added
|
|
319
|
+
* ahead of the content that will use it, is not a defect. Only fields the
|
|
320
|
+
* subtype declares *itself* are reported — see {@link declaredFields}.
|
|
321
|
+
*
|
|
322
|
+
* @param {object} finding - One entry from `unemitted`.
|
|
323
|
+
* @returns {string} The message.
|
|
324
|
+
*/
|
|
325
|
+
export function unemittedMessage(finding) {
|
|
326
|
+
return (
|
|
327
|
+
`${finding.documentType} subtype "${finding.subtype}" declares ` +
|
|
328
|
+
`\`system.${finding.field}\` at ${finding.systemVersion}, which ` +
|
|
329
|
+
`\`${finding.type}\` never emits — every compiled document will carry the ` +
|
|
330
|
+
`field's initial value rather than an authored one`
|
|
331
|
+
);
|
|
332
|
+
}
|