@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.
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Read a package's DataModel field sets out of its source, as data.
3
+ *
4
+ * The consuming half of this contract already lives in `schema-check.mjs`: a
5
+ * content build subtracts what its builders emit from what a document will
6
+ * actually receive, because Foundry discards an unknown `system` key at
7
+ * construction and says nothing about it (#60). What was missing is the
8
+ * producing half — until now each system carried its own extractor, and the
9
+ * first one to exist hardcoded {@link SCHEMA_ARTIFACT_VERSION}, a constant this
10
+ * package owns. Two producers stamping a third repository's constant by hand is
11
+ * the drift this module exists to remove: the version is imported, not restated.
12
+ *
13
+ * ## Read from the source, not from a running Foundry
14
+ *
15
+ * A DataModel's schema is only introspectable inside Foundry: `defineSchema()`
16
+ * returns `new StringField(...)` and friends, which do not exist in Node. So
17
+ * this reads the AST rather than a regex, because every shape it has to follow
18
+ * is structural rather than textual.
19
+ *
20
+ * TypeScript's parser reads plain JavaScript too, which is what makes one
21
+ * reader serve both shapes in play — `sohl` is TypeScript, `hm3` is JavaScript.
22
+ * That is also why the dependency sits *here*: this package already pins the
23
+ * compiler for `coverage.mjs`, so a JavaScript-only repository does not acquire
24
+ * a TypeScript pin merely to describe its own data models.
25
+ *
26
+ * ## The four shapes it follows
27
+ *
28
+ * - **A registry, not a directory walk.** The subtype → DataModel map is the
29
+ * canonical statement of which subtypes exist. Walking `*Model.js` instead
30
+ * would publish schemas for classes nothing registers, and would silently
31
+ * miss one whose filename does not match.
32
+ * - **Inheritance, however it is spelled.** `...Super.defineSchema()`,
33
+ * `...super.defineSchema()`, `Object.assign(super.defineSchema(), {…})` and a
34
+ * subclass with no `defineSchema()` at all are four spellings of one idea.
35
+ * All four are followed, because which one a repository uses is a matter of
36
+ * house style and says nothing about the schema.
37
+ * - **Delegation.** `defineSchema() { return defineXDataSchema(); }` is followed
38
+ * to the function that builds the literal.
39
+ * - **`SchemaField` nests.** `charges: new SchemaField({ value, max })` records
40
+ * `charges`, `charges.value` and `charges.max`, because a builder may write
41
+ * the whole object or the leaves. Written bare or as `fields.SchemaField`,
42
+ * since both spellings are in use.
43
+ *
44
+ * Fields reached by inheritance are recorded apart from the subtype's own,
45
+ * because the two answer different questions: a builder must not emit a field
46
+ * nothing declares *anywhere*, but it is not expected to fill the system's own
47
+ * inherited machinery.
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import fs from "node:fs";
53
+ import path from "node:path";
54
+ import ts from "typescript";
55
+
56
+ import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
57
+
58
+ /**
59
+ * Which files a specifier may resolve to, in order.
60
+ *
61
+ * `.ts` leads because a TypeScript repository also ships compiled `.js`
62
+ * alongside its sources in some layouts, and the source is the thing being
63
+ * described.
64
+ */
65
+ const EXTENSIONS = [".ts", ".js", ".mjs"];
66
+
67
+ /**
68
+ * Parse one file into an AST, memoised per run.
69
+ *
70
+ * The memo matters more than it looks: a registry file is re-read once per
71
+ * subtype otherwise, and an inheritance chain re-reads its base class once per
72
+ * leaf. Both are quadratic on a file that never changes mid-run.
73
+ *
74
+ * @param {string} file - Absolute path.
75
+ * @param {Map<string, ts.SourceFile>} cache - The per-run memo.
76
+ * @returns {ts.SourceFile} The parsed file.
77
+ */
78
+ function parse(file, cache) {
79
+ const hit = cache.get(file);
80
+ if (hit) return hit;
81
+ const src = ts.createSourceFile(
82
+ file,
83
+ fs.readFileSync(file, "utf8"),
84
+ ts.ScriptTarget.Latest,
85
+ true,
86
+ );
87
+ cache.set(file, src);
88
+ return src;
89
+ }
90
+
91
+ /** A property name, whether written bare, quoted or computed-as-literal. */
92
+ function propName(name) {
93
+ if (ts.isIdentifier(name)) return name.text;
94
+ if (ts.isStringLiteral(name)) return name.text;
95
+ return name.getText();
96
+ }
97
+
98
+ /**
99
+ * A *field* name, which unlike a property name elsewhere must be knowable.
100
+ *
101
+ * A computed key — `[`${name}Date`]: worldTimeDateField()` — depends on an
102
+ * argument this reader does not evaluate, so its real name is not in the file.
103
+ * {@link propName} would hand back the source text, and a field called
104
+ * ``[`${name}Date`]`` matches nothing a builder could ever emit: it is absent
105
+ * from the schema for checking purposes while *looking* present, and it shows
106
+ * up as permanently unemitted noise.
107
+ *
108
+ * So this refuses rather than guessing. The schema is a contract other
109
+ * repositories read, and a contract it cannot state is worth stopping for —
110
+ * the same reason `compareFields` refuses an artifact of the wrong version
111
+ * instead of resolving it anyway. Writing the keys out fixes it at the source,
112
+ * where the names are actually decided.
113
+ *
114
+ * @param {ts.PropertyName} name - The property name node.
115
+ * @param {string} file - For the message.
116
+ * @returns {string} The literal field name.
117
+ * @throws {Error} When the name is computed.
118
+ */
119
+ function fieldName(name, file) {
120
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
121
+ throw new Error(
122
+ `${path.basename(file)} declares a schema field with a computed name, ` +
123
+ `\`${name.getText()}\`, whose value depends on an argument this ` +
124
+ `reader does not evaluate. Write the keys out so the published ` +
125
+ `schema can name them.`,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The `subtype: ClassName` entries of a registry object literal.
131
+ *
132
+ * @param {ts.SourceFile} src - The parsed file holding the registry.
133
+ * @param {string} name - The registry binding, e.g. `itemModels`.
134
+ * @returns {Map<string, string>} Subtype to DataModel class name.
135
+ */
136
+ export function registryOf(src, name) {
137
+ const out = new Map();
138
+ const visit = (node) => {
139
+ if (
140
+ ts.isVariableDeclaration(node) &&
141
+ ts.isIdentifier(node.name) &&
142
+ node.name.text === name &&
143
+ node.initializer
144
+ ) {
145
+ // `{…} satisfies ItemDMMap` and `{…} as const` both wrap the
146
+ // literal without changing it.
147
+ let init = node.initializer;
148
+ if (ts.isSatisfiesExpression?.(init) || ts.isAsExpression(init)) {
149
+ init = init.expression;
150
+ }
151
+ if (ts.isObjectLiteralExpression(init)) {
152
+ for (const p of init.properties) {
153
+ if (
154
+ ts.isPropertyAssignment(p) &&
155
+ ts.isIdentifier(p.initializer)
156
+ ) {
157
+ out.set(propName(p.name), p.initializer.text);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ ts.forEachChild(node, visit);
163
+ };
164
+ visit(src);
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * The `tsconfig.json` path aliases, read rather than restated.
170
+ *
171
+ * A TypeScript repository may import every DataModel through `@src/…`, so a
172
+ * resolver that understood only relative specifiers would find none of them.
173
+ * Reading the mapping means adding an alias there does not silently make a
174
+ * subtype unreadable here.
175
+ *
176
+ * Absent for a JavaScript repository, which is not an error — it simply has no
177
+ * aliases to resolve.
178
+ *
179
+ * @param {string} rootDir - The repository root.
180
+ * @returns {[string, string][]} Prefix to directory, longest prefix first.
181
+ */
182
+ export function pathAliases(rootDir) {
183
+ const file = path.join(rootDir, "tsconfig.json");
184
+ if (!fs.existsSync(file)) return [];
185
+ // Parsed as plain JSON, deliberately. `tsconfig.json` *permits* comments,
186
+ // and a regex stripping `/* … */` eats from the slash-star inside a path
187
+ // string like `"@types/*"` to the next one, corrupting the file it was
188
+ // meant to clean. If comments ever appear, reach for a JSONC parser.
189
+ let json;
190
+ try {
191
+ json = JSON.parse(fs.readFileSync(file, "utf8"));
192
+ } catch {
193
+ return [];
194
+ }
195
+ const paths = json.compilerOptions?.paths ?? {};
196
+ return Object.entries(paths)
197
+ .filter(([, target]) => Array.isArray(target) && target.length)
198
+ .map(([alias, [target]]) => [
199
+ alias.replace(/\*$/, ""),
200
+ path.resolve(rootDir, String(target).replace(/\*$/, "")),
201
+ ])
202
+ .sort((a, b) => b[0].length - a[0].length);
203
+ }
204
+
205
+ /** Resolve an import specifier — relative or aliased — to a file on disk. */
206
+ function resolveSpecifier(fromDir, spec, aliases) {
207
+ let base = null;
208
+ if (spec.startsWith(".")) {
209
+ base = path.resolve(fromDir, spec.replace(/\.(m?js|ts)$/, ""));
210
+ } else {
211
+ for (const [prefix, dir] of aliases) {
212
+ if (!spec.startsWith(prefix)) continue;
213
+ base = path.join(dir, spec.slice(prefix.length));
214
+ break;
215
+ }
216
+ }
217
+ if (!base) return null;
218
+ for (const ext of EXTENSIONS) {
219
+ for (const candidate of [
220
+ `${base}${ext}`,
221
+ path.join(base, `index${ext}`),
222
+ ]) {
223
+ if (fs.existsSync(candidate)) return candidate;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Where an imported name comes from, resolved to a file on disk.
231
+ *
232
+ * @param {ts.SourceFile} src - The importing file.
233
+ * @param {string} name - The imported binding.
234
+ * @param {[string, string][]} aliases - From {@link pathAliases}.
235
+ * @returns {string|null} An absolute path, or `null` when not imported.
236
+ */
237
+ function importSourceOf(src, name, aliases) {
238
+ let found = null;
239
+ ts.forEachChild(src, (node) => {
240
+ if (found || !ts.isImportDeclaration(node)) return;
241
+ const bindings = node.importClause?.namedBindings;
242
+ if (!bindings || !ts.isNamedImports(bindings)) return;
243
+ if (!bindings.elements.some((e) => e.name.text === name)) return;
244
+ const spec = node.moduleSpecifier;
245
+ if (!ts.isStringLiteral(spec)) return;
246
+ found = resolveSpecifier(
247
+ path.dirname(src.fileName),
248
+ spec.text,
249
+ aliases,
250
+ );
251
+ });
252
+ return found;
253
+ }
254
+
255
+ /** The class declaration for `className`, if this file declares it. */
256
+ function classDeclIn(src, className) {
257
+ let found = null;
258
+ const visit = (node) => {
259
+ if (found) return;
260
+ if (ts.isClassDeclaration(node) && node.name?.text === className) {
261
+ found = node;
262
+ return;
263
+ }
264
+ ts.forEachChild(node, visit);
265
+ };
266
+ visit(src);
267
+ return found;
268
+ }
269
+
270
+ /**
271
+ * Find a class, in the file that names it or in the file that file imports it
272
+ * from.
273
+ *
274
+ * Both orders occur: a registry may sit in the same file as the classes it
275
+ * maps, or in a configuration module that imports every one of them. Looking
276
+ * locally first means neither layout needs to be declared.
277
+ *
278
+ * @returns {{file: string, src: ts.SourceFile, decl: ts.ClassDeclaration}|null}
279
+ */
280
+ function locateClass(fromFile, className, aliases, cache) {
281
+ const src = parse(fromFile, cache);
282
+ const decl = classDeclIn(src, className);
283
+ if (decl) return { file: fromFile, src, decl };
284
+
285
+ const imported = importSourceOf(src, className, aliases);
286
+ if (!imported) return null;
287
+ const importedSrc = parse(imported, cache);
288
+ const importedDecl = classDeclIn(importedSrc, className);
289
+ return importedDecl ?
290
+ { file: imported, src: importedSrc, decl: importedDecl }
291
+ : null;
292
+ }
293
+
294
+ /**
295
+ * The name of the class this one extends, when that is a plain identifier.
296
+ *
297
+ * `extends foundry.abstract.TypeDataModel` deliberately yields `null`: the
298
+ * Foundry base is where the walk stops, and it is spelled as a property access
299
+ * rather than an identifier in every repository here, so the shape that ends
300
+ * the chain is also the shape this cannot follow.
301
+ */
302
+ function superClassOf(decl) {
303
+ for (const clause of decl.heritageClauses ?? []) {
304
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
305
+ const [type] = clause.types;
306
+ if (type && ts.isIdentifier(type.expression))
307
+ return type.expression.text;
308
+ }
309
+ return null;
310
+ }
311
+
312
+ /** The expression a function body returns, if it returns one directly. */
313
+ function returnExpression(body) {
314
+ let found = null;
315
+ const visit = (node) => {
316
+ if (found) return;
317
+ if (ts.isReturnStatement(node) && node.expression) {
318
+ found = node.expression;
319
+ return;
320
+ }
321
+ ts.forEachChild(node, visit);
322
+ };
323
+ visit(body);
324
+ return found;
325
+ }
326
+
327
+ /** The object literal a function body returns, if it returns one directly. */
328
+ function returnedLiteral(body) {
329
+ if (ts.isObjectLiteralExpression(body)) return body;
330
+ const expr = returnExpression(body);
331
+ return expr && ts.isObjectLiteralExpression(expr) ? expr : null;
332
+ }
333
+
334
+ /**
335
+ * The object literal a named local function or arrow returns.
336
+ *
337
+ * @param {ts.SourceFile} src - The file being read.
338
+ * @param {string} fnName - The function to resolve.
339
+ * @returns {ts.ObjectLiteralExpression|null} The literal it returns.
340
+ */
341
+ function literalReturnedBy(src, fnName) {
342
+ let literal = null;
343
+ const visit = (node) => {
344
+ if (literal) return;
345
+ const isTarget =
346
+ (ts.isFunctionDeclaration(node) && node.name?.text === fnName) ||
347
+ (ts.isVariableDeclaration(node) &&
348
+ ts.isIdentifier(node.name) &&
349
+ node.name.text === fnName);
350
+ if (isTarget) {
351
+ const body =
352
+ ts.isFunctionDeclaration(node) ? node.body
353
+ : node.initializer && ts.isArrowFunction(node.initializer) ?
354
+ node.initializer.body
355
+ : null;
356
+ if (body) literal = returnedLiteral(body);
357
+ }
358
+ ts.forEachChild(node, visit);
359
+ };
360
+ visit(src);
361
+ return literal;
362
+ }
363
+
364
+ /** `X.defineSchema()` or `super.defineSchema()`, as an inheritance edge. */
365
+ function schemaCallEdge(expr) {
366
+ if (!ts.isCallExpression(expr)) return null;
367
+ const callee = expr.expression;
368
+ if (ts.isIdentifier(callee)) {
369
+ // `...defineSohlItemDataSchema()` — a local builder function.
370
+ return { kind: "local", name: callee.text };
371
+ }
372
+ if (!ts.isPropertyAccessExpression(callee)) return null;
373
+ if (callee.name.text !== "defineSchema") return null;
374
+ if (callee.expression.kind === ts.SyntaxKind.SuperKeyword) {
375
+ return { kind: "super", name: null };
376
+ }
377
+ if (ts.isIdentifier(callee.expression)) {
378
+ return { kind: "class", name: callee.expression.text };
379
+ }
380
+ return null;
381
+ }
382
+
383
+ /** `Object.assign(…)`. */
384
+ function isObjectAssign(expr) {
385
+ return (
386
+ ts.isCallExpression(expr) &&
387
+ ts.isPropertyAccessExpression(expr.expression) &&
388
+ ts.isIdentifier(expr.expression.expression) &&
389
+ expr.expression.expression.text === "Object" &&
390
+ expr.expression.name.text === "assign"
391
+ );
392
+ }
393
+
394
+ /**
395
+ * What a class's `defineSchema()` contributes: its own literals, and the edges
396
+ * it inherits along.
397
+ *
398
+ * A subclass with no `defineSchema()` at all contributes nothing of its own and
399
+ * inherits everything — `class MiscGearModel extends GearModel {}` is a real
400
+ * and meaningful declaration, not a gap in the data.
401
+ *
402
+ * @returns {{literals: ts.ObjectLiteralExpression[], edges: object[]}|null}
403
+ * `null` when the class declares a `defineSchema()` this cannot follow.
404
+ */
405
+ function schemaContributions(src, decl) {
406
+ const method = decl.members.find(
407
+ (m) =>
408
+ ts.isMethodDeclaration(m) &&
409
+ propName(m.name) === "defineSchema" &&
410
+ m.body,
411
+ );
412
+ // No `defineSchema()` — the whole schema is the parent's.
413
+ if (!method)
414
+ return { literals: [], edges: [{ kind: "super", name: null }] };
415
+
416
+ const direct = returnedLiteral(method.body);
417
+ if (direct) return { literals: [direct], edges: [] };
418
+
419
+ const ret = returnExpression(method.body);
420
+ if (!ret) return null;
421
+
422
+ // `return Object.assign(super.defineSchema(), { … })`. Every object
423
+ // literal argument contributes fields — including the `{}` some styles
424
+ // pass as the target — and every `defineSchema()` call is an edge.
425
+ if (isObjectAssign(ret)) {
426
+ const literals = [];
427
+ const edges = [];
428
+ for (const arg of ret.arguments) {
429
+ if (ts.isObjectLiteralExpression(arg)) literals.push(arg);
430
+ const edge = schemaCallEdge(arg);
431
+ if (edge) edges.push(edge);
432
+ }
433
+ return { literals, edges };
434
+ }
435
+
436
+ // `return defineXDataSchema();` — follow the identifier once.
437
+ const edge = schemaCallEdge(ret);
438
+ if (edge?.kind === "local") {
439
+ const lit = literalReturnedBy(src, edge.name);
440
+ if (lit) return { literals: [lit], edges: [] };
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /** Whether an expression is a `SchemaField` construction, however spelled. */
446
+ function isSchemaField(expr) {
447
+ if (!ts.isNewExpression(expr)) return false;
448
+ const callee = expr.expression;
449
+ if (ts.isIdentifier(callee)) return callee.text === "SchemaField";
450
+ // `new fields.SchemaField({ … })`
451
+ if (ts.isPropertyAccessExpression(callee)) {
452
+ return callee.name.text === "SchemaField";
453
+ }
454
+ return false;
455
+ }
456
+
457
+ /** The keys of a `new SchemaField({ … })`, recursively dotted. */
458
+ function nestedKeysOf(expr, file) {
459
+ if (!isSchemaField(expr)) return [];
460
+ const arg = expr.arguments?.[0];
461
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return [];
462
+ const out = [];
463
+ for (const p of arg.properties) {
464
+ if (!ts.isPropertyAssignment(p)) continue;
465
+ const key = fieldName(p.name, file);
466
+ out.push(key);
467
+ for (const child of nestedKeysOf(p.initializer, file)) {
468
+ out.push(`${key}.${child}`);
469
+ }
470
+ }
471
+ return out;
472
+ }
473
+
474
+ /**
475
+ * Every field path an object literal declares, and what it spreads.
476
+ *
477
+ * @returns {{own: string[], edges: object[]}}
478
+ */
479
+ function readLiteral(literal, file) {
480
+ const own = [];
481
+ const edges = [];
482
+ for (const p of literal.properties) {
483
+ if (ts.isSpreadAssignment(p)) {
484
+ const edge = schemaCallEdge(p.expression);
485
+ if (edge) edges.push(edge);
486
+ continue;
487
+ }
488
+ if (!ts.isPropertyAssignment(p)) continue;
489
+ const key = fieldName(p.name, file);
490
+ own.push(key);
491
+ for (const child of nestedKeysOf(p.initializer, file)) {
492
+ own.push(`${key}.${child}`);
493
+ }
494
+ }
495
+ return { own, edges };
496
+ }
497
+
498
+ /**
499
+ * Resolve one subtype's schema into own and inherited field paths.
500
+ *
501
+ * @param {object} opts - Resolution inputs.
502
+ * @param {string} opts.file - The file naming the class (registry or importer).
503
+ * @param {string} opts.className - The DataModel class.
504
+ * @param {[string, string][]} opts.aliases - From {@link pathAliases}.
505
+ * @param {Map<string, ts.SourceFile>} opts.cache - The per-run parse memo.
506
+ * @param {string} opts.rootDir - For readable error paths.
507
+ * @returns {{own: string[], inherited: string[]}}
508
+ */
509
+ export function fieldsForClass({ file, className, aliases, cache, rootDir }) {
510
+ const own = [];
511
+ const inherited = [];
512
+ const seen = new Set();
513
+
514
+ /**
515
+ * Walk one class, attributing the fields it declares itself to `into` and
516
+ * everything above it to `inherited`.
517
+ */
518
+ const walkClass = (fromFile, name, into) => {
519
+ const key = `class:${fromFile}:${name}`;
520
+ if (seen.has(key)) return;
521
+ seen.add(key);
522
+
523
+ const located = locateClass(fromFile, name, aliases, cache);
524
+ if (!located) return;
525
+ const contributions = schemaContributions(located.src, located.decl);
526
+ if (!contributions) return;
527
+ const superName = superClassOf(located.decl);
528
+
529
+ for (const literal of contributions.literals) {
530
+ walkLiteral(located, literal, into, superName);
531
+ }
532
+ for (const edge of contributions.edges) {
533
+ followEdge(located, edge, superName);
534
+ }
535
+ };
536
+
537
+ /** Walk a schema literal, following the spreads inside it. */
538
+ const walkLiteral = (located, literal, into, superName) => {
539
+ const { own: fields, edges } = readLiteral(literal, located.file);
540
+ into.push(...fields);
541
+ for (const edge of edges) followEdge(located, edge, superName);
542
+ };
543
+
544
+ /** Follow one inheritance edge; everything it reaches is inherited. */
545
+ const followEdge = (located, edge, superName) => {
546
+ if (edge.kind === "local") {
547
+ const key = `local:${located.file}:${edge.name}`;
548
+ if (seen.has(key)) return;
549
+ seen.add(key);
550
+ // A spread of a schema-building function is the parent's
551
+ // contribution — a shared `defineXDataSchema()` is where the common
552
+ // fields come from — so it lands in `inherited` whichever file it
553
+ // is written in.
554
+ const lit = literalReturnedBy(located.src, edge.name);
555
+ if (lit) {
556
+ walkLiteral(located, lit, inherited, superName);
557
+ return;
558
+ }
559
+ // Not declared here, so it was imported. Following it matters more
560
+ // than it looks: the shared base schema is spread by name from the
561
+ // file that exports it, and resolving only same-file functions
562
+ // dropped it **entirely and in silence** — every SoHL subtype lost
563
+ // `shortcode` and `actionDefs`, so content correctly authoring
564
+ // `system.shortcode` was reported as undeclared. A spread that
565
+ // resolves to nothing must not read as a spread of nothing.
566
+ const from = importSourceOf(located.src, edge.name, aliases);
567
+ if (!from) return;
568
+ const importedSrc = parse(from, cache);
569
+ const importedLit = literalReturnedBy(importedSrc, edge.name);
570
+ if (importedLit) {
571
+ walkLiteral(
572
+ { file: from, src: importedSrc },
573
+ importedLit,
574
+ inherited,
575
+ superName,
576
+ );
577
+ }
578
+ return;
579
+ }
580
+ const name = edge.kind === "super" ? superName : edge.name;
581
+ if (name) walkClass(located.file, name, inherited);
582
+ };
583
+
584
+ const located = locateClass(file, className, aliases, cache);
585
+ if (!located) {
586
+ throw new Error(
587
+ `${className} is registered in ` +
588
+ `${path.relative(rootDir, file)} but its declaration cannot be ` +
589
+ `found there or in anything it imports, so its schema cannot ` +
590
+ `be read.`,
591
+ );
592
+ }
593
+ if (!schemaContributions(located.src, located.decl)) {
594
+ throw new Error(
595
+ `${className} (${path.relative(rootDir, located.file)}) declares a ` +
596
+ `defineSchema() this reader cannot follow.`,
597
+ );
598
+ }
599
+
600
+ walkClass(file, className, own);
601
+
602
+ const ownSet = new Set(own);
603
+ return {
604
+ own: [...ownSet].sort(),
605
+ inherited: [...new Set(inherited)].filter((f) => !ownSet.has(f)).sort(),
606
+ };
607
+ }
608
+
609
+ /**
610
+ * Build the whole artifact from a package's source.
611
+ *
612
+ * @param {object} opts - Inputs.
613
+ * @param {string} opts.rootDir - The repository root.
614
+ * @param {object[]} opts.registries - `{documentType, from, registry}` entries.
615
+ * @param {string} opts.packageId - The Foundry package id.
616
+ * @param {string} opts.version - The package version.
617
+ * @returns {object} The artifact `schema-check.mjs` reads.
618
+ */
619
+ export function buildSchemaArtifact({
620
+ rootDir,
621
+ registries,
622
+ packageId,
623
+ version,
624
+ }) {
625
+ const aliases = pathAliases(rootDir);
626
+ const cache = new Map();
627
+ const documents = {};
628
+
629
+ for (const { documentType, from, registry } of registries) {
630
+ const file = path.resolve(rootDir, from);
631
+ if (!fs.existsSync(file)) {
632
+ throw new Error(
633
+ `packageBuild.schema.${documentType}.from names ${from}, ` +
634
+ `which does not exist.`,
635
+ );
636
+ }
637
+ const map = registryOf(parse(file, cache), registry);
638
+ if (!map.size) {
639
+ throw new Error(
640
+ `\`${registry}\` was not found in ${from}, or maps nothing. ` +
641
+ `The registry is what says which subtypes exist, so an ` +
642
+ `empty read would publish a schema that silently covers ` +
643
+ `nothing.`,
644
+ );
645
+ }
646
+ documents[documentType] = {};
647
+ for (const [subtype, className] of [...map].sort()) {
648
+ documents[documentType][subtype] = fieldsForClass({
649
+ file,
650
+ className,
651
+ aliases,
652
+ cache,
653
+ rootDir,
654
+ });
655
+ }
656
+ }
657
+
658
+ return {
659
+ version: SCHEMA_ARTIFACT_VERSION,
660
+ system: packageId,
661
+ systemVersion: version,
662
+ documents,
663
+ };
664
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "7.0.0",
3
+ "version": "8.1.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",