@heroiclands/package-build 6.1.0 → 7.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.
@@ -58,6 +58,7 @@ import {
58
58
  canonicalKey,
59
59
  loadForeignManifests,
60
60
  manifestsComplete,
61
+ PACKAGE_BASE,
61
62
  readCanonicalKey,
62
63
  } from "./kb-manifest.mjs";
63
64
  import { frontmatterWikilinks, slugify } from "./web-wikilinks.mjs";
@@ -336,6 +337,74 @@ export function buildLinkIndex(
336
337
  */
337
338
  const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
338
339
 
340
+ /**
341
+ * Every package landing this build can name, as `package` → base (#87).
342
+ *
343
+ * **A landing needs no manifest, and that is what makes it work.** The link
344
+ * manifest indexes content notes, and a homepage is deliberately not one — it
345
+ * compiles to no document and is entered in no manifest. The reading that
346
+ * follows from this, and that left a hardcoded URL as the only authored form,
347
+ * is that a landing therefore cannot be addressed. It does not follow: a
348
+ * landing's address is not a *note's* address but the **package's**, and
349
+ * {@link PACKAGE_BASE} already records where each package is served. That is a
350
+ * frozen constant vendored into every repository, so consulting it walks no
351
+ * tree, reads no manifest and builds no index — which is precisely why the
352
+ * mechanism survives `homepage` mode, where the licensing fence means none of
353
+ * those exist.
354
+ *
355
+ * The roster is consulted **for landings only**. Widening the package set the
356
+ * other rules read would make them offer manifest-based advice about packages
357
+ * no manifest is vendored for.
358
+ *
359
+ * @param {string} ownPackage - The package this build publishes.
360
+ * @param {Iterable<string>} manifestPackages - Packages a vendored manifest
361
+ * names, which are addressable whether or not the roster lists them.
362
+ * @returns {Map<string, string>} Package to base, each base slash-terminated.
363
+ */
364
+ function landingBases(ownPackage, manifestPackages) {
365
+ const bases = new Map();
366
+ // Convention first, roster second, so a package the roster relocates is
367
+ // recorded at the relocated base rather than the default one.
368
+ for (const pkg of [ownPackage, ...manifestPackages]) {
369
+ if (pkg) bases.set(pkg, `/${pkg}/`);
370
+ }
371
+ for (const [pkg, base] of Object.entries(PACKAGE_BASE)) {
372
+ if (typeof base === "string" && base.endsWith("/")) {
373
+ bases.set(pkg, base);
374
+ }
375
+ }
376
+ return bases;
377
+ }
378
+
379
+ /**
380
+ * The package whose landing an address names, or `null`.
381
+ *
382
+ * Matches the whole path, not a prefix: `/sohl/` is the landing, `/sohl/kb/`
383
+ * is a page inside the package and belongs to the manifest rules instead.
384
+ *
385
+ * @param {string} url - The authored address.
386
+ * @param {Map<string, string>} bases - From {@link landingBases}.
387
+ * @returns {{pkg: string, base: string}|null} The package and its base.
388
+ */
389
+ function landingTarget(url, bases) {
390
+ const value = String(url ?? "").trim();
391
+ if (!value || !/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
392
+ let parsed;
393
+ try {
394
+ parsed = new URL(value);
395
+ } catch {
396
+ return null;
397
+ }
398
+ if (!/^https?:$/.test(parsed.protocol)) return null;
399
+ if (!SITE_HOST.test(parsed.hostname)) return null;
400
+ const pathname =
401
+ parsed.pathname.endsWith("/") ? parsed.pathname : `${parsed.pathname}/`;
402
+ for (const [pkg, base] of bases) {
403
+ if (pathname === base) return { pkg, base };
404
+ }
405
+ return null;
406
+ }
407
+
339
408
  /**
340
409
  * How an authored address resolves, or `null` for one nothing here can judge.
341
410
  *
@@ -400,9 +469,17 @@ function readAddress(url, packages) {
400
469
  * and what replaced it, so this is a fact rather than a guess — and it is
401
470
  * exactly the SoHL defect.
402
471
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
403
- * vendored manifest names. Both have a better form to write, which is why they
404
- * are reported; a bare `/<package>/` is left alone, because a package
405
- * homepage is in no manifest and there is nothing better to write.
472
+ * vendored manifest names. Every one of them has a better form to write, which
473
+ * is why every one is reported including a bare `/<package>/`, which names
474
+ * another package's landing (#87).
475
+ *
476
+ * That last case was exempt until the better form was identified, on the
477
+ * reasoning that a landing is in no link manifest so nothing could resolve it.
478
+ * True, and beside the point: it does not need resolving. A landing's address
479
+ * *is* its package prefix, so `/<package>/` is the absolute URL with the host
480
+ * struck off — host-free, emitted verbatim, and needing no index, which is
481
+ * what lets it hold in homepage-only mode where the tree is never walked. The
482
+ * form was already accepted here; nothing had ever named it as the one to use.
406
483
  * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
407
484
  * time. `href:` means "already resolved, use verbatim", so the same leading
408
485
  * slash is correct there and is not reported.
@@ -424,6 +501,7 @@ function readAddress(url, packages) {
424
501
  export function auditHomepageLinks(index) {
425
502
  const findings = [];
426
503
  const packages = new Set([index.contentPackage, ...index.packages]);
504
+ const bases = landingBases(index.contentPackage, index.packages);
427
505
 
428
506
  for (const note of index.notes) {
429
507
  if (!isHomepage(note.fm)) continue;
@@ -467,28 +545,43 @@ export function auditHomepageLinks(index) {
467
545
  if (!address) continue;
468
546
  const { shape, segments, prefix } = address;
469
547
 
470
- if (shape === "absolute" && prefix) {
471
- // A bare `/<package>/` is a package's homepage, which is in no
472
- // link manifest and has no relative form from another package.
473
- // A finding with no fix is noise.
548
+ // Landings first, and by the roster rather than by the manifest
549
+ // package set: a landing is addressable in a repository that
550
+ // vendors no manifest at all, which is the case the fence creates
551
+ // and the case this rule exists for (#87).
552
+ const landing = landingTarget(url, bases);
553
+ if (landing) {
554
+ report(
555
+ field,
556
+ url,
557
+ url,
558
+ occurrence,
559
+ `hardcoded absolute URL to ` +
560
+ (landing.pkg === index.contentPackage ?
561
+ `this package's own landing`
562
+ : `package "${landing.pkg}"'s landing`) +
563
+ ` — write "${landing.base}", which names no host, is ` +
564
+ `emitted verbatim, and resolves through the package ` +
565
+ `roster rather than through an index, so it holds ` +
566
+ `where no content tree is walked`,
567
+ );
568
+ } else if (shape === "absolute" && prefix) {
474
569
  const rest = segments.slice(1).join("/");
475
- if (rest) {
476
- report(
477
- field,
478
- url,
479
- url,
480
- occurrence,
481
- prefix === index.contentPackage ?
482
- `hardcoded absolute URL into this package's own ` +
483
- `address write the package-relative ` +
484
- `"${rest}/", which the landing resolves ` +
485
- `against the site so the page follows the mount`
486
- : `hardcoded absolute URL into package "${prefix}" ` +
487
- `— resolve it through that package's link ` +
488
- `manifest, whose entries carry the address, so a ` +
489
- `relocation does not leave this page behind`,
490
- );
491
- }
570
+ report(
571
+ field,
572
+ url,
573
+ url,
574
+ occurrence,
575
+ prefix === index.contentPackage ?
576
+ `hardcoded absolute URL into this package's own ` +
577
+ `address write the package-relative ` +
578
+ `"${rest}/", which the landing resolves ` +
579
+ `against the site so the page follows the mount`
580
+ : `hardcoded absolute URL into package "${prefix}" ` +
581
+ `— resolve it through that package's link ` +
582
+ `manifest, whose entries carry the address, so a ` +
583
+ `relocation does not leave this page behind`,
584
+ );
492
585
  } else if (shape === "rooted" && kind === "url") {
493
586
  const rest =
494
587
  prefix ? segments.slice(1).join("/") : segments.join("/");
@@ -497,9 +590,21 @@ export function auditHomepageLinks(index) {
497
590
  url,
498
591
  url,
499
592
  occurrence,
500
- `url "${url}" is root-relative, but a landing's url: is ` +
501
- `resolved against the site write "${rest}/", or ` +
502
- `href: for an address that is already resolved`,
593
+ // A `url:` is package-relative by construction, so it
594
+ // cannot address anything outside this package at all
595
+ // there is no relative spelling of another package's root.
596
+ // `href:` is the field for an address already resolved.
597
+ !rest ?
598
+ `url "${url}" addresses ` +
599
+ (prefix ?
600
+ `package "${prefix}"'s landing`
601
+ : `the site root`) +
602
+ `, but a landing's url: is package-relative and ` +
603
+ `cannot leave this package — write ` +
604
+ `href: "${url}", which is used verbatim`
605
+ : `url "${url}" is root-relative, but a landing's url: ` +
606
+ `is resolved against the site — write "${rest}/", ` +
607
+ `or href: for an address that is already resolved`,
503
608
  );
504
609
  }
505
610
 
@@ -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
+ }
@@ -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
  }