@heroiclands/package-build 6.1.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +840 -0
  2. package/CONTENT.md +21 -1
  3. package/bin/content-build.mjs +105 -10
  4. package/bin/package-build.mjs +114 -1
  5. package/config.mjs +62 -3
  6. package/content-config.mjs +254 -22
  7. package/engine/base-compiler.mjs +25 -0
  8. package/engine/content-links.mjs +132 -27
  9. package/engine/diagnostics.mjs +61 -1
  10. package/engine/foreign-catalog.mjs +47 -0
  11. package/engine/generate.mjs +10 -5
  12. package/engine/helpers.mjs +38 -0
  13. package/engine/journals.mjs +8 -1
  14. package/engine/macros.mjs +2 -0
  15. package/engine/pack-config.mjs +143 -13
  16. package/engine/prose-lint.mjs +10 -2
  17. package/engine/scenes.mjs +2 -2
  18. package/engine/schema-check.mjs +332 -0
  19. package/engine/schema-extract.mjs +611 -0
  20. package/engine/web-wikilinks.mjs +13 -4
  21. package/engine/wikilink-syntax.mjs +25 -0
  22. package/engine/wikilinks.mjs +6 -3
  23. package/manifest.mjs +37 -2
  24. package/package.json +5 -3
  25. package/sohl/actors.mjs +23 -10
  26. package/sohl/item-fields.mjs +0 -35
  27. package/sohl/items.mjs +1 -1
  28. package/types/content-config.d.mts +14 -0
  29. package/types/engine/base-compiler.d.mts +18 -1
  30. package/types/engine/content-links.d.mts +11 -3
  31. package/types/engine/diagnostics.d.mts +33 -1
  32. package/types/engine/foreign-catalog.d.mts +15 -0
  33. package/types/engine/generate.d.mts +3 -2
  34. package/types/engine/helpers.d.mts +29 -3
  35. package/types/engine/journals.d.mts +7 -1
  36. package/types/engine/pack-config.d.mts +22 -0
  37. package/types/engine/prose-lint.d.mts +10 -2
  38. package/types/engine/schema-check.d.mts +176 -0
  39. package/types/engine/schema-extract.d.mts +61 -0
  40. package/types/engine/wikilink-syntax.d.mts +24 -0
  41. package/types/sohl/actors.d.mts +3 -3
@@ -289,12 +289,25 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
289
289
  * resolves to nothing — yields `{}`, so a caller spreads the result and the
290
290
  * position is dropped rather than guessed.
291
291
  *
292
+ * **`key: true` addresses the declaration rather than the value.** A finding
293
+ * about a *value* — this pack name is not in `packs[]` — belongs on the value,
294
+ * which is the default. A finding that names a **field** — `\`site.sections.x\`
295
+ * is not a recognized option` — sends the reader to look for that field, so the
296
+ * position should be the field's own, and in a flow mapping
297
+ * (`{ title: X, banner: Y }`) the two are different columns on one line. The
298
+ * key's node is found through the same parse, so this stays one locator rather
299
+ * than a second one free to disagree with it.
300
+ *
292
301
  * @param {string} text - The document's contents.
293
302
  * @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
294
303
  * strings, sequence entries as numbers.
304
+ * @param {object} [opts]
305
+ * @param {boolean} [opts.key=false] - Report where the last segment is
306
+ * *declared* rather than where its value sits. Ignored for a sequence entry,
307
+ * which has no key.
295
308
  * @returns {{line?: number, column?: number}} Spreadable position fields.
296
309
  */
297
- export function positionOfYamlPath(text, keyPath) {
310
+ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
298
311
  if (typeof text !== "string" || !text) return {};
299
312
  if (!Array.isArray(keyPath) || keyPath.length === 0) return {};
300
313
 
@@ -305,6 +318,19 @@ export function positionOfYamlPath(text, keyPath) {
305
318
  // `keepScalar` returns the Scalar node rather than its value, which is
306
319
  // the only form carrying a range.
307
320
  node = doc.getIn(keyPath, true);
321
+ if (key && node !== undefined) {
322
+ const last = keyPath[keyPath.length - 1];
323
+ const parent =
324
+ keyPath.length === 1 ?
325
+ doc.contents
326
+ : doc.getIn(keyPath.slice(0, -1), true);
327
+ const pair = parent?.items?.find?.(
328
+ (item) =>
329
+ item?.key != null &&
330
+ String(item.key.value) === String(last),
331
+ );
332
+ if (pair?.key?.range) node = pair.key;
333
+ }
308
334
  } catch {
309
335
  return {};
310
336
  }
@@ -314,3 +340,37 @@ export function positionOfYamlPath(text, keyPath) {
314
340
  const { line, col } = counter.linePos(start);
315
341
  return { line, column: col };
316
342
  }
343
+
344
+ /**
345
+ * The YAML key path a **dotted field path** addresses.
346
+ *
347
+ * Configuration checks report the offending key as the path a reader would
348
+ * write it — `packs[1].name`, `site.sections.affliction.title` — because that
349
+ * is what the message has to say. {@link positionOfYamlPath} addresses a node
350
+ * by segments instead, so this is the one translation between them: `.`
351
+ * separates map keys, and a bracketed suffix is a sequence index.
352
+ *
353
+ * A path this cannot parse yields `[]`, which {@link positionOfYamlPath} in
354
+ * turn resolves to no position — dropped rather than guessed, as everything
355
+ * else here is.
356
+ *
357
+ * @param {string} field - The dotted path, as a diagnostic spells it.
358
+ * @returns {Array<string|number>} Its segments, sequence indices as numbers.
359
+ */
360
+ export function yamlKeyPath(field) {
361
+ if (typeof field !== "string" || field === "") return [];
362
+ /** @type {Array<string|number>} */
363
+ const segments = [];
364
+ for (const segment of field.split(".")) {
365
+ const parsed = /^([^[\]]*)((?:\[\d+\])*)$/.exec(segment);
366
+ // Anything else is not a path this understands — a key holding a `[`,
367
+ // or a dot inside a key. Refuse the whole path rather than resolve
368
+ // part of it to a node that is not the one named.
369
+ if (!parsed) return [];
370
+ if (parsed[1] !== "") segments.push(parsed[1]);
371
+ for (const index of parsed[2].matchAll(/\[(\d+)\]/g)) {
372
+ segments.push(Number(index[1]));
373
+ }
374
+ }
375
+ return segments;
376
+ }
@@ -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 {
@@ -49,7 +49,7 @@ import { Actors } from "../sohl/actors.mjs";
49
49
  import { Macros } from "./macros.mjs";
50
50
  import { Scenes } from "./scenes.mjs";
51
51
  import {
52
- buildStats,
52
+ statsForPack,
53
53
  loadFolders,
54
54
  buildFolderResolver,
55
55
  writeFolderDocs,
@@ -102,8 +102,9 @@ export const packJsonDir = (name, config = loadPackConfig()) =>
102
102
  * @param {object} [config] - The resolved build configuration. Defaults to this
103
103
  * repository's.
104
104
  * @returns {string[]} Each Item pack's JSON directory. Empty when the
105
- * repository ships no items at all the actors pass, which is the only
106
- * caller that needs one, refuses that itself.
105
+ * repository ships no items at all, which is a legitimate package: the actors
106
+ * pass accepts an empty list and reports an item it cannot resolve per
107
+ * `(type, shortcode)` instead, naming the being (#49).
107
108
  */
108
109
  export function itemPackJsonDirs(config = loadPackConfig()) {
109
110
  return config.packs
@@ -246,7 +247,7 @@ export function unsatisfiedPassDependencies(running, config) {
246
247
  * count (0 on success) and the number of entries it wrote.
247
248
  */
248
249
  async function generatePack(
249
- { name, type, folders, companions },
250
+ { name, type, folders, companions, system },
250
251
  config,
251
252
  router,
252
253
  routingReporter,
@@ -290,7 +291,9 @@ async function generatePack(
290
291
  companionDests[companion.name] = companionDest;
291
292
  }
292
293
 
293
- writeFolderDocs(folderList, buildStats(undefined, config), dest, type);
294
+ // A folder document belongs to the pack it is written into, so it carries
295
+ // that pack's system rather than the package-wide one (#48).
296
+ writeFolderDocs(folderList, statsForPack(system, config), dest, type);
294
297
 
295
298
  const pack = new packClass({
296
299
  contentBase,
@@ -311,6 +314,8 @@ async function generatePack(
311
314
  foreignSourceDirs: foreignItemCatalogDirs(config),
312
315
  folderResolver: resolver,
313
316
  packName: name,
317
+ // Which system this pack's documents are stamped for (#48).
318
+ packSystem: system ?? null,
314
319
  docType: type,
315
320
  router,
316
321
  routingReporter,
@@ -335,6 +335,44 @@ export function buildStats(
335
335
  };
336
336
  }
337
337
 
338
+ /**
339
+ * The `_stats` block for one pack, stamped with the system that pack is for
340
+ * (#48).
341
+ *
342
+ * **`systemId` travels with `systemVersion`.** They are one decision, so where
343
+ * one is omitted both are. Stamping a per-pack version against a package-wide
344
+ * id would emit `systemId: sohl, systemVersion: 1.6.3` on HM3 documents — a
345
+ * *plausible lie*, which is worse than the missing value #43 fixed, because
346
+ * nothing about it looks wrong.
347
+ *
348
+ * Resolution, in order:
349
+ *
350
+ * 1. The pack's own `system:`, looked up in the `systems:` block. That is the
351
+ * case a module shipping for two systems needs, and the one no
352
+ * package-wide value could express.
353
+ * 2. Failing that, the package-wide `stats` — a package whose packs are all for
354
+ * one system, which is every package that worked before this existed.
355
+ *
356
+ * A pack naming a system is validated against `systems:` at configuration time,
357
+ * so an unresolvable name never reaches here.
358
+ *
359
+ * @param {string|null|undefined} packSystem - The pack's declared `system:`.
360
+ * @param {object} [config] - The resolved configuration.
361
+ * @returns {object} The `_stats` block for that pack.
362
+ */
363
+ export function statsForPack(packSystem, config = loadPackConfig()) {
364
+ const declared = packSystem ? config.systems?.[packSystem] : null;
365
+ if (!declared) return buildStats(undefined, config);
366
+ return {
367
+ systemId: packSystem,
368
+ systemVersion: declared.compatibility.verified,
369
+ coreVersion: supportedCoreVersion(config),
370
+ createdTime: 0,
371
+ modifiedTime: 0,
372
+ lastModifiedBy: config.stats.lastModifiedBy,
373
+ };
374
+ }
375
+
338
376
  /** Memoised {@link defaultStats}. */
339
377
  let cachedDefaultStats;
340
378
 
@@ -230,6 +230,11 @@ export function buildPages(rawPages, entryId, noteName) {
230
230
  * heading; see {@link splitPages}.
231
231
  * @param {string|null} [params.folder] - The folder id, or `null`.
232
232
  * @param {object} [params.flags] - Document flags.
233
+ * @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
234
+ * caller because it is a property of the *pack* being written, not of the
235
+ * entry: a module may ship the same content for two systems, and each pack's
236
+ * documents record the system version they were built against (#48). A
237
+ * caller with no pack in hand gets the package-wide block.
233
238
  * @returns {object} The JournalEntry document, keyed for the pack.
234
239
  */
235
240
  export function buildJournalEntry({
@@ -239,6 +244,7 @@ export function buildJournalEntry({
239
244
  leadName,
240
245
  folder = null,
241
246
  flags,
247
+ stats = defaultStats(),
242
248
  }) {
243
249
  const rawPages = splitPages(markdown, leadName);
244
250
  const pages = buildPages(rawPages, id, name);
@@ -250,7 +256,7 @@ export function buildJournalEntry({
250
256
  ownership: { default: 0 },
251
257
  flags: flags || {},
252
258
  _id: id,
253
- _stats: defaultStats(),
259
+ _stats: stats,
254
260
  _key: `!journal!${id}`,
255
261
  };
256
262
  }
@@ -347,6 +353,7 @@ export class Journals extends BasePackCompiler {
347
353
  leadName: ownsDoc ? name : undefined,
348
354
  folder,
349
355
  flags: fm.flags,
356
+ stats: this.stats,
350
357
  });
351
358
  }
352
359
 
package/engine/macros.mjs CHANGED
@@ -322,6 +322,8 @@ export class Macros extends BasePackCompiler {
322
322
  return buildMacroEntry(fm, {
323
323
  command: macroCommand(body, name),
324
324
  folder: this.folderResolver(sohlField(fm, "folder", null)),
325
+ // This pack's system, not the package-wide one (#48).
326
+ stats: this.stats,
325
327
  });
326
328
  }
327
329
 
@@ -80,7 +80,12 @@ import path from "node:path";
80
80
  import { createRequire } from "node:module";
81
81
  import YAML from "yaml";
82
82
 
83
- import { defineConfig } from "../content-config.mjs";
83
+ import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
84
+ import {
85
+ formatDiagnostic,
86
+ positionOfYamlPath,
87
+ yamlKeyPath,
88
+ } from "./diagnostics.mjs";
84
89
 
85
90
  /** The stem every consuming repository declares its build under. */
86
91
  export const CONFIG_BASENAME = "package-build.config";
@@ -276,6 +281,33 @@ function shippedSystemVersion(rootDir, input) {
276
281
  input.relationships ?? {}
277
282
  ).systems;
278
283
 
284
+ // The `systems:` block declares without requiring (#48), so it is consulted
285
+ // first: a package that has adopted it needs no relationship, and one that
286
+ // ships for two systems could not express itself through a relationship at
287
+ // all. `requiresSystem` names the package-wide default when there is one;
288
+ // otherwise a single declared system is unambiguous. With several and no
289
+ // gate, there is no package-wide answer — each pack carries its own, and
290
+ // {@link statsForPack} is what reads it.
291
+ const systemsBlock =
292
+ /** @type {Record<string, {compatibility?: {verified?: string}}>} */ (
293
+ input.systems ?? {}
294
+ );
295
+ const systemIds = Object.keys(systemsBlock);
296
+ if (systemIds.length) {
297
+ const chosen =
298
+ typeof input.requiresSystem === "string" ? input.requiresSystem
299
+ : systemIds.length === 1 ? systemIds[0]
300
+ : null;
301
+ if (chosen) {
302
+ const verified = systemsBlock[chosen]?.compatibility?.verified;
303
+ if (typeof verified === "string" && verified.length)
304
+ return verified;
305
+ }
306
+ // Several declared and none required: the package-wide value is
307
+ // deliberately absent rather than one of them picked arbitrarily.
308
+ return null;
309
+ }
310
+
279
311
  // A module that names neither a system nor a relationship with one is
280
312
  // system-agnostic on purpose: its packs are core document types carrying no
281
313
  // system data, and it installs under any system. There is no version to
@@ -314,6 +346,95 @@ function shippedSystemVersion(rootDir, input) {
314
346
  return verified;
315
347
  }
316
348
 
349
+ /**
350
+ * Where in the configuration file a dotted field path was written.
351
+ *
352
+ * Only a YAML configuration has text to resolve a path against. An `.mjs` one
353
+ * is deliberately not searched: JavaScript source fed to a YAML parser is not
354
+ * an error — it parses as *something*, and a path could resolve to a line that
355
+ * has nothing to do with the key. A position that is wrong is worse than none,
356
+ * so the extension decides.
357
+ *
358
+ * A path that names a key the file never declared — a missing required one —
359
+ * has no node of its own. The position then names the **mapping it belongs
360
+ * in**, one level up and no further: that entry is a real node, and it is the
361
+ * one the reader has to edit. Walking further would drift away from the key
362
+ * with each step, so a top-level key that is simply absent gets no position at
363
+ * all.
364
+ *
365
+ * @param {string} configPath - Absolute path of the configuration file.
366
+ * @param {string} field - The dotted path the diagnostic names.
367
+ * @returns {{line?: number, column?: number}} Spreadable position fields,
368
+ * empty when nothing can be established honestly.
369
+ */
370
+ function positionInConfig(configPath, field) {
371
+ if (!/\.ya?ml$/i.test(configPath)) return {};
372
+
373
+ let text;
374
+ try {
375
+ text = fs.readFileSync(configPath, "utf8");
376
+ } catch {
377
+ return {};
378
+ }
379
+
380
+ const keyPath = yamlKeyPath(field);
381
+ if (keyPath.length === 0) return {};
382
+
383
+ const declared = positionOfYamlPath(text, keyPath, { key: true });
384
+ if (declared.line !== undefined) return declared;
385
+ if (keyPath.length === 1) return {};
386
+ return positionOfYamlPath(text, keyPath.slice(0, -1), { key: true });
387
+ }
388
+
389
+ /**
390
+ * Attach the position of the key a configuration error names.
391
+ *
392
+ * Eighty-one checks across `content-config.mjs` and `config.mjs` report through
393
+ * one `fail()`, which knows the offending key's dotted path and nothing
394
+ * about where it was written. Locating one of them and not the rest would be
395
+ * worse than locating none — a reader would learn that some configuration
396
+ * errors carry a position and could not predict which — so the path rides on
397
+ * the error and every one of them is located here, at the boundary that knows
398
+ * which file was read (#95).
399
+ *
400
+ * The message keeps its body and gains the `file:line:column: error: ` prefix
401
+ * every other finding in this build already uses, so nothing a reader has today
402
+ * is lost. `located` marks it done, so an error crossing two boundaries is
403
+ * decorated once; the fields are also left on the error, for a caller that
404
+ * wants to re-render it.
405
+ *
406
+ * @param {unknown} err - What was thrown.
407
+ * @param {string} [configPath] - The configuration file that was read.
408
+ * @returns {unknown} The same error, decorated when it named a field.
409
+ */
410
+ export function locateConfigError(err, configPath) {
411
+ const failure =
412
+ /** @type {{field?: unknown, located?: boolean, message?: string, file?: string, line?: number, column?: number}} */ (
413
+ err
414
+ );
415
+ if (!(err instanceof Error)) return err;
416
+ if (
417
+ failure.located ||
418
+ typeof failure.field !== "string" ||
419
+ !failure.field
420
+ ) {
421
+ return err;
422
+ }
423
+ if (!configPath) return err;
424
+
425
+ const at = {
426
+ file: configPath,
427
+ ...positionInConfig(configPath, failure.field),
428
+ };
429
+ Object.assign(failure, at, { located: true });
430
+ failure.message = formatDiagnostic({
431
+ ...at,
432
+ severity: "error",
433
+ message: /** @type {string} */ (failure.message),
434
+ });
435
+ return err;
436
+ }
437
+
317
438
  /**
318
439
  * Turn a parsed YAML configuration into the frozen one the engine reads.
319
440
  *
@@ -400,23 +521,28 @@ export function configFromData(data, configPath) {
400
521
  const stats = input.stats;
401
522
  if (stats !== null && typeof stats === "object" && !Array.isArray(stats)) {
402
523
  const declared = /** @type {Record<string, unknown>} */ (stats);
403
- if (declared.systemVersion !== undefined) {
404
- throw new Error(
405
- `package-build: ${configPath} declares ` +
406
- `\`stats.systemVersion\`, which a data configuration may ` +
407
- `not: a system derives it from the \`version\` of the ` +
408
- `\`package.json\` beside it, and a module from the ` +
409
- `\`compatibility.verified\` of the system it declares a ` +
410
- `relationship with. Remove the key.`,
411
- );
412
- }
524
+ // `stats.systemId` and `stats.systemVersion` are both refused by
525
+ // `defineConfig`, which reports them with a locator — so nothing is
526
+ // rejected here. This half only supplies the value the validator cannot
527
+ // compute: resolving a system package's version means reading the
528
+ // adjacent `package.json`, and `defineConfig` performs no I/O.
529
+ //
530
+ // Passed under a symbol so the channel is not a second, forgeable
531
+ // spelling of the key that was just refused (see
532
+ // {@link DERIVED_SYSTEM_VERSION}).
413
533
  input.stats = {
414
534
  ...declared,
415
- systemVersion: shippedSystemVersion(rootDir, input),
535
+ [DERIVED_SYSTEM_VERSION]: shippedSystemVersion(rootDir, input),
416
536
  };
417
537
  }
418
538
 
419
- return defineConfig(/** @type {never} */ (input));
539
+ try {
540
+ return defineConfig(/** @type {never} */ (input));
541
+ } catch (err) {
542
+ // Every `fail()` in the validator names a key and knows nothing about
543
+ // the file; this is where the two meet.
544
+ throw locateConfigError(err, configPath);
545
+ }
420
546
  }
421
547
 
422
548
  /**
@@ -432,6 +558,10 @@ function loadCodeConfig(configPath) {
432
558
  try {
433
559
  module = require(configPath);
434
560
  } catch (err) {
561
+ // A code configuration calls `defineConfig` itself, so its rejections
562
+ // arrive here. There is no YAML to locate into, but the file is known —
563
+ // `locateConfigError` names it and drops the line.
564
+ locateConfigError(err, configPath);
435
565
  if (
436
566
  /** @type {{ code?: string }} */ (err)?.code ===
437
567
  "ERR_REQUIRE_ASYNC_MODULE"
@@ -240,8 +240,16 @@ function toDiagnostic(directory, result) {
240
240
  *
241
241
  * {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
242
242
  * which is precisely the "shipped default, consumer overrides" behaviour the
243
- * command promises: a `.markdownlint-cli2.jsonc` found in the tree replaces it,
244
- * and a repository with none gets these rules.
243
+ * command promises: a repository with no configuration of its own gets these
244
+ * rules, and a `.markdownlint-cli2.jsonc` found in the tree overrides them.
245
+ *
246
+ * The override is **key by key, and each key wholesale** — which is not the same
247
+ * as "replaces it", and the difference is the one worth stating. A consumer file
248
+ * declaring only `ignores` keeps this rule set intact, including `default: false`
249
+ * and every per-rule option; but its `ignores` *replaces*
250
+ * {@link MARKDOWN_IGNORES} rather than extending it, so such a file must restate
251
+ * every shared entry it still wants. Omitting `CHANGELOG.md` there silently
252
+ * starts linting a generated file.
245
253
  *
246
254
  * @param {string} root - Repository to lint.
247
255
  * @param {object} [opts]
package/engine/scenes.mjs CHANGED
@@ -414,7 +414,7 @@ export class Scenes extends BasePackCompiler {
414
414
  packageId: foundryPackageId(),
415
415
  name,
416
416
  folder,
417
- stats: defaultStats(),
417
+ stats: this.stats,
418
418
  journalEntryId: entryId,
419
419
  // A map note's prose is a derived JournalEntry: it lands in the
420
420
  // default JournalEntry pack, not in whichever Scene pack the map
@@ -521,7 +521,7 @@ export class Scenes extends BasePackCompiler {
521
521
  sort: 0,
522
522
  flags: {},
523
523
  _id: id,
524
- _stats: defaultStats(),
524
+ _stats: this.stats,
525
525
  _key: `!adventures!${id}`,
526
526
  };
527
527
  }