@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 CHANGED
@@ -1,5 +1,350 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 8.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 333c340: Follow a spread of an imported schema function, and refuse a computed field name.
8
+
9
+ Two defects in `package-build schema`, both found by running it against real
10
+ content rather than by reading it.
11
+
12
+ **A shared base schema spread from another file was dropped, in silence.** A
13
+ concrete DataModel spreads a shared builder by name — `...defineSohlDataSchema()`
14
+ — and the resolver looked for that function only in the file doing the
15
+ spreading. When it was imported, the lookup found nothing and the spread
16
+ contributed nothing, with no error: a spread of a missing function read exactly
17
+ like a spread of an empty one.
18
+
19
+ The effect was not small. Every SoHL Item and Actor subtype lost `shortcode`,
20
+ `actionDefs`, `lastRun` and `scheduledActions` from the published schema. So
21
+ content correctly authoring `system.shortcode` — which SoHL requires to be
22
+ unique per `(type, shortcode)` on an actor, and which content therefore sets
23
+ deliberately — was reported as emitting a field no DataModel declares. The check
24
+ was accusing the content of the reader's own blind spot, which is worse than not
25
+ checking: it is a false accusation delivered with the same confidence as a true
26
+ one.
27
+
28
+ An imported spread is now resolved through the import, with a same-file
29
+ definition still taking precedence.
30
+
31
+ **A computed field name is now refused rather than published as source text.**
32
+ `[`${name}Date`]: worldTimeDateField()` takes its real name from an argument
33
+ this reader does not evaluate, and the previous behaviour handed back the source
34
+ text — putting a field called ``[`${name}Date`]`` into the schema. That field
35
+ matches nothing any builder could emit: absent for checking purposes while
36
+ looking present, and permanently reported as unemitted.
37
+
38
+ It now stops, naming the file and the key:
39
+
40
+ ```text
41
+ temporal-fields.ts declares a schema field with a computed name,
42
+ `[`${name}DurationFormula`]`, whose value depends on an argument this reader
43
+ does not evaluate. Write the keys out so the published schema can name them.
44
+ ```
45
+
46
+ Stopping is the same stance `compareFields` already takes on an artifact of the
47
+ wrong version: the schema is a contract other repositories read, so a contract
48
+ this cannot state is worth failing for rather than approximating. The fix
49
+ belongs at the source, where the names are actually decided.
50
+
51
+ **Bump**
52
+
53
+ _Minor._ Repositories whose schemas spread an imported builder will publish more
54
+ fields than before — which is the correction. A repository using computed field
55
+ names now fails where it previously produced a wrong artifact; none does today
56
+ except the one this was found on, and that is being fixed at the source.
57
+
58
+ ### Patch Changes
59
+
60
+ - 2319a56: Bump `markdown-it` from 15.0.0 to 15.0.1 (#121).
61
+
62
+ The surface is unchanged from the 15.0.0 adoption: one constructor,
63
+ `markdownit({ html: true })` in `engine/helpers.mjs`, and three `md.render()`
64
+ call sites — `engine/helpers.mjs`, `engine/journals.mjs` and `sohl/actors.mjs`.
65
+ No plugin is installed, no renderer rule is overridden, and `linkify` is left at
66
+ its default `false`. That last fact decides most of this release.
67
+
68
+ **Two of the five changes cannot reach a build that never linkifies.**
69
+
70
+ | Release note | Reaches here? |
71
+ | --------------------------------------------------------- | ---------------------------------------------------------------- |
72
+ | _Security:_ quadratic complexity replacing fuzzy links | No — `linkify` is `false`, so the fuzzy-link replacer never runs |
73
+ | _Security:_ quadratic complexity in scheme backscan | No — same; the rule is the inline **linkify** rule |
74
+ | Preserve brackets around IPv6 literals in `normalizeLink` | Yes, in principle — no content in any consumer writes one |
75
+ | Preserve spaces in all-space code spans (#1180) | Yes, for runs of three or more spaces only |
76
+ | Code spans after unclosed link/image labels (#1201) | Yes, for an unclosed `[` followed by an odd-length backtick run |
77
+
78
+ Calling the first two "security" fixes is upstream's framing and the honest
79
+ reading of it here is that this package was never exposed: both are complexity
80
+ bounds on `linkify-it` code paths that are unreachable with the linkifier off.
81
+ Taking the bump is hygiene, not remediation.
82
+
83
+ **The three real fixes were measured, not assumed.** Both versions were rendered
84
+ through `markdownit({ html: true })` — this package's exact configuration — and
85
+ compared:
86
+
87
+ ```text
88
+ a ` ` b 15.0.0 → <p>a <code> </code> b</p> 15.0.1 → <p>a <code> </code> b</p>
89
+ [`a`b` 15.0.0 → <p>[`a`b`</p> 15.0.1 → <p>[<code>a</code>b`</p>
90
+ [x](http://[::1]/)
91
+ 15.0.0 → href="http://%5B::1%5D/" 15.0.1 → href="http://[::1]/"
92
+ ```
93
+
94
+ Each has a narrow trigger. The code-span fix moves output only at three spaces
95
+ or more — 15.0.0 stripped one space from each end of an all-space span, so one-
96
+ and two-space spans were already correct and stay byte-identical. The unclosed-
97
+ label fix needs the backtick run to be **odd**: `[Sword `hp`and`ac`` renders
98
+ the same on both, because its four backticks close. And the IPv6 fix only fires
99
+ on a bracketed address literal in a link, image or autolink destination, which
100
+ 15.0.0 percent-encoded into a host no browser resolves.
101
+
102
+ **Verified against every consumer, and against a search rather than a guess.**
103
+ All 14,995 `.md` files across the six HeroicLands content repositories — `sohl`,
104
+ `hm3`, `thalorna`, `kethira`, `harnensemble` and `harnadventures` — were parsed
105
+ the way `parseMarkdownFile` parses them and rendered through both versions.
106
+ Every one is byte-identical. Scanning the same corpus explains why rather than
107
+ leaving it to luck: zero files contain an IPv6 URL, zero contain an all-space
108
+ code span of three or more spaces, and the 109 files that do use `` ` ` `` use
109
+ the one-space form the fix does not touch. Beyond the corpus, an exhaustive
110
+ render of all 488,280 strings up to eight characters over `[`, `]`, `` ` ``,
111
+ `x` and `!` — the alphabet the label-and-backtick fix is about — found the
112
+ divergence set to be exactly the shapes above and nothing else. The repository's
113
+ 1,713 tests pass unchanged.
114
+
115
+ **Bump**
116
+
117
+ _Patch, not minor._ No export, option, or emitted document changes shape. The
118
+ only behaviour that moves is markdown that was rendered wrongly before, on
119
+ constructs no consumer writes, and the two changes advertised as security fixes
120
+ are unreachable from this configuration.
121
+
122
+ ## 8.0.0
123
+
124
+ ### Major Changes
125
+
126
+ - d324b5a: Stop emitting five `system` fields no SoHL DataModel declares (#60).
127
+
128
+ The comparison this release adds was run against sohl's published schema at
129
+ 0.8.2 and found five, on its first run:
130
+
131
+ | type | emitted, undeclared |
132
+ | ---------------- | -------------------------------------------------------- |
133
+ | `affliction` | `isTreated` |
134
+ | `trauma` | `isTreated`, `isBleeding` |
135
+ | `projectilegear` | `impactBase.overrideDice`, `impactBase.overrideModifier` |
136
+
137
+ Every one was discarded when the document was constructed, on every compiled
138
+ document, with nothing said — which is the whole of what #60 is about.
139
+
140
+ **Two of them were never storable.** `isTreated` and `isBleeding` are _derived_
141
+ on the logic classes: `AfflictionLogic.isTreated` is `treatmentDate != null`,
142
+ and `TraumaLogic.isBleeding` is `bloodLossAdvanceDurationBase != null`. So the
143
+ builder wrote a constant Foundry threw away while the field it is computed from
144
+ went unwritten — both directions of the same defect, on the same field. Nothing
145
+ replaces them: an untreated affliction is one whose `treatmentDate` is unset,
146
+ which is already the initial value.
147
+
148
+ **Three were authored fields that vanished.** `trauma.isTreated`,
149
+ `trauma.isBleeding` and the two projectile overrides carried a frontmatter
150
+ `name`, so a note could write them — and the value went nowhere.
151
+
152
+ **The projectile overrides are removed rather than reported upstream as missing
153
+ fields**, because nothing anywhere wants them: no DataModel declares them, no
154
+ logic class reads them, no localization key names them. A
155
+ launcher-versus-ammunition override may be worth having, but it would have to be
156
+ designed in the system first, and a content builder cannot be where it is
157
+ invented.
158
+
159
+ **Checked before removing, because three were authored.** Dropping an authored
160
+ field turns a note that writes it from a silent loss into an unknown-key error,
161
+ so all six content trees were searched first: `sohl`, `thalorna`, `kethira`,
162
+ `harnensemble`, `harnadventures` and `hm3` write none of the five.
163
+
164
+ **Verified.** Against sohl 0.8.2's published schema, `undeclared` falls from
165
+ five to zero. The twelve remaining findings are the advisory direction —
166
+ fields a subtype declares that no builder emits, `treatmentDate` among them —
167
+ and are reported rather than fatal.
168
+
169
+ **Bump**
170
+
171
+ _Major._ Three of the five were part of the authored frontmatter vocabulary, and
172
+ a note writing one is now an unknown-key error rather than a value silently
173
+ dropped. No content in this organisation writes them, but a consumer outside it
174
+ would have to delete the keys — and would find its documents unchanged, since
175
+ they never reached a saved document in the first place.
176
+
177
+ ### Minor Changes
178
+
179
+ - d324b5a: Run the emitted-versus-declared field comparison in `content-build lint` (#60).
180
+
181
+ The comparison shipped in 7.0.0 with nothing calling it, because no system had
182
+ published its field sets yet. `sohl` now does, so this reads the artifact and
183
+ runs both directions.
184
+
185
+ **Which system, at which version, is already settled.** `stats.systemId` and
186
+ `stats.systemVersion` are derived rather than authored (#48) — a system package
187
+ is its own system, a module takes the one it requires, and the version is the
188
+ `compatibility.verified` it pins. So there is no second piece of configuration
189
+ to disagree with the first about whose schema to check against.
190
+
191
+ Two places to find it:
192
+
193
+ - **A system** reads its own `schema.json`, generated from its `src/`.
194
+ - **A module** reads the copy `content-build deps fetch` caches from the archive
195
+ of the version it pins — which is what makes the comparison happen at
196
+ `verified` rather than against whatever the system's `main` holds today.
197
+
198
+ **The fetch now keeps the schema.** Both fetch paths already unpacked the
199
+ dependency's archive, but only one kept the result: a download unzips into
200
+ `<cache>/package/` and leaves it, while `deps fetch --from` unzips into a
201
+ temporary directory and deletes it. A reader looking in the unpacked tree would
202
+ have found the schema for one and not the other — so it is copied to one known
203
+ place beside the extracted items instead.
204
+
205
+ **An absent schema is announced, not skipped in silence.** A system before its
206
+ first schema build, and a module pinning a version released before the artifact
207
+ existed, both have nothing to check against. That is not an error — but a check
208
+ that quietly does nothing is indistinguishable from one that passed, and this
209
+ issue exists because a defect went unnoticed for a release. So the run says so:
210
+
211
+ ```text
212
+ No published schema for sohl@0.8.2, so emitted `system` fields are unchecked.
213
+ A system generates its own; a module gets one from `content-build deps fetch`.
214
+ ```
215
+
216
+ **Also lands the five fixes the comparison found.** They were pushed after
217
+ #116's merge and so were not part of it: `affliction` and `trauma` emitted
218
+ `isTreated`, `trauma` emitted `isBleeding`, and `projectilegear` emitted
219
+ `impactBase.overrideDice` and `impactBase.overrideModifier` — none of which any
220
+ DataModel declares. Without them this change would have turned `sohl`'s own
221
+ lint red on the defects it was written to find.
222
+
223
+ **Verified against both shapes.** Against `sohl`, the run reports zero errors
224
+ and twelve advisory warnings, and `lint` passes. Against `sohl-kethira-basic`,
225
+ whose pinned 0.8.2 archive predates the artifact, it announces the skip and
226
+ reports only that repository's pre-existing findings.
227
+
228
+ **Bump**
229
+
230
+ _Minor._ New reporting on an existing command, and a fetch that keeps one more
231
+ file. The error direction can fail a build that passed before — but only for a
232
+ package whose dependency publishes a schema, which no released version does yet.
233
+ - ee9a9a8: Compare a builder's emitted `system` fields against the receiving DataModel
234
+ (#60) — the comparison half.
235
+
236
+ Foundry discards an unknown `system` key when a document is constructed, and
237
+ says nothing: the value is absent at load while the build that wrote it reported
238
+ success. Both directions of that mismatch have already happened here, both
239
+ compiled clean, and both were found by set-subtracting compiled documents'
240
+ `system` keys against `defineSchema()` **by hand**.
241
+
242
+ **The emitted half needs neither compilation nor parsing.** `field-spec.mjs`
243
+ already makes the field list the only statement of the mapping — "the
244
+ declaration is the builder" — so every `system` path a type can emit is
245
+ `field.to`, known statically. Nothing compiles a document to find out.
246
+
247
+ **The declared half arrives as data, pinned to the declared version.** A system
248
+ publishes its field sets as an artifact and this reads it, the shape the link
249
+ manifest already uses for addresses. Against `compatibility.verified`, never the
250
+ system's `main`: `affiliation.subType` _is_ defined on sohl `main` and simply
251
+ unreleased, while `sohl-kethira-basic` pins `0.8.2` — so a check against `main`
252
+ passes and the field still evaporates for all 21 of its deities.
253
+
254
+ **`own` and `inherited` are recorded apart, and the two directions read
255
+ different sets.** A subtype's schema spreads its parent's, so `notes`, `docHtml`
256
+ and the rest land on every subtype; they are the system's own runtime concerns
257
+ and no content builder is expected to emit them.
258
+
259
+ | direction | read against | severity |
260
+ | --------------------- | ------------------------------------------------------------------ | -------- |
261
+ | emitted, not declared | `own` ∪ `inherited` — the field must exist somewhere | error |
262
+ | declared, not emitted | `own` only — what the subtype adds is what its builder answers for | report |
263
+
264
+ Collapsing them would report every inherited field on every type: a wall of
265
+ findings that are all correct and none actionable.
266
+
267
+ **A false positive the real schema caught before this shipped.** Run against
268
+ sohl's actual `mysticalability`, the _declared, not emitted_ direction reported
269
+ `charges.value` and `charges.max` on a type that populates them correctly — the
270
+ builder writes `charges` as a whole object and never names the leaves beneath
271
+ it. A declared path is now covered when the builder emits any ancestor. The
272
+ first real schema tried produced two false findings, which is exactly the kind
273
+ that teaches people to ignore a report.
274
+
275
+ **Verified against the real declarations.** With sohl's `mysticalability`
276
+ schema transcribed from source, the comparison reports nothing; with #35's
277
+ `assocMysteryCode` reinstated, it reports exactly that field.
278
+
279
+ **What this does not do yet.** It does not read an artifact from disk, and
280
+ nothing runs it in a build — those wait on a system actually publishing its
281
+ schemas, which is sohl's half and a separate change. The comparison, the format
282
+ and both regression cases are pinned here so that half has something to satisfy.
283
+
284
+ **Bump**
285
+
286
+ _Minor._ New surface — `engine/schema-check.mjs` and its exports — and nothing
287
+ existing changes behaviour. No consumer runs the comparison until an artifact
288
+ exists to run it against.
289
+ - 14cd092: Add `package-build schema`, so a system publishes its DataModel field sets from
290
+ here rather than from its own copy of an extractor.
291
+
292
+ The consuming half of this contract shipped in 7.0.0: `content-build lint`
293
+ subtracts what a package's builders emit from what a document will actually
294
+ receive, because Foundry discards an unknown `system` key at construction and
295
+ says nothing about it. The producing half lived in the first system that needed
296
+ it, which meant the second system to need it would have copied 491 lines — and,
297
+ worse, would have copied a hardcoded `SCHEMA_ARTIFACT_VERSION`, a constant this
298
+ package owns. Two producers stamping a third repository's constant by hand is
299
+ the drift worth removing before it happens rather than after: the version is now
300
+ imported by the producer, not restated.
301
+
302
+ **Why here and not in each system.** A DataModel's schema is only introspectable
303
+ inside Foundry — `defineSchema()` returns field classes that do not exist in
304
+ Node — so the field sets have to be read out of the source as an AST.
305
+ TypeScript's parser reads plain JavaScript too, and this package already pins
306
+ that compiler for `coverage.mjs`. Putting the reader here means a
307
+ JavaScript-only system does not acquire a TypeScript pin merely to describe its
308
+ own data models.
309
+
310
+ **Declared, because the two layouts in use disagree.**
311
+
312
+ ```yaml
313
+ packageBuild:
314
+ schema:
315
+ Item: { from: module/data/item-models.js, registry: itemModels }
316
+ Actor: { from: module/data/actor-models.js, registry: actorModels }
317
+ ```
318
+
319
+ One system keeps both registries in a single configuration module; the other
320
+ keeps one per file. Neither layout is more correct, and a convention guessing
321
+ between them would fail by reading _nothing_ rather than by complaining — which
322
+ is the worst failure available here, since an empty schema passes every check.
323
+ A registry that maps nothing is refused for the same reason.
324
+
325
+ **Four spellings of inheritance, all followed.** `...Super.defineSchema()`,
326
+ `...super.defineSchema()`, `Object.assign(super.defineSchema(), {…})`, and a
327
+ subclass with no `defineSchema()` at all. The last is a real and complete
328
+ declaration — `class MiscGearModel extends GearModel {}` — and reading it as
329
+ "declares nothing" would make every field of a whole subtype look undeclared.
330
+ `SchemaField` nesting is recorded as dotted paths whether written bare or as
331
+ `fields.SchemaField`, since both spellings are in use.
332
+
333
+ **A schema with nothing to compare against now says so.** The emitted side of
334
+ the check is the `fields:` of `itemBuilders`, so a package whose compendium
335
+ content is committed JSON rather than built from field declarations has an empty
336
+ one — and every field the system declares would have been reported as unemitted.
337
+ That is hundreds of findings whose only content is that the package does not
338
+ build documents that way, which is not news and not a defect. It is announced
339
+ once instead, for the same reason the absent-schema case is: a check that quietly
340
+ does nothing reads exactly like one that passed. The moment a builder declares
341
+ `fields:`, the comparison starts running on its own.
342
+
343
+ **Bump**
344
+
345
+ _Minor._ A new command, a new optional configuration key, and a `lint` that
346
+ reports strictly less than before.
347
+
3
348
  ## 7.0.0
4
349
 
5
350
  ### Major Changes
@@ -73,6 +73,12 @@ import {
73
73
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
74
74
  import { lintContentTree } from "../engine/content-lint.mjs";
75
75
  import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
76
+ import {
77
+ compareFields,
78
+ resolveSchemaArtifact,
79
+ undeclaredMessage,
80
+ unemittedMessage,
81
+ } from "../engine/schema-check.mjs";
76
82
  // The one vocabulary, loaded whole. Every content project authors the full type
77
83
  // set — an adventure module ships skills, beings and magic swords — so no
78
84
  // consumer gets a subset (#19, #20).
@@ -386,9 +392,79 @@ function lintCommand() {
386
392
  references: argv.references,
387
393
  });
388
394
 
395
+ // What the builders emit, against what the receiving system
396
+ // declares (#60). Reported here rather than at compile: it is
397
+ // a property of the *declarations*, not of any one note, so it
398
+ // is the same answer for every document and belongs where a
399
+ // reader is already being told about the vocabulary.
400
+ const schema = resolveSchemaArtifact(config);
401
+ const schemaFindings = [];
402
+ if (!schema && config.stats?.systemId) {
403
+ // Said out loud, because a check that quietly does nothing
404
+ // is indistinguishable from one that passed — and this
405
+ // whole issue exists because a defect went unnoticed for a
406
+ // release. A system before its first schema build, or a
407
+ // module pinning a version released before the artifact
408
+ // existed, lands here.
409
+ log.info(
410
+ `No published schema for ${config.stats.systemId}` +
411
+ `@${config.stats.systemVersion ?? "?"}, so emitted ` +
412
+ `\`system\` fields are unchecked. A system ` +
413
+ `generates its own; a module gets one from ` +
414
+ `\`content-build deps fetch\`.`,
415
+ );
416
+ }
417
+ const fieldSpecs = config.itemFields ?? {};
418
+ const comparable = Object.keys(fieldSpecs).length > 0;
419
+ if (schema && !comparable) {
420
+ // A schema, and nothing to compare it against. The emitted
421
+ // side of this check is the `fields:` of `itemBuilders`, so
422
+ // a package whose compendium content is committed JSON
423
+ // rather than built from field declarations has an empty
424
+ // one — and every field the system declares would be
425
+ // reported as unemitted. That is hundreds of findings whose
426
+ // only content is that this package does not build
427
+ // documents this way, which is not news and not a defect.
428
+ //
429
+ // Said out loud rather than skipped in silence, for the
430
+ // same reason the absent-schema case is: a check that
431
+ // quietly does nothing reads exactly like one that passed.
432
+ log.info(
433
+ `Read ${config.stats?.systemId ?? "the system"}'s ` +
434
+ `schema, but this package declares no ` +
435
+ `\`itemBuilders\` field specifications — so there ` +
436
+ `is nothing to compare it against and the ` +
437
+ `emitted-versus-declared check does not apply.`,
438
+ );
439
+ }
440
+ if (schema && comparable) {
441
+ const { undeclared, unemitted } = compareFields({
442
+ builders: fieldSpecs,
443
+ artifact: schema.artifact,
444
+ });
445
+ for (const f of undeclared) {
446
+ schemaFindings.push({
447
+ file: schema.source,
448
+ severity: "error",
449
+ message: undeclaredMessage(f),
450
+ });
451
+ }
452
+ for (const f of unemitted) {
453
+ // Advisory: a field the system fills at runtime, or one
454
+ // added ahead of the content that will use it, is not a
455
+ // defect.
456
+ emitDiagnostic({
457
+ file: schema.source,
458
+ severity: "warning",
459
+ message: unemittedMessage(f),
460
+ });
461
+ }
462
+ }
463
+
389
464
  const findings = [
390
465
  ...addresses.findings,
391
466
  ...frontmatter.findings,
467
+ ...schemaFindings,
392
468
  ];
393
469
  for (const finding of findings) emitDiagnostic(finding);
394
470
  if (findings.length) {
@@ -80,6 +80,8 @@ import { hideBin } from "yargs/helpers";
80
80
  import { loadPackageBuildConfig } from "../config.mjs";
81
81
  import { loadPackConfig, packConfigPath } from "../engine/pack-config.mjs";
82
82
  import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
83
+ import { buildSchemaArtifact } from "../engine/schema-extract.mjs";
84
+ import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
83
85
  import { validateLangSource } from "../lang.mjs";
84
86
  import {
85
87
  analyzeCoverage,
@@ -312,6 +314,109 @@ function assetsCommand() {
312
314
  };
313
315
  }
314
316
 
317
+ /**
318
+ * Format generated text the way the repository formats everything else.
319
+ *
320
+ * Not cosmetic. A generated file that Prettier would reformat leaves
321
+ * `lint:format` and the generator's own `--check` each demanding what the other
322
+ * forbids, and the repository cannot be made green. Resolving the config from
323
+ * the *output path* is what makes one implementation here serve repositories
324
+ * with different Prettier settings.
325
+ *
326
+ * Imported on use, as `prose-lint.mjs` does, so that commands which never
327
+ * format do not pay to load it.
328
+ *
329
+ * @param {string} text - The unformatted content.
330
+ * @param {string} filepath - Where it will be written.
331
+ * @returns {Promise<string>} The formatted content.
332
+ */
333
+ async function formatGenerated(text, filepath) {
334
+ const prettier = await import("prettier");
335
+ const config = await prettier.resolveConfig(filepath);
336
+ return prettier.format(text, { ...config, filepath });
337
+ }
338
+
339
+ /**
340
+ * `schema` — publish this package's DataModel field sets as `schema.json`.
341
+ *
342
+ * The producing half of the check `content-build lint` runs: Foundry discards
343
+ * an unknown `system` key at construction and says nothing, so a content build
344
+ * needs to know what a document will actually receive (#60). It cannot ask a
345
+ * running Foundry, and it cannot read `defineSchema()` from a sibling checkout,
346
+ * so the system publishes the field sets as data — the same shape the link
347
+ * manifest already uses for addresses.
348
+ *
349
+ * `--check` fails when the committed copy disagrees with what the source would
350
+ * produce now, because a generated file nothing checks drifts from its
351
+ * generator silently — and this one is read by other repositories.
352
+ *
353
+ * @returns {object} The yargs command module.
354
+ */
355
+ function schemaCommand() {
356
+ return {
357
+ command: "schema",
358
+ describe: "Publish this package's DataModel field sets as schema.json",
359
+ builder: (y) =>
360
+ y.option("check", {
361
+ type: "boolean",
362
+ default: false,
363
+ describe:
364
+ "Fail when the committed schema.json is out of date " +
365
+ "rather than rewriting it",
366
+ }),
367
+ handler: handler(async (argv) => {
368
+ const config = loadPackageBuildConfig();
369
+ if (!config.schema.length) {
370
+ console.log(
371
+ "package-build: no `packageBuild.schema` declared; " +
372
+ "nothing to publish.",
373
+ );
374
+ return;
375
+ }
376
+
377
+ const pkg = readPackageJson(config);
378
+ const artifact = buildSchemaArtifact({
379
+ rootDir: config.rootDir,
380
+ registries: config.schema,
381
+ packageId: config.packageId,
382
+ version: pkg.version,
383
+ });
384
+
385
+ const out = path.join(config.rootDir, SCHEMA_ARTIFACT_FILE);
386
+ const text = await formatGenerated(JSON.stringify(artifact), out);
387
+ const counts = Object.entries(artifact.documents)
388
+ .map(
389
+ ([kind, subtypes]) =>
390
+ `${Object.keys(subtypes).length} ${kind}`,
391
+ )
392
+ .join(", ");
393
+
394
+ if (argv.check) {
395
+ const current =
396
+ fs.existsSync(out) ? fs.readFileSync(out, "utf8") : null;
397
+ if (current !== text) {
398
+ die(
399
+ `${SCHEMA_ARTIFACT_FILE} does not match what this ` +
400
+ `package's data models would produce — regenerate ` +
401
+ `it with \`package-build schema\`.`,
402
+ );
403
+ }
404
+ console.log(
405
+ `✅ ${SCHEMA_ARTIFACT_FILE} is up to date ` +
406
+ `(${counts} subtypes).`,
407
+ );
408
+ return;
409
+ }
410
+
411
+ fs.writeFileSync(out, text, "utf8");
412
+ console.log(
413
+ `✅ Wrote ${SCHEMA_ARTIFACT_FILE} for ${artifact.system} ` +
414
+ `${artifact.systemVersion} (${counts} subtypes).`,
415
+ );
416
+ }),
417
+ };
418
+ }
419
+
315
420
  /**
316
421
  * `manifest` — generate `system.json` / `module.json` into the build stage.
317
422
  *
@@ -927,6 +1032,7 @@ yargs(hideBin(process.argv))
927
1032
  .command(cleanCommand())
928
1033
  .command(assetsCommand())
929
1034
  .command(manifestCommand())
1035
+ .command(schemaCommand())
930
1036
  .command(langCommand())
931
1037
  .command(bundleCommand())
932
1038
  .command(releaseCommand())
package/config.mjs CHANGED
@@ -72,6 +72,7 @@ const SECTION_KEYS = [
72
72
  "assetTransform",
73
73
  "manifest",
74
74
  "manifestFlags",
75
+ "schema",
75
76
  "clean",
76
77
  "lang",
77
78
  "deploy",
@@ -227,6 +228,41 @@ function normalizeAsset(value, index) {
227
228
  });
228
229
  }
229
230
 
231
+ /**
232
+ * Validate the DataModel registries `package-build schema` reads.
233
+ *
234
+ * Each entry names a file and the binding in it that maps subtype to DataModel
235
+ * class. Two keys rather than a convention because the two repositories that
236
+ * declare this disagree on both: one keeps `ITEM_DM_DEF` and `ACTOR_DM_DEF` in
237
+ * a single configuration module, the other keeps `itemModels` and `actorModels`
238
+ * in a file apiece. Neither layout is more correct, and guessing between them
239
+ * would fail by reading nothing rather than by complaining.
240
+ *
241
+ * The document type is the key, so a package that models only Items simply says
242
+ * so — there is no empty `Actor` entry to write.
243
+ *
244
+ * @param {unknown} value - The `schema` block, or `undefined`.
245
+ * @returns {Readonly<object[]>} Registry entries; empty when absent.
246
+ */
247
+ function normalizeSchema(value) {
248
+ if (value === undefined) return Object.freeze([]);
249
+ if (!isMapping(value)) fail("packageBuild.schema", "must be a mapping");
250
+ const input = /** @type {Record<string, unknown>} */ (value);
251
+
252
+ const entries = Object.entries(input).map(([documentType, entry]) => {
253
+ const where = `packageBuild.schema.${documentType}`;
254
+ if (!isMapping(entry)) fail(where, "must be a mapping");
255
+ const row = /** @type {Record<string, unknown>} */ (entry);
256
+ rejectUnknownKeys(row, ["from", "registry"], `${where}.`);
257
+ return Object.freeze({
258
+ documentType,
259
+ from: requireNonEmptyString(row.from, `${where}.from`),
260
+ registry: requireNonEmptyString(row.registry, `${where}.registry`),
261
+ });
262
+ });
263
+ return Object.freeze(entries);
264
+ }
265
+
230
266
  /**
231
267
  * Validate the manifest specification.
232
268
  *
@@ -695,6 +731,7 @@ export function resolvePackageBuildConfig(shared) {
695
731
  "packageBuild.release.artifact",
696
732
  ),
697
733
  assets: Object.freeze(assets),
734
+ schema: normalizeSchema(section.schema),
698
735
  assetTransform:
699
736
  section.assetTransform === undefined ?
700
737
  null