@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.
@@ -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
+ }