@heroiclands/package-build 9.0.0 → 10.0.1

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 (78) hide show
  1. package/CHANGELOG.md +721 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +437 -7
  4. package/content-config.mjs +259 -28
  5. package/docs/content-format.md +1418 -0
  6. package/engine/address-charset.mjs +62 -0
  7. package/engine/alias-index.mjs +153 -0
  8. package/engine/base-compiler.mjs +194 -4
  9. package/engine/content-address.mjs +4 -4
  10. package/engine/content-format-check.mjs +570 -0
  11. package/engine/content-format.mjs +253 -0
  12. package/engine/content-links.mjs +132 -56
  13. package/engine/content-lint.mjs +8 -1
  14. package/engine/diagnostics.mjs +33 -0
  15. package/engine/document-subtypes.mjs +440 -0
  16. package/engine/field-spec.mjs +49 -43
  17. package/engine/frontmatter-lint.mjs +351 -28
  18. package/engine/generate.mjs +32 -4
  19. package/engine/helpers.mjs +41 -29
  20. package/engine/ids.mjs +19 -1
  21. package/engine/index.mjs +15 -0
  22. package/engine/item-registry.mjs +72 -5
  23. package/engine/kb-manifest.mjs +36 -7
  24. package/engine/map-notes.mjs +34 -18
  25. package/engine/note-claims.mjs +383 -0
  26. package/engine/note-vocabulary.mjs +678 -0
  27. package/engine/pack-config.mjs +39 -22
  28. package/engine/pack-router.mjs +17 -6
  29. package/engine/prose-lint.mjs +55 -3
  30. package/engine/retired-fields.mjs +117 -3
  31. package/engine/scenes.mjs +19 -1
  32. package/engine/schema-check.mjs +347 -3
  33. package/engine/site-build.mjs +1 -1
  34. package/engine/site-index.mjs +38 -21
  35. package/engine/system-block.mjs +513 -0
  36. package/engine/web-wikilinks.mjs +112 -80
  37. package/engine/wikilink-syntax.mjs +30 -0
  38. package/engine/wikilinks.mjs +67 -51
  39. package/package.json +6 -2
  40. package/sohl/actors.mjs +249 -36
  41. package/sohl/document-subtypes.mjs +82 -0
  42. package/sohl/index.mjs +3 -0
  43. package/sohl/items.mjs +110 -14
  44. package/sohl/note-schemas.mjs +11 -7
  45. package/types/content-config.d.mts +48 -4
  46. package/types/engine/address-charset.d.mts +45 -0
  47. package/types/engine/alias-index.d.mts +122 -0
  48. package/types/engine/base-compiler.d.mts +132 -4
  49. package/types/engine/content-address.d.mts +2 -2
  50. package/types/engine/content-format-check.d.mts +163 -0
  51. package/types/engine/content-format.d.mts +101 -0
  52. package/types/engine/content-links.d.mts +16 -1
  53. package/types/engine/content-lint.d.mts +6 -0
  54. package/types/engine/diagnostics.d.mts +29 -0
  55. package/types/engine/document-subtypes.d.mts +233 -0
  56. package/types/engine/field-spec.d.mts +76 -23
  57. package/types/engine/frontmatter-lint.d.mts +47 -2
  58. package/types/engine/generate.d.mts +14 -1
  59. package/types/engine/helpers.d.mts +21 -13
  60. package/types/engine/ids.d.mts +10 -0
  61. package/types/engine/index.d.mts +5 -0
  62. package/types/engine/item-registry.d.mts +21 -2
  63. package/types/engine/kb-manifest.d.mts +35 -8
  64. package/types/engine/map-notes.d.mts +21 -11
  65. package/types/engine/note-claims.d.mts +113 -0
  66. package/types/engine/note-vocabulary.d.mts +251 -0
  67. package/types/engine/pack-config.d.mts +4 -3
  68. package/types/engine/pack-router.d.mts +4 -4
  69. package/types/engine/prose-lint.d.mts +6 -2
  70. package/types/engine/retired-fields.d.mts +73 -2
  71. package/types/engine/schema-check.d.mts +182 -0
  72. package/types/engine/system-block.d.mts +281 -0
  73. package/types/engine/web-wikilinks.d.mts +23 -12
  74. package/types/engine/wikilink-syntax.d.mts +29 -0
  75. package/types/sohl/actors.d.mts +62 -6
  76. package/types/sohl/document-subtypes.d.mts +14 -0
  77. package/types/sohl/index.d.mts +1 -0
  78. package/types/sohl/items.d.mts +21 -0
@@ -39,6 +39,9 @@
39
39
  * npx content-build package clean [pack] [entry]
40
40
  * npx content-build docs item-fields [--out <path>] [--title <title>]
41
41
  * npx content-build lint [root] [--no-references]
42
+ * npx content-build content-format schema --schema <system>=<path>
43
+ * npx content-build content-format fields [--fields <system>]
44
+ * npx content-build content-format notes [root] [--strict]
42
45
  * npx content-build links [root] [--manifests <dir>]
43
46
  * npx content-build format [paths..] [--write]
44
47
  * npx content-build markdown [paths..] [--fix]
@@ -69,6 +72,12 @@ import {
69
72
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
70
73
  import { lintContentTree } from "../engine/content-lint.mjs";
71
74
  import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
75
+ import { loadContentFormat } from "../engine/content-format.mjs";
76
+ import {
77
+ checkDeclaredFields,
78
+ checkSchemaTargets,
79
+ measureCorpus,
80
+ } from "../engine/content-format-check.mjs";
72
81
  import {
73
82
  compareFields,
74
83
  resolveSchemaArtifact,
@@ -79,9 +88,13 @@ import {
79
88
  // set — an adventure module ships skills, beings and magic swords — so no
80
89
  // consumer gets a subset (#19, #20).
81
90
  import { NOTE_SCHEMAS } from "../sohl/note-schemas.mjs";
91
+ // The shipped declarations, so this repository can check its own specification
92
+ // against them without standing up a consumer's configuration (#136).
93
+ import { ITEM_FIELDS } from "../sohl/item-fields.mjs";
82
94
  // The engine's own types, merged under the registry's so the vocabulary stands
83
95
  // in a package that configures no `itemBuilders` at all (#51).
84
96
  import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
97
+ import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
85
98
  import { checkFormatting, lintMarkdown } from "../engine/prose-lint.mjs";
86
99
  import { emitLinkManifest } from "../engine/manifest-emit.mjs";
87
100
  import {
@@ -90,7 +103,12 @@ import {
90
103
  formatUnaddressableFinding as formatUnaddressable,
91
104
  } from "../engine/site-build.mjs";
92
105
  import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
93
- import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
106
+ import {
107
+ emitDiagnostic,
108
+ positionInFrontmatter,
109
+ positionOfLiteral,
110
+ } from "../engine/diagnostics.mjs";
111
+ import { reportFindings } from "./report.mjs";
94
112
  import {
95
113
  readItemAddresses,
96
114
  diffItemAddresses,
@@ -99,6 +117,7 @@ import {
99
117
  addressFindingMessage,
100
118
  } from "../engine/address-diff.mjs";
101
119
  import { itemPackJsonDirs } from "../engine/generate.mjs";
120
+ import { walkMarkdownTree } from "../engine/helpers.mjs";
102
121
  import {
103
122
  formatUnaddressableFinding,
104
123
  unaddressableForeignPackages,
@@ -170,11 +189,19 @@ function reportFailure(err) {
170
189
  else log.error(message);
171
190
  }
172
191
 
192
+ /**
193
+ * The declaration sets this package ships, addressable by system id.
194
+ *
195
+ * @type {Record<string, Record<string, readonly object[]>>}
196
+ */
197
+ const SHIPPED_ITEM_FIELDS = { sohl: ITEM_FIELDS };
198
+
173
199
  const argv = yargs(hideBin(process.argv))
174
200
  .command(packageCommand())
175
201
  .command(depsCommand())
176
202
  .command(docsCommand())
177
203
  .command(lintCommand())
204
+ .command(contentFormatCommand())
178
205
  .command(linksCommand())
179
206
  .command(formatCommand())
180
207
  .command(markdownCommand())
@@ -315,6 +342,319 @@ function docsCommand() {
315
342
  };
316
343
  }
317
344
 
345
+ /**
346
+ * `content-build content-format` — check the format specification itself (#130).
347
+ *
348
+ * Two checks, because the specification makes claims about two different
349
+ * worlds, and they fail for different reasons and at different times:
350
+ *
351
+ * - `schema` compares every `system.*` target the document names against the
352
+ * naming system's published `schema.json`. A failure means the specification
353
+ * and the system disagree, which is a defect in one of the two.
354
+ * - `fields` compares the per-type tables against the field declarations that
355
+ * compile them, so the hand-written half cannot drift from the generated one
356
+ * (#136).
357
+ * - `notes` measures a content tree against the vocabulary the document
358
+ * declares per type. During #127 it is the migration's progress bar rather
359
+ * than a gate, so it **reports** by default and `--strict` makes it fatal —
360
+ * turned on one class at a time as each slice lands.
361
+ *
362
+ * Both read the committed document rather than a transcription of it, so
363
+ * editing `docs/content-format.md` changes what they assert.
364
+ *
365
+ * @returns {object} The yargs command module.
366
+ */
367
+ // eslint-disable-next-line
368
+ function contentFormatCommand() {
369
+ return {
370
+ command: "content-format <action>",
371
+ describe: "Check the content format specification against schemas and notes",
372
+ builder: (yargs) =>
373
+ yargs
374
+ .command(contentFormatSchemaCommand())
375
+ .command(contentFormatFieldsCommand())
376
+ .command(contentFormatNotesCommand())
377
+ .demandCommand(1, "Name an action.")
378
+ .strict(),
379
+ handler: () => {},
380
+ };
381
+ }
382
+
383
+ /**
384
+ * The parsed specification a `content-format` action should read.
385
+ *
386
+ * @param {object} argv - The parsed command line.
387
+ * @returns {import("../engine/content-format.mjs").ContentFormat} The document.
388
+ */
389
+ function specFrom(argv) {
390
+ return argv.spec ? loadContentFormat(path.resolve(argv.spec)) : loadContentFormat();
391
+ }
392
+
393
+ /**
394
+ * `content-format schema` — every `system.*` target, against a published schema.
395
+ *
396
+ * The schemas are named on the command line as `<system>=<path>`, because a
397
+ * consumer holds one and this repository holds a committed fixture, and neither
398
+ * arrangement should be the one the other has to pretend to. A system the
399
+ * document maps onto but no schema was supplied for is reported as unchecked —
400
+ * HM3 publishes no artifact today, so that is the ordinary case for a fifth of
401
+ * the claims, and a check that quietly skipped them would read as one that
402
+ * passed.
403
+ *
404
+ * @returns {object} The yargs command module.
405
+ */
406
+ function contentFormatSchemaCommand() {
407
+ return {
408
+ command: "schema",
409
+ describe: "Check every `system.*` target the format names against a published schema.json",
410
+ builder: (yargs) => {
411
+ yargs.option("spec", {
412
+ describe:
413
+ "The specification to read. Defaults to the docs/content-format.md this package ships.",
414
+ type: "string",
415
+ });
416
+ yargs.option("schema", {
417
+ describe:
418
+ "A published schema, as `<system>=<path>`. Repeatable; a system with none is reported unchecked.",
419
+ type: "string",
420
+ array: true,
421
+ demandOption: true,
422
+ });
423
+ },
424
+ handler: (argv) => {
425
+ try {
426
+ const format = specFrom(argv);
427
+ /** @type {Record<string, object>} */
428
+ const schemas = {};
429
+ for (const entry of argv.schema) {
430
+ const at = String(entry).indexOf("=");
431
+ if (at <= 0) {
432
+ log.error(`--schema takes \`<system>=<path>\`, not "${entry}".`);
433
+ process.exitCode = 1;
434
+ return;
435
+ }
436
+ const system = entry.slice(0, at);
437
+ const file = path.resolve(entry.slice(at + 1));
438
+ schemas[system] = JSON.parse(fs.readFileSync(file, "utf8"));
439
+ }
440
+
441
+ const { findings, checked, unchecked } = checkSchemaTargets({ format, schemas });
442
+ for (const finding of findings) emitDiagnostic(finding);
443
+ for (const [system, count] of Object.entries(unchecked)) {
444
+ log.info(
445
+ `${count} claim(s) about ${system} are unchecked — no ` +
446
+ `schema was supplied for it, so nothing here confirms them.`,
447
+ );
448
+ }
449
+ if (findings.length) {
450
+ log.error(
451
+ `${findings.length} of ${checked} checked claim(s) name a ` +
452
+ `field no schema declares.`,
453
+ );
454
+ process.exitCode = 1;
455
+ } else {
456
+ log.info(`${checked} mapping claim(s) confirmed against the supplied schemas.`);
457
+ }
458
+ } catch (err) {
459
+ reportFailure(err);
460
+ process.exitCode = 1;
461
+ }
462
+ },
463
+ };
464
+ }
465
+
466
+ /**
467
+ * The field declarations a `content-format fields` run should compare against.
468
+ *
469
+ * Either this package's own shipped registry, named on the command line, or the
470
+ * consuming repository's resolved configuration. Both are real arrangements and
471
+ * neither should have to pretend to be the other: this repository ships the
472
+ * specification *and* the SoHL declarations and configures no content tree,
473
+ * while a consumer configures `itemBuilders` and resolves the specification
474
+ * from its toolchain.
475
+ *
476
+ * @param {object} argv - The parsed command line.
477
+ * @returns {{itemFields: Record<string, readonly object[]>, system: string}}
478
+ * The declarations, and the system column of the mapping tables they compile.
479
+ */
480
+ function declarationsFrom(argv) {
481
+ if (argv.fields) return { itemFields: SHIPPED_ITEM_FIELDS[argv.fields], system: argv.fields };
482
+ const config = loadPackConfig();
483
+ const system = config.stats?.systemId;
484
+ if (!system) {
485
+ throw new Error(
486
+ "package-build: this repository's configuration names no system, so " +
487
+ "nothing says which column of the format's mapping tables its " +
488
+ "`itemBuilders` declarations compile. Name a shipped set with " +
489
+ "`--fields <system>` instead.",
490
+ );
491
+ }
492
+ return { itemFields: config.itemFields ?? {}, system };
493
+ }
494
+
495
+ /**
496
+ * `content-format fields` — the per-type tables, against the declarations.
497
+ *
498
+ * The specification hand-writes a `data` table under most of its type sections,
499
+ * covering ground {@link module:engine/field-reference} already generates from
500
+ * the `fields` on each `itemBuilders` entry — the duplication that module exists
501
+ * to prevent, one document over (#136).
502
+ *
503
+ * **Checked, not merged.** The document's vocabulary spans note types that
504
+ * produce Scenes, Macros and JournalEntries, which no item registry covers, so
505
+ * there is no wholesale generation to fall back on. What the two *can* be held
506
+ * to is agreement where they both speak: a mapping row saying `data.weight`
507
+ * reaches `system.weightBase` and a declaration writing `weight` to `weightBase`
508
+ * are one statement made twice, and a rename that moves only one of them is a
509
+ * defect. A type only one side describes is named as out of reach rather than
510
+ * skipped in silence.
511
+ *
512
+ * @returns {object} The yargs command module.
513
+ */
514
+ function contentFormatFieldsCommand() {
515
+ return {
516
+ command: "fields",
517
+ describe: "Check the format's per-type tables against the field declarations",
518
+ builder: (yargs) => {
519
+ yargs.option("spec", {
520
+ describe:
521
+ "The specification to read. Defaults to the docs/content-format.md this package ships.",
522
+ type: "string",
523
+ });
524
+ yargs.option("fields", {
525
+ describe:
526
+ "A declaration set this package ships, by system id. Defaults to the consuming repository's own `itemBuilders`.",
527
+ type: "string",
528
+ choices: Object.keys(SHIPPED_ITEM_FIELDS),
529
+ });
530
+ yargs.option("coverage", {
531
+ describe:
532
+ "List, per type, the fields only one side names. They are not findings — the two vocabularies differ by design until #127 lands.",
533
+ type: "boolean",
534
+ default: false,
535
+ });
536
+ },
537
+ handler: (argv) => {
538
+ try {
539
+ const format = specFrom(argv);
540
+ const { itemFields, system } = declarationsFrom(argv);
541
+ const result = checkDeclaredFields({ format, itemFields, system });
542
+ for (const finding of result.findings) emitDiagnostic(finding);
543
+
544
+ // Named rather than left implicit: a check that silently
545
+ // compared nine of twenty-two types would read as one that
546
+ // covered them all.
547
+ log.info(
548
+ `${result.checked.length} type(s) compared against ${system}'s ` +
549
+ `declarations (${result.fields} field pair(s)).`,
550
+ );
551
+ log.info(
552
+ `${result.skipped.spec.length} type(s) the format declares are ` +
553
+ `out of reach — no \`itemBuilders\` entry covers them: ` +
554
+ `${result.skipped.spec.join(", ")}.`,
555
+ );
556
+ if (result.skipped.registry.length) {
557
+ log.info(
558
+ `${result.skipped.registry.length} declared type(s) the ` +
559
+ `format names no section for: ` +
560
+ `${result.skipped.registry.join(", ")}.`,
561
+ );
562
+ }
563
+ if (argv.coverage) {
564
+ for (const entry of result.coverage) {
565
+ log.info(
566
+ `${entry.type}: format only [${entry.specOnly.join(", ")}], ` +
567
+ `declaration only [${entry.registryOnly.join(", ")}]`,
568
+ );
569
+ }
570
+ }
571
+
572
+ if (result.findings.length) {
573
+ log.error(
574
+ `${result.findings.length} of ${result.fields} compared field ` +
575
+ `pair(s) disagree between the specification and the ` +
576
+ `declaration that compiles them.`,
577
+ );
578
+ process.exitCode = 1;
579
+ }
580
+ } catch (err) {
581
+ reportFailure(err);
582
+ process.exitCode = 1;
583
+ }
584
+ },
585
+ };
586
+ }
587
+
588
+ /**
589
+ * `content-format notes` — a content tree, against the declared vocabulary.
590
+ *
591
+ * **A report, not a gate.** Every authored note predates the format, so a
592
+ * failing check would be red in every repository from the day it lands and
593
+ * would stay red for the length of #127 — which is a check nobody can act on.
594
+ * `--strict` raises the findings to errors, and #127 turns it on slice by
595
+ * slice as each class of finding reaches zero.
596
+ *
597
+ * @returns {object} The yargs command module.
598
+ */
599
+ function contentFormatNotesCommand() {
600
+ return {
601
+ command: "notes [root]",
602
+ describe: "Measure a content tree against the vocabulary the format declares (a report)",
603
+ builder: (yargs) => {
604
+ yargs.positional("root", {
605
+ describe: "Content tree to measure. Defaults to the configured contentBase.",
606
+ type: "string",
607
+ });
608
+ yargs.option("spec", {
609
+ describe:
610
+ "The specification to read. Defaults to the docs/content-format.md this package ships.",
611
+ type: "string",
612
+ });
613
+ yargs.option("strict", {
614
+ describe:
615
+ "Fail on the findings instead of reporting them. Turned on per slice of #127, as each class reaches zero.",
616
+ type: "boolean",
617
+ default: false,
618
+ });
619
+ },
620
+ handler: (argv) => {
621
+ try {
622
+ const config = loadPackConfig();
623
+ const root = argv.root ?? config.paths.content;
624
+ const format = specFrom(argv);
625
+
626
+ const notes = [];
627
+ for (const { frontmatter, absPath } of walkMarkdownTree(root, {
628
+ skipDirectories: config.skipDirectories,
629
+ })) {
630
+ if (!frontmatter || typeof frontmatter.type !== "string") continue;
631
+ notes.push({
632
+ file: absPath,
633
+ fm: frontmatter,
634
+ raw: fs.readFileSync(absPath, "utf8"),
635
+ });
636
+ }
637
+
638
+ const { findings, byClass } = measureCorpus(notes, format, {
639
+ strict: argv.strict,
640
+ });
641
+ for (const finding of findings) emitDiagnostic(finding);
642
+
643
+ const counts = Object.entries(byClass).sort(([a], [b]) => (a < b ? -1 : 1));
644
+ for (const [cls, count] of counts) log.info(`${cls}: ${count}`);
645
+ log.info(
646
+ `${findings.length} finding(s) across ${notes.length} note(s) ` +
647
+ `measured against ${format.file}.`,
648
+ );
649
+ if (argv.strict && findings.length) process.exitCode = 1;
650
+ } catch (err) {
651
+ reportFailure(err);
652
+ process.exitCode = 1;
653
+ }
654
+ },
655
+ };
656
+ }
657
+
318
658
  /**
319
659
  * `content-build lint` — check a content tree's addresses.
320
660
  *
@@ -368,6 +708,11 @@ function lintCommand() {
368
708
  });
369
709
  const frontmatter = lintFrontmatter(index, {
370
710
  schemas: { ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS },
711
+ // The closed frontmatter regions (#128). Passed in rather
712
+ // than reached for, so the linter stays a checker of
713
+ // whatever it is handed and this stays the one place that
714
+ // decides which vocabulary a tree is held to.
715
+ vocabulary: NOTE_VOCABULARY,
371
716
  references: argv.references,
372
717
  });
373
718
 
@@ -445,10 +790,23 @@ function lintCommand() {
445
790
  ...frontmatter.findings,
446
791
  ...schemaFindings,
447
792
  ];
448
- for (const finding of findings) emitDiagnostic(finding);
449
- if (findings.length) {
793
+ // Only an **error** fails the run. Every finding was an error
794
+ // until #142, so this changed nothing on the day it landed —
795
+ // but a field retired in favour of another is reported while
796
+ // both spellings still compile, and failing a build over a note
797
+ // that produces the correct document would red a tree that has
798
+ // done nothing wrong. `reportFindings` already draws exactly
799
+ // that line, so the rule is the shared one rather than a second
800
+ // copy here. Each finding names its own file.
801
+ const errors = reportFindings(findings, {});
802
+ if (errors) {
450
803
  log.error(`${findings.length} finding(s) across ${addresses.notes} note(s).`);
451
804
  process.exitCode = 1;
805
+ } else if (findings.length) {
806
+ log.warn(
807
+ `${findings.length} advisory finding(s) across ` +
808
+ `${addresses.notes} note(s); none fails the build.`,
809
+ );
452
810
  } else {
453
811
  log.info(
454
812
  `Addresses and frontmatter are well-formed ` +
@@ -521,6 +879,17 @@ function formatCommand() {
521
879
  `Formatted ${written.length} of ${checked} file(s).`
522
880
  : `Already formatted (${checked} file(s)).`,
523
881
  );
882
+ // `--write` collects findings too — a file Prettier cannot
883
+ // parse, or one that will not format to a fixpoint — and
884
+ // used to discard them, so a run that had left files
885
+ // unformatted still reported success and exited 0 (#125).
886
+ for (const finding of findings) emitDiagnostic(finding);
887
+ if (findings.length) {
888
+ log.error(
889
+ `${findings.length} of ${checked} file(s) could not be formatted.`,
890
+ );
891
+ process.exitCode = 1;
892
+ }
524
893
  return;
525
894
  }
526
895
  for (const finding of findings) emitDiagnostic(finding);
@@ -653,6 +1022,8 @@ function linksCommand() {
653
1022
  const {
654
1023
  deadAnchors,
655
1024
  deadAddresses,
1025
+ deadAliases,
1026
+ aliasCollisions,
656
1027
  frontmatterLinks,
657
1028
  homepageLinks,
658
1029
  usedManifest,
@@ -668,14 +1039,62 @@ function linksCommand() {
668
1039
  `heading in ${d.dest.rel} declares`,
669
1040
  });
670
1041
  }
1042
+ // The pipe says the author meant an address (#131), so all
1043
+ // three of these are errors — but they read differently
1044
+ // because the corrections differ.
671
1045
  for (const d of deadAddresses) {
672
1046
  emitDiagnostic({
673
1047
  file: d.note.file,
674
1048
  ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
675
1049
  severity: "error",
676
- message: `dead address [[${d.target}]] — no document has that identity`,
1050
+ message:
1051
+ d.reason === "not-an-address" ?
1052
+ `"${d.target}" is written as an address — the ` +
1053
+ `"|" says so — but it is not one; write ` +
1054
+ `[[type-shortcode|Text]], or drop the "|" to ` +
1055
+ `name it as an alias within this note's type`
1056
+ : d.reason === "unknown-type" ?
1057
+ `address [[${d.target}]] names no known ` + `content type`
1058
+ : `dead address [[${d.target}]] — no document ` + `has that identity`,
1059
+ });
1060
+ }
1061
+ // An alias that names nothing may be a worldbuilding
1062
+ // placeholder — a long-standing convention in the setting
1063
+ // trees — so it is reported and does not fail the build. The
1064
+ // ambiguous case is the collision below, reported at its
1065
+ // claimants rather than here.
1066
+ for (const d of deadAliases) {
1067
+ emitDiagnostic({
1068
+ file: d.note.file,
1069
+ ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
1070
+ severity: "warning",
1071
+ message:
1072
+ d.ambiguous ?
1073
+ `alias [[${d.target}]] is claimed by ` +
1074
+ `${d.claimants.length} ${d.note.type} notes, ` +
1075
+ `so it names none of them; address the ` +
1076
+ `intended one as [[type-shortcode|Text]]`
1077
+ : `unresolved alias [[${d.target}]] — no ` +
1078
+ `${d.note.type} note claims that name`,
677
1079
  });
678
1080
  }
1081
+ // Reported once per claimant, at the claimant, because the
1082
+ // note that merely cites an ambiguous alias is innocent (#13).
1083
+ for (const c of aliasCollisions) {
1084
+ const others = c.claimants.map((n) => n.rel).join(", ");
1085
+ for (const claimant of c.claimants) {
1086
+ emitDiagnostic({
1087
+ file: claimant.file,
1088
+ ...positionInFrontmatter(claimant.raw, "aliases", c.alias),
1089
+ severity: "error",
1090
+ message:
1091
+ `alias "${c.alias}" is claimed by ` +
1092
+ `${c.claimants.length} ${c.type} notes ` +
1093
+ `(${others}), so [[${c.alias}]] names none of ` +
1094
+ `them; rename all but one`,
1095
+ });
1096
+ }
1097
+ }
679
1098
  for (const f of frontmatterLinks) {
680
1099
  emitDiagnostic({
681
1100
  file: f.note.file,
@@ -700,9 +1119,12 @@ function linksCommand() {
700
1119
  });
701
1120
  }
702
1121
 
1122
+ // Warnings are reported and do not fail: a dead alias may be a
1123
+ // note not yet written, which is the placeholder convention.
703
1124
  const failures =
704
1125
  deadAnchors.length +
705
1126
  deadAddresses.length +
1127
+ aliasCollisions.reduce((n, c) => n + c.claimants.length, 0) +
706
1128
  frontmatterLinks.length +
707
1129
  homepageLinks.length;
708
1130
  if (failures) {
@@ -711,10 +1133,18 @@ function linksCommand() {
711
1133
  } else {
712
1134
  log.info(
713
1135
  `${index.notes.length} notes: every anchor link lands ` +
714
- `and every qualified address resolves ` +
1136
+ `and every address resolves ` +
715
1137
  `(${usedManifest.size} cross-package reference(s) ` +
716
- `via manifest), no wikilink in frontmatter, ` +
717
- `every homepage address resolvable.`,
1138
+ `via manifest), no alias claimed twice, no ` +
1139
+ `wikilink in frontmatter, every homepage address ` +
1140
+ `resolvable.`,
1141
+ );
1142
+ }
1143
+ if (deadAliases.length) {
1144
+ log.warn(
1145
+ `${deadAliases.length} unresolved alias(es) — a bare ` +
1146
+ `[[Name]] naming no note may be a placeholder, so ` +
1147
+ `these are reported rather than failed.`,
718
1148
  );
719
1149
  }
720
1150
  } catch (err) {