@heroiclands/package-build 7.0.0 → 8.0.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,231 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 8.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - d324b5a: Stop emitting five `system` fields no SoHL DataModel declares (#60).
8
+
9
+ The comparison this release adds was run against sohl's published schema at
10
+ 0.8.2 and found five, on its first run:
11
+
12
+ | type | emitted, undeclared |
13
+ | ---------------- | -------------------------------------------------------- |
14
+ | `affliction` | `isTreated` |
15
+ | `trauma` | `isTreated`, `isBleeding` |
16
+ | `projectilegear` | `impactBase.overrideDice`, `impactBase.overrideModifier` |
17
+
18
+ Every one was discarded when the document was constructed, on every compiled
19
+ document, with nothing said — which is the whole of what #60 is about.
20
+
21
+ **Two of them were never storable.** `isTreated` and `isBleeding` are _derived_
22
+ on the logic classes: `AfflictionLogic.isTreated` is `treatmentDate != null`,
23
+ and `TraumaLogic.isBleeding` is `bloodLossAdvanceDurationBase != null`. So the
24
+ builder wrote a constant Foundry threw away while the field it is computed from
25
+ went unwritten — both directions of the same defect, on the same field. Nothing
26
+ replaces them: an untreated affliction is one whose `treatmentDate` is unset,
27
+ which is already the initial value.
28
+
29
+ **Three were authored fields that vanished.** `trauma.isTreated`,
30
+ `trauma.isBleeding` and the two projectile overrides carried a frontmatter
31
+ `name`, so a note could write them — and the value went nowhere.
32
+
33
+ **The projectile overrides are removed rather than reported upstream as missing
34
+ fields**, because nothing anywhere wants them: no DataModel declares them, no
35
+ logic class reads them, no localization key names them. A
36
+ launcher-versus-ammunition override may be worth having, but it would have to be
37
+ designed in the system first, and a content builder cannot be where it is
38
+ invented.
39
+
40
+ **Checked before removing, because three were authored.** Dropping an authored
41
+ field turns a note that writes it from a silent loss into an unknown-key error,
42
+ so all six content trees were searched first: `sohl`, `thalorna`, `kethira`,
43
+ `harnensemble`, `harnadventures` and `hm3` write none of the five.
44
+
45
+ **Verified.** Against sohl 0.8.2's published schema, `undeclared` falls from
46
+ five to zero. The twelve remaining findings are the advisory direction —
47
+ fields a subtype declares that no builder emits, `treatmentDate` among them —
48
+ and are reported rather than fatal.
49
+
50
+ **Bump**
51
+
52
+ _Major._ Three of the five were part of the authored frontmatter vocabulary, and
53
+ a note writing one is now an unknown-key error rather than a value silently
54
+ dropped. No content in this organisation writes them, but a consumer outside it
55
+ would have to delete the keys — and would find its documents unchanged, since
56
+ they never reached a saved document in the first place.
57
+
58
+ ### Minor Changes
59
+
60
+ - d324b5a: Run the emitted-versus-declared field comparison in `content-build lint` (#60).
61
+
62
+ The comparison shipped in 7.0.0 with nothing calling it, because no system had
63
+ published its field sets yet. `sohl` now does, so this reads the artifact and
64
+ runs both directions.
65
+
66
+ **Which system, at which version, is already settled.** `stats.systemId` and
67
+ `stats.systemVersion` are derived rather than authored (#48) — a system package
68
+ is its own system, a module takes the one it requires, and the version is the
69
+ `compatibility.verified` it pins. So there is no second piece of configuration
70
+ to disagree with the first about whose schema to check against.
71
+
72
+ Two places to find it:
73
+
74
+ - **A system** reads its own `schema.json`, generated from its `src/`.
75
+ - **A module** reads the copy `content-build deps fetch` caches from the archive
76
+ of the version it pins — which is what makes the comparison happen at
77
+ `verified` rather than against whatever the system's `main` holds today.
78
+
79
+ **The fetch now keeps the schema.** Both fetch paths already unpacked the
80
+ dependency's archive, but only one kept the result: a download unzips into
81
+ `<cache>/package/` and leaves it, while `deps fetch --from` unzips into a
82
+ temporary directory and deletes it. A reader looking in the unpacked tree would
83
+ have found the schema for one and not the other — so it is copied to one known
84
+ place beside the extracted items instead.
85
+
86
+ **An absent schema is announced, not skipped in silence.** A system before its
87
+ first schema build, and a module pinning a version released before the artifact
88
+ existed, both have nothing to check against. That is not an error — but a check
89
+ that quietly does nothing is indistinguishable from one that passed, and this
90
+ issue exists because a defect went unnoticed for a release. So the run says so:
91
+
92
+ ```text
93
+ No published schema for sohl@0.8.2, so emitted `system` fields are unchecked.
94
+ A system generates its own; a module gets one from `content-build deps fetch`.
95
+ ```
96
+
97
+ **Also lands the five fixes the comparison found.** They were pushed after
98
+ #116's merge and so were not part of it: `affliction` and `trauma` emitted
99
+ `isTreated`, `trauma` emitted `isBleeding`, and `projectilegear` emitted
100
+ `impactBase.overrideDice` and `impactBase.overrideModifier` — none of which any
101
+ DataModel declares. Without them this change would have turned `sohl`'s own
102
+ lint red on the defects it was written to find.
103
+
104
+ **Verified against both shapes.** Against `sohl`, the run reports zero errors
105
+ and twelve advisory warnings, and `lint` passes. Against `sohl-kethira-basic`,
106
+ whose pinned 0.8.2 archive predates the artifact, it announces the skip and
107
+ reports only that repository's pre-existing findings.
108
+
109
+ **Bump**
110
+
111
+ _Minor._ New reporting on an existing command, and a fetch that keeps one more
112
+ file. The error direction can fail a build that passed before — but only for a
113
+ package whose dependency publishes a schema, which no released version does yet.
114
+ - ee9a9a8: Compare a builder's emitted `system` fields against the receiving DataModel
115
+ (#60) — the comparison half.
116
+
117
+ Foundry discards an unknown `system` key when a document is constructed, and
118
+ says nothing: the value is absent at load while the build that wrote it reported
119
+ success. Both directions of that mismatch have already happened here, both
120
+ compiled clean, and both were found by set-subtracting compiled documents'
121
+ `system` keys against `defineSchema()` **by hand**.
122
+
123
+ **The emitted half needs neither compilation nor parsing.** `field-spec.mjs`
124
+ already makes the field list the only statement of the mapping — "the
125
+ declaration is the builder" — so every `system` path a type can emit is
126
+ `field.to`, known statically. Nothing compiles a document to find out.
127
+
128
+ **The declared half arrives as data, pinned to the declared version.** A system
129
+ publishes its field sets as an artifact and this reads it, the shape the link
130
+ manifest already uses for addresses. Against `compatibility.verified`, never the
131
+ system's `main`: `affiliation.subType` _is_ defined on sohl `main` and simply
132
+ unreleased, while `sohl-kethira-basic` pins `0.8.2` — so a check against `main`
133
+ passes and the field still evaporates for all 21 of its deities.
134
+
135
+ **`own` and `inherited` are recorded apart, and the two directions read
136
+ different sets.** A subtype's schema spreads its parent's, so `notes`, `docHtml`
137
+ and the rest land on every subtype; they are the system's own runtime concerns
138
+ and no content builder is expected to emit them.
139
+
140
+ | direction | read against | severity |
141
+ | --------------------- | ------------------------------------------------------------------ | -------- |
142
+ | emitted, not declared | `own` ∪ `inherited` — the field must exist somewhere | error |
143
+ | declared, not emitted | `own` only — what the subtype adds is what its builder answers for | report |
144
+
145
+ Collapsing them would report every inherited field on every type: a wall of
146
+ findings that are all correct and none actionable.
147
+
148
+ **A false positive the real schema caught before this shipped.** Run against
149
+ sohl's actual `mysticalability`, the _declared, not emitted_ direction reported
150
+ `charges.value` and `charges.max` on a type that populates them correctly — the
151
+ builder writes `charges` as a whole object and never names the leaves beneath
152
+ it. A declared path is now covered when the builder emits any ancestor. The
153
+ first real schema tried produced two false findings, which is exactly the kind
154
+ that teaches people to ignore a report.
155
+
156
+ **Verified against the real declarations.** With sohl's `mysticalability`
157
+ schema transcribed from source, the comparison reports nothing; with #35's
158
+ `assocMysteryCode` reinstated, it reports exactly that field.
159
+
160
+ **What this does not do yet.** It does not read an artifact from disk, and
161
+ nothing runs it in a build — those wait on a system actually publishing its
162
+ schemas, which is sohl's half and a separate change. The comparison, the format
163
+ and both regression cases are pinned here so that half has something to satisfy.
164
+
165
+ **Bump**
166
+
167
+ _Minor._ New surface — `engine/schema-check.mjs` and its exports — and nothing
168
+ existing changes behaviour. No consumer runs the comparison until an artifact
169
+ exists to run it against.
170
+ - 14cd092: Add `package-build schema`, so a system publishes its DataModel field sets from
171
+ here rather than from its own copy of an extractor.
172
+
173
+ The consuming half of this contract shipped in 7.0.0: `content-build lint`
174
+ subtracts what a package's builders emit from what a document will actually
175
+ receive, because Foundry discards an unknown `system` key at construction and
176
+ says nothing about it. The producing half lived in the first system that needed
177
+ it, which meant the second system to need it would have copied 491 lines — and,
178
+ worse, would have copied a hardcoded `SCHEMA_ARTIFACT_VERSION`, a constant this
179
+ package owns. Two producers stamping a third repository's constant by hand is
180
+ the drift worth removing before it happens rather than after: the version is now
181
+ imported by the producer, not restated.
182
+
183
+ **Why here and not in each system.** A DataModel's schema is only introspectable
184
+ inside Foundry — `defineSchema()` returns field classes that do not exist in
185
+ Node — so the field sets have to be read out of the source as an AST.
186
+ TypeScript's parser reads plain JavaScript too, and this package already pins
187
+ that compiler for `coverage.mjs`. Putting the reader here means a
188
+ JavaScript-only system does not acquire a TypeScript pin merely to describe its
189
+ own data models.
190
+
191
+ **Declared, because the two layouts in use disagree.**
192
+
193
+ ```yaml
194
+ packageBuild:
195
+ schema:
196
+ Item: { from: module/data/item-models.js, registry: itemModels }
197
+ Actor: { from: module/data/actor-models.js, registry: actorModels }
198
+ ```
199
+
200
+ One system keeps both registries in a single configuration module; the other
201
+ keeps one per file. Neither layout is more correct, and a convention guessing
202
+ between them would fail by reading _nothing_ rather than by complaining — which
203
+ is the worst failure available here, since an empty schema passes every check.
204
+ A registry that maps nothing is refused for the same reason.
205
+
206
+ **Four spellings of inheritance, all followed.** `...Super.defineSchema()`,
207
+ `...super.defineSchema()`, `Object.assign(super.defineSchema(), {…})`, and a
208
+ subclass with no `defineSchema()` at all. The last is a real and complete
209
+ declaration — `class MiscGearModel extends GearModel {}` — and reading it as
210
+ "declares nothing" would make every field of a whole subtype look undeclared.
211
+ `SchemaField` nesting is recorded as dotted paths whether written bare or as
212
+ `fields.SchemaField`, since both spellings are in use.
213
+
214
+ **A schema with nothing to compare against now says so.** The emitted side of
215
+ the check is the `fields:` of `itemBuilders`, so a package whose compendium
216
+ content is committed JSON rather than built from field declarations has an empty
217
+ one — and every field the system declares would have been reported as unemitted.
218
+ That is hundreds of findings whose only content is that the package does not
219
+ build documents that way, which is not news and not a defect. It is announced
220
+ once instead, for the same reason the absent-schema case is: a check that quietly
221
+ does nothing reads exactly like one that passed. The moment a builder declares
222
+ `fields:`, the comparison starts running on its own.
223
+
224
+ **Bump**
225
+
226
+ _Minor._ A new command, a new optional configuration key, and a `lint` that
227
+ reports strictly less than before.
228
+
3
229
  ## 7.0.0
4
230
 
5
231
  ### 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
@@ -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 {