@heroiclands/package-build 8.1.0 → 10.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 (122) hide show
  1. package/CHANGELOG.md +748 -0
  2. package/CONTENT.md +273 -13
  3. package/bin/content-build.mjs +479 -123
  4. package/bin/package-build.mjs +27 -69
  5. package/bin/report.mjs +1 -2
  6. package/bundle.mjs +2 -10
  7. package/config.mjs +31 -106
  8. package/container.mjs +13 -57
  9. package/content-config.mjs +300 -188
  10. package/coverage.mjs +14 -55
  11. package/deploy.mjs +4 -13
  12. package/docs/content-format.md +1418 -0
  13. package/e2e.mjs +16 -55
  14. package/engine/address-charset.mjs +62 -0
  15. package/engine/address-diff.mjs +1 -4
  16. package/engine/alias-index.mjs +153 -0
  17. package/engine/base-compiler.mjs +203 -31
  18. package/engine/code-fences.mjs +4 -13
  19. package/engine/compendiums.mjs +13 -37
  20. package/engine/content-address.mjs +6 -10
  21. package/engine/content-format-check.mjs +570 -0
  22. package/engine/content-format.mjs +253 -0
  23. package/engine/content-links.mjs +144 -99
  24. package/engine/content-lint.mjs +12 -16
  25. package/engine/content-slug.mjs +2 -6
  26. package/engine/content-tables.mjs +26 -79
  27. package/engine/diagnostics.mjs +37 -15
  28. package/engine/document-subtypes.mjs +440 -0
  29. package/engine/field-reference.mjs +6 -20
  30. package/engine/field-spec.mjs +49 -45
  31. package/engine/foreign-catalog.mjs +7 -22
  32. package/engine/foreign-manifests.mjs +1 -4
  33. package/engine/frontmatter-lint.mjs +347 -43
  34. package/engine/frontmatter.mjs +3 -8
  35. package/engine/generate.mjs +36 -20
  36. package/engine/helpers.mjs +54 -81
  37. package/engine/homepage.mjs +4 -15
  38. package/engine/ids.mjs +21 -12
  39. package/engine/index.mjs +15 -0
  40. package/engine/item-registry.mjs +72 -9
  41. package/engine/journals.mjs +4 -14
  42. package/engine/kb-manifest.mjs +41 -24
  43. package/engine/macros.mjs +2 -10
  44. package/engine/manifest-emit.mjs +6 -17
  45. package/engine/map-notes.mjs +53 -87
  46. package/engine/note-claims.mjs +383 -0
  47. package/engine/note-package.mjs +1 -4
  48. package/engine/note-vocabulary.mjs +678 -0
  49. package/engine/pack-config.mjs +56 -60
  50. package/engine/pack-router.mjs +18 -8
  51. package/engine/prose-config.mjs +20 -4
  52. package/engine/prose-lint.mjs +61 -17
  53. package/engine/region-events.mjs +1 -3
  54. package/engine/retired-fields.mjs +117 -3
  55. package/engine/scene-levels.mjs +8 -22
  56. package/engine/scenes.mjs +31 -47
  57. package/engine/schema-check.mjs +348 -7
  58. package/engine/schema-extract.mjs +11 -39
  59. package/engine/site-build.mjs +13 -38
  60. package/engine/site-index.mjs +40 -35
  61. package/engine/system-block.mjs +513 -0
  62. package/engine/web-wikilinks.mjs +115 -92
  63. package/engine/wikilink-syntax.mjs +30 -0
  64. package/engine/wikilinks.mjs +147 -183
  65. package/index.mjs +1 -5
  66. package/lang.mjs +1 -3
  67. package/manifest.mjs +10 -37
  68. package/markdownlint-config.mjs +1 -5
  69. package/package.json +6 -2
  70. package/sohl/actors.mjs +251 -68
  71. package/sohl/being-info.mjs +3 -6
  72. package/sohl/document-subtypes.mjs +82 -0
  73. package/sohl/index.mjs +4 -6
  74. package/sohl/item-builders.mjs +1 -3
  75. package/sohl/item-fields.mjs +16 -34
  76. package/sohl/items.mjs +111 -17
  77. package/sohl/kb-passes.mjs +29 -39
  78. package/sohl/note-schemas.mjs +11 -7
  79. package/sohl/skill-base.mjs +7 -23
  80. package/stage.mjs +3 -13
  81. package/templates.mjs +4 -15
  82. package/types/bundle.d.mts +1 -1
  83. package/types/container.d.mts +2 -2
  84. package/types/content-config.d.mts +48 -4
  85. package/types/coverage.d.mts +1 -1
  86. package/types/e2e.d.mts +4 -4
  87. package/types/engine/address-charset.d.mts +45 -0
  88. package/types/engine/alias-index.d.mts +122 -0
  89. package/types/engine/base-compiler.d.mts +132 -4
  90. package/types/engine/content-address.d.mts +2 -2
  91. package/types/engine/content-format-check.d.mts +163 -0
  92. package/types/engine/content-format.d.mts +101 -0
  93. package/types/engine/content-links.d.mts +16 -1
  94. package/types/engine/content-lint.d.mts +6 -0
  95. package/types/engine/diagnostics.d.mts +29 -0
  96. package/types/engine/document-subtypes.d.mts +233 -0
  97. package/types/engine/field-spec.d.mts +76 -23
  98. package/types/engine/frontmatter-lint.d.mts +47 -2
  99. package/types/engine/generate.d.mts +15 -2
  100. package/types/engine/helpers.d.mts +22 -14
  101. package/types/engine/ids.d.mts +10 -0
  102. package/types/engine/index.d.mts +5 -0
  103. package/types/engine/item-registry.d.mts +21 -2
  104. package/types/engine/kb-manifest.d.mts +35 -8
  105. package/types/engine/map-notes.d.mts +21 -11
  106. package/types/engine/note-claims.d.mts +113 -0
  107. package/types/engine/note-vocabulary.d.mts +251 -0
  108. package/types/engine/pack-config.d.mts +4 -3
  109. package/types/engine/pack-router.d.mts +4 -4
  110. package/types/engine/prose-lint.d.mts +6 -2
  111. package/types/engine/retired-fields.d.mts +73 -2
  112. package/types/engine/schema-check.d.mts +182 -0
  113. package/types/engine/schema-extract.d.mts +1 -1
  114. package/types/engine/site-index.d.mts +1 -1
  115. package/types/engine/system-block.d.mts +281 -0
  116. package/types/engine/web-wikilinks.d.mts +23 -12
  117. package/types/engine/wikilink-syntax.d.mts +29 -0
  118. package/types/manifest.d.mts +1 -1
  119. package/types/sohl/actors.d.mts +62 -6
  120. package/types/sohl/document-subtypes.d.mts +14 -0
  121. package/types/sohl/index.d.mts +1 -0
  122. 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]
@@ -59,11 +62,7 @@ import log from "loglevel";
59
62
  import prefix from "loglevel-plugin-prefix";
60
63
  import yargs from "yargs";
61
64
  import { hideBin } from "yargs/helpers";
62
- import {
63
- compilePacks,
64
- cleanPacks,
65
- unpackPacks,
66
- } from "../engine/compendiums.mjs";
65
+ import { compilePacks, cleanPacks, unpackPacks } from "../engine/compendiums.mjs";
67
66
  import { loadPackConfig } from "../engine/pack-config.mjs";
68
67
  import {
69
68
  fetchAllCatalogs,
@@ -73,6 +72,12 @@ import {
73
72
  import { renderItemFieldReference } from "../engine/field-reference.mjs";
74
73
  import { lintContentTree } from "../engine/content-lint.mjs";
75
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";
76
81
  import {
77
82
  compareFields,
78
83
  resolveSchemaArtifact,
@@ -83,9 +88,13 @@ import {
83
88
  // set — an adventure module ships skills, beings and magic swords — so no
84
89
  // consumer gets a subset (#19, #20).
85
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";
86
94
  // The engine's own types, merged under the registry's so the vocabulary stands
87
95
  // in a package that configures no `itemBuilders` at all (#51).
88
96
  import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
97
+ import { NOTE_VOCABULARY } from "../engine/note-vocabulary.mjs";
89
98
  import { checkFormatting, lintMarkdown } from "../engine/prose-lint.mjs";
90
99
  import { emitLinkManifest } from "../engine/manifest-emit.mjs";
91
100
  import {
@@ -93,12 +102,13 @@ import {
93
102
  gatesFailed,
94
103
  formatUnaddressableFinding as formatUnaddressable,
95
104
  } from "../engine/site-build.mjs";
105
+ import { auditLinks, buildLinkIndex, walkReachability } from "../engine/content-links.mjs";
96
106
  import {
97
- auditLinks,
98
- buildLinkIndex,
99
- walkReachability,
100
- } from "../engine/content-links.mjs";
101
- import { emitDiagnostic, positionOfLiteral } from "../engine/diagnostics.mjs";
107
+ emitDiagnostic,
108
+ positionInFrontmatter,
109
+ positionOfLiteral,
110
+ } from "../engine/diagnostics.mjs";
111
+ import { reportFindings } from "./report.mjs";
102
112
  import {
103
113
  readItemAddresses,
104
114
  diffItemAddresses,
@@ -107,6 +117,7 @@ import {
107
117
  addressFindingMessage,
108
118
  } from "../engine/address-diff.mjs";
109
119
  import { itemPackJsonDirs } from "../engine/generate.mjs";
120
+ import { walkMarkdownTree } from "../engine/helpers.mjs";
110
121
  import {
111
122
  formatUnaddressableFinding,
112
123
  unaddressableForeignPackages,
@@ -143,9 +154,7 @@ function configuredPacks() {
143
154
  * @returns {string} The `version` field of this package's manifest.
144
155
  */
145
156
  function ownVersion() {
146
- return JSON.parse(
147
- fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
148
- ).version;
157
+ return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
149
158
  }
150
159
 
151
160
  // Configure loglevel
@@ -176,16 +185,23 @@ prefix.apply(log, {
176
185
  */
177
186
  function reportFailure(err) {
178
187
  const message = err instanceof Error ? err.message : String(err);
179
- if (/** @type {{located?: boolean}} */ (err)?.located)
180
- console.error(message);
188
+ if (/** @type {{located?: boolean}} */ (err)?.located) console.error(message);
181
189
  else log.error(message);
182
190
  }
183
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
+
184
199
  const argv = yargs(hideBin(process.argv))
185
200
  .command(packageCommand())
186
201
  .command(depsCommand())
187
202
  .command(docsCommand())
188
203
  .command(lintCommand())
204
+ .command(contentFormatCommand())
189
205
  .command(linksCommand())
190
206
  .command(formatCommand())
191
207
  .command(markdownCommand())
@@ -245,13 +261,11 @@ function docsCommand() {
245
261
  choices: ["item-fields"],
246
262
  });
247
263
  yargs.option("out", {
248
- describe:
249
- "Write to this file instead of the configured location.",
264
+ describe: "Write to this file instead of the configured location.",
250
265
  type: "string",
251
266
  });
252
267
  yargs.option("check", {
253
- describe:
254
- "Compare against the file already there; write nothing.",
268
+ describe: "Compare against the file already there; write nothing.",
255
269
  type: "boolean",
256
270
  default: false,
257
271
  });
@@ -274,13 +288,10 @@ function docsCommand() {
274
288
  const config = loadPackConfig();
275
289
  const spec = config.docs?.itemFields ?? {};
276
290
  const destination =
277
- argv.out ??
278
- (spec.out ? path.resolve(config.rootDir, spec.out) : null);
291
+ argv.out ?? (spec.out ? path.resolve(config.rootDir, spec.out) : null);
279
292
 
280
293
  const page = `${renderItemFieldReference({
281
- ...((title ?? spec.title) ?
282
- { title: title ?? spec.title }
283
- : {}),
294
+ ...((title ?? spec.title) ? { title: title ?? spec.title } : {}),
284
295
  ...(spec.preamble ? { preamble: spec.preamble } : {}),
285
296
  generatedBy: "`content-build docs item-fields`",
286
297
  config,
@@ -296,9 +307,7 @@ function docsCommand() {
296
307
  }
297
308
  const relative = path.relative(config.rootDir, destination);
298
309
  const current =
299
- fs.existsSync(destination) ?
300
- fs.readFileSync(destination, "utf8")
301
- : "";
310
+ fs.existsSync(destination) ? fs.readFileSync(destination, "utf8") : "";
302
311
  if (current !== page) {
303
312
  // Staleness belongs to the whole file, so no line is
304
313
  // named — the diagnostics contract drops a field it
@@ -321,11 +330,130 @@ function docsCommand() {
321
330
  recursive: true,
322
331
  });
323
332
  fs.writeFileSync(destination, page);
333
+ log.info(`Wrote ${path.relative(config.rootDir, destination)}`);
334
+ } else {
335
+ process.stdout.write(page);
336
+ }
337
+ } catch (err) {
338
+ reportFailure(err);
339
+ process.exitCode = 1;
340
+ }
341
+ },
342
+ };
343
+ }
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)) {
324
444
  log.info(
325
- `Wrote ${path.relative(config.rootDir, destination)}`,
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.`,
326
453
  );
454
+ process.exitCode = 1;
327
455
  } else {
328
- process.stdout.write(page);
456
+ log.info(`${checked} mapping claim(s) confirmed against the supplied schemas.`);
329
457
  }
330
458
  } catch (err) {
331
459
  reportFailure(err);
@@ -335,6 +463,198 @@ function docsCommand() {
335
463
  };
336
464
  }
337
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
+
338
658
  /**
339
659
  * `content-build lint` — check a content tree's addresses.
340
660
  *
@@ -353,8 +673,7 @@ function lintCommand() {
353
673
  describe: "Check a content tree's addresses and frontmatter",
354
674
  builder: (yargs) => {
355
675
  yargs.positional("root", {
356
- describe:
357
- "Content tree to lint. Defaults to the configured contentBase.",
676
+ describe: "Content tree to lint. Defaults to the configured contentBase.",
358
677
  type: "string",
359
678
  });
360
679
  yargs.option("references", {
@@ -389,6 +708,11 @@ function lintCommand() {
389
708
  });
390
709
  const frontmatter = lintFrontmatter(index, {
391
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,
392
716
  references: argv.references,
393
717
  });
394
718
 
@@ -466,12 +790,23 @@ function lintCommand() {
466
790
  ...frontmatter.findings,
467
791
  ...schemaFindings,
468
792
  ];
469
- for (const finding of findings) emitDiagnostic(finding);
470
- if (findings.length) {
471
- log.error(
472
- `${findings.length} finding(s) across ${addresses.notes} note(s).`,
473
- );
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) {
803
+ log.error(`${findings.length} finding(s) across ${addresses.notes} note(s).`);
474
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
+ );
475
810
  } else {
476
811
  log.info(
477
812
  `Addresses and frontmatter are well-formed ` +
@@ -510,19 +845,16 @@ function formatCommand() {
510
845
  describe: "Check formatting with the shared Prettier configuration",
511
846
  builder: (yargs) => {
512
847
  yargs.positional("paths", {
513
- describe:
514
- "Files or directories to check. Defaults to the whole repository.",
848
+ describe: "Files or directories to check. Defaults to the whole repository.",
515
849
  type: "string",
516
850
  });
517
851
  yargs.option("write", {
518
- describe:
519
- "Rewrite unformatted files in place instead of reporting them.",
852
+ describe: "Rewrite unformatted files in place instead of reporting them.",
520
853
  type: "boolean",
521
854
  default: false,
522
855
  });
523
856
  yargs.option("check", {
524
- describe:
525
- "Report unformatted files without rewriting them (the default).",
857
+ describe: "Report unformatted files without rewriting them (the default).",
526
858
  type: "boolean",
527
859
  });
528
860
  },
@@ -532,30 +864,37 @@ function formatCommand() {
532
864
  // it contradicts `--write`; naming both is a mistake worth
533
865
  // saying out loud rather than silently resolving.
534
866
  if (argv.check === true && argv.write) {
535
- log.error(
536
- "--check and --write ask for opposite things; name one.",
537
- );
867
+ log.error("--check and --write ask for opposite things; name one.");
538
868
  process.exitCode = 1;
539
869
  return;
540
870
  }
541
871
  const root = process.cwd();
542
- const { findings, checked, written } = await checkFormatting(
543
- root,
544
- { paths: argv.paths, write: argv.write },
545
- );
872
+ const { findings, checked, written } = await checkFormatting(root, {
873
+ paths: argv.paths,
874
+ write: argv.write,
875
+ });
546
876
  if (argv.write) {
547
877
  log.info(
548
878
  written.length ?
549
879
  `Formatted ${written.length} of ${checked} file(s).`
550
880
  : `Already formatted (${checked} file(s)).`,
551
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
+ }
552
893
  return;
553
894
  }
554
895
  for (const finding of findings) emitDiagnostic(finding);
555
896
  if (findings.length) {
556
- log.error(
557
- `${findings.length} of ${checked} file(s) are not formatted.`,
558
- );
897
+ log.error(`${findings.length} of ${checked} file(s) are not formatted.`);
559
898
  process.exitCode = 1;
560
899
  } else {
561
900
  log.info(`Formatting is clean (${checked} file(s)).`);
@@ -586,8 +925,7 @@ function markdownCommand() {
586
925
  describe: "Lint markdown with the shared markdownlint rule set",
587
926
  builder: (yargs) => {
588
927
  yargs.positional("paths", {
589
- describe:
590
- "Globs to lint. Defaults to every markdown file in the repository.",
928
+ describe: "Globs to lint. Defaults to every markdown file in the repository.",
591
929
  type: "string",
592
930
  });
593
931
  yargs.option("fix", {
@@ -635,8 +973,7 @@ function linksCommand() {
635
973
  describe: "Check that every link in a content tree lands somewhere",
636
974
  builder: (yargs) => {
637
975
  yargs.positional("root", {
638
- describe:
639
- "Content tree to check. Defaults to the configured contentBase.",
976
+ describe: "Content tree to check. Defaults to the configured contentBase.",
640
977
  type: "string",
641
978
  });
642
979
  yargs.option("manifests", {
@@ -665,9 +1002,7 @@ function linksCommand() {
665
1002
  message: `unusable link manifest: ${s.reason}`,
666
1003
  });
667
1004
  }
668
- log.error(
669
- "Refresh the vendored copy from that package's own build.",
670
- );
1005
+ log.error("Refresh the vendored copy from that package's own build.");
671
1006
  process.exitCode = 1;
672
1007
  return;
673
1008
  }
@@ -675,14 +1010,10 @@ function linksCommand() {
675
1010
  // Readable is not the same as addressable: a key shape the
676
1011
  // lookup cannot parse makes every cross-package link miss, and
677
1012
  // the audit then blames the *notes*.
678
- const drifted = unaddressableForeignPackages(
679
- index.foreign.index,
680
- );
1013
+ const drifted = unaddressableForeignPackages(index.foreign.index);
681
1014
  if (drifted.length) {
682
1015
  for (const f of drifted) {
683
- console.error(
684
- formatUnaddressableFinding(f, manifestDir),
685
- );
1016
+ console.error(formatUnaddressableFinding(f, manifestDir));
686
1017
  }
687
1018
  process.exitCode = 1;
688
1019
  return;
@@ -691,6 +1022,8 @@ function linksCommand() {
691
1022
  const {
692
1023
  deadAnchors,
693
1024
  deadAddresses,
1025
+ deadAliases,
1026
+ aliasCollisions,
694
1027
  frontmatterLinks,
695
1028
  homepageLinks,
696
1029
  usedManifest,
@@ -706,14 +1039,62 @@ function linksCommand() {
706
1039
  `heading in ${d.dest.rel} declares`,
707
1040
  });
708
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.
709
1045
  for (const d of deadAddresses) {
710
1046
  emitDiagnostic({
711
1047
  file: d.note.file,
712
1048
  ...positionOfLiteral(d.note.raw, d.text, d.occurrence),
713
1049
  severity: "error",
714
- 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`,
715
1059
  });
716
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`,
1079
+ });
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
+ }
717
1098
  for (const f of frontmatterLinks) {
718
1099
  emitDiagnostic({
719
1100
  file: f.note.file,
@@ -738,23 +1119,32 @@ function linksCommand() {
738
1119
  });
739
1120
  }
740
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.
741
1124
  const failures =
742
1125
  deadAnchors.length +
743
1126
  deadAddresses.length +
1127
+ aliasCollisions.reduce((n, c) => n + c.claimants.length, 0) +
744
1128
  frontmatterLinks.length +
745
1129
  homepageLinks.length;
746
1130
  if (failures) {
747
- log.error(
748
- `${failures} link problem(s) across ${index.notes.length} note(s).`,
749
- );
1131
+ log.error(`${failures} link problem(s) across ${index.notes.length} note(s).`);
750
1132
  process.exitCode = 1;
751
1133
  } else {
752
1134
  log.info(
753
1135
  `${index.notes.length} notes: every anchor link lands ` +
754
- `and every qualified address resolves ` +
1136
+ `and every address resolves ` +
755
1137
  `(${usedManifest.size} cross-package reference(s) ` +
756
- `via manifest), no wikilink in frontmatter, ` +
757
- `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.`,
758
1148
  );
759
1149
  }
760
1150
  } catch (err) {
@@ -789,14 +1179,12 @@ function manifestCommand() {
789
1179
  describe: "Emit this package's cross-package link manifest",
790
1180
  builder: (yargs) => {
791
1181
  yargs.positional("root", {
792
- describe:
793
- "Content tree to read. Defaults to the configured contentBase.",
1182
+ describe: "Content tree to read. Defaults to the configured contentBase.",
794
1183
  type: "string",
795
1184
  });
796
1185
  yargs.option("out", {
797
1186
  describe:
798
- "Directory to write into. Defaults to the configured " +
799
- "`paths.manifestOut`.",
1187
+ "Directory to write into. Defaults to the configured " + "`paths.manifestOut`.",
800
1188
  type: "string",
801
1189
  });
802
1190
  },
@@ -825,10 +1213,7 @@ function manifestCommand() {
825
1213
  // lost its address becomes a dead link in every consumer.
826
1214
  for (const s of skipped) {
827
1215
  emitDiagnostic({
828
- file: path.join(
829
- argv.root ?? config.paths.content,
830
- s.file,
831
- ),
1216
+ file: path.join(argv.root ?? config.paths.content, s.file),
832
1217
  severity: "warning",
833
1218
  message: `no address, so it is absent from the manifest: ${s.reason}`,
834
1219
  });
@@ -864,8 +1249,7 @@ function siteCommand() {
864
1249
  describe: "Build a Hugo content tree from the content tree",
865
1250
  builder: (yargs) => {
866
1251
  yargs.option("out", {
867
- describe:
868
- "Write the mount here instead of the configured `site.out`.",
1252
+ describe: "Write the mount here instead of the configured `site.out`.",
869
1253
  type: "string",
870
1254
  });
871
1255
  },
@@ -903,26 +1287,16 @@ function siteCommand() {
903
1287
  }
904
1288
  for (const s of gates.staleManifests) {
905
1289
  emitDiagnostic({
906
- file: path.join(
907
- loadPackConfig().paths.manifests,
908
- `${s.package}.json`,
909
- ),
1290
+ file: path.join(loadPackConfig().paths.manifests, `${s.package}.json`),
910
1291
  severity: "error",
911
1292
  message: `unusable link manifest: ${s.reason}`,
912
1293
  });
913
1294
  }
914
1295
  for (const f of gates.unaddressable) {
915
- console.error(
916
- formatUnaddressable(
917
- f,
918
- loadPackConfig().paths.manifests,
919
- ),
920
- );
1296
+ console.error(formatUnaddressable(f, loadPackConfig().paths.manifests));
921
1297
  }
922
1298
  for (const c of gates.conflicts) {
923
- log.error(
924
- `address ${c.key} is also published by ${c.package}`,
925
- );
1299
+ log.error(`address ${c.key} is also published by ${c.package}`);
926
1300
  }
927
1301
  if (gatesFailed(gates)) {
928
1302
  process.exitCode = 1;
@@ -937,9 +1311,7 @@ function siteCommand() {
937
1311
  log.error(`bad content table: ${e.reason} (${e.source})`);
938
1312
  }
939
1313
  for (const e of result.wikiErrors) {
940
- log.error(
941
- `bad wikilink [[${e.target}]]: ${e.reason} (${e.file})`,
942
- );
1314
+ log.error(`bad wikilink [[${e.target}]]: ${e.reason} (${e.file})`);
943
1315
  }
944
1316
  if (result.tableErrors.length || result.wikiErrors.length) {
945
1317
  process.exitCode = 1;
@@ -989,8 +1361,7 @@ function reachabilityCommand() {
989
1361
  describe: "Check that every document in a corpus is reachable",
990
1362
  builder: (yargs) => {
991
1363
  yargs.positional("dir", {
992
- describe:
993
- "The corpus directory, relative to the content tree root.",
1364
+ describe: "The corpus directory, relative to the content tree root.",
994
1365
  type: "string",
995
1366
  });
996
1367
  yargs.positional("file", {
@@ -1008,8 +1379,7 @@ function reachabilityCommand() {
1008
1379
  default: [],
1009
1380
  });
1010
1381
  yargs.option("root", {
1011
- describe:
1012
- "Content tree to read. Defaults to the configured contentBase.",
1382
+ describe: "Content tree to read. Defaults to the configured contentBase.",
1013
1383
  type: "string",
1014
1384
  });
1015
1385
  },
@@ -1026,9 +1396,7 @@ function reachabilityCommand() {
1026
1396
  stopAt: (n) => indexes.has(String(n.fm.shortcode)),
1027
1397
  });
1028
1398
 
1029
- const total = index.notes.filter((n) =>
1030
- n.rel.startsWith(`${dir}/`),
1031
- ).length;
1399
+ const total = index.notes.filter((n) => n.rel.startsWith(`${dir}/`)).length;
1032
1400
 
1033
1401
  for (const o of orphans) {
1034
1402
  // Unreachability is a property of the whole document, so
@@ -1052,8 +1420,7 @@ function reachabilityCommand() {
1052
1420
  process.exitCode = 1;
1053
1421
  } else {
1054
1422
  log.info(
1055
- `All ${total} document(s) in ${dir} are reachable ` +
1056
- `from ${argv.file}.`,
1423
+ `All ${total} document(s) in ${dir} are reachable ` + `from ${argv.file}.`,
1057
1424
  );
1058
1425
  }
1059
1426
  } catch (err) {
@@ -1144,8 +1511,7 @@ function depsCommand() {
1144
1511
  return;
1145
1512
  }
1146
1513
  const count = await fetchAllCatalogs(config);
1147
- if (count)
1148
- log.info(`Fetched ${count} dependency catalogue(s).`);
1514
+ if (count) log.info(`Fetched ${count} dependency catalogue(s).`);
1149
1515
  } catch (err) {
1150
1516
  reportFailure(err);
1151
1517
  process.exitCode = 1;
@@ -1192,24 +1558,15 @@ async function diffAddresses(config, argv) {
1192
1558
  ...config,
1193
1559
  paths: {
1194
1560
  ...config.paths,
1195
- foreignCache: path.join(
1196
- path.dirname(config.paths.foreignCache),
1197
- "baseline",
1198
- ),
1561
+ foreignCache: path.join(path.dirname(config.paths.foreignCache), "baseline"),
1199
1562
  },
1200
1563
  };
1201
- const dir = await fetchCatalogFromPath(
1202
- cacheConfig,
1203
- { id: config.foundryPackage },
1204
- argv.from,
1205
- );
1564
+ const dir = await fetchCatalogFromPath(cacheConfig, { id: config.foundryPackage }, argv.from);
1206
1565
  // `<id>@<version>`, which is what the diagnostics name the baseline by.
1207
1566
  const label = path.basename(dir);
1208
1567
 
1209
1568
  const itemsRoot = path.join(dir, "items");
1210
- const baselineDirs = fs
1211
- .readdirSync(itemsRoot)
1212
- .map((name) => path.join(itemsRoot, name));
1569
+ const baselineDirs = fs.readdirSync(itemsRoot).map((name) => path.join(itemsRoot, name));
1213
1570
  const currentDirs = itemPackJsonDirs(config);
1214
1571
  if (!currentDirs.length) {
1215
1572
  throw new Error(
@@ -1254,8 +1611,7 @@ async function diffAddresses(config, argv) {
1254
1611
  function addressesCommand() {
1255
1612
  return {
1256
1613
  command: "addresses <action>",
1257
- describe:
1258
- "Compare the addresses this build publishes against a release's",
1614
+ describe: "Compare the addresses this build publishes against a release's",
1259
1615
  builder: (yargs) => {
1260
1616
  // Required, for the reason every other action is (#57): an
1261
1617
  // optional one exits 0 having compared nothing.