@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
@@ -527,6 +527,23 @@ export type ItemBuilderEntry = ((fm: object) => object) | {
527
527
  img?: string;
528
528
  fields?: readonly object[];
529
529
  };
530
+ /**
531
+ * One **registry** of a declared set, and the system it belongs to (#58).
532
+ *
533
+ * A repository shipping content for two systems declares one of these per
534
+ * system: the accepted type vocabulary is their union, and a type both declare
535
+ * keeps a builder on each side rather than one of them winning in silence.
536
+ */
537
+ export type ItemRegistrySpec = {
538
+ /**
539
+ * The system id whose vocabulary this registry is.
540
+ */
541
+ system: string;
542
+ /**
543
+ * The registry itself.
544
+ */
545
+ builders: Record<string, ItemBuilderEntry>;
546
+ };
530
547
  /**
531
548
  * The configuration a consumer writes.
532
549
  */
@@ -566,9 +583,13 @@ export type ContentBuildConfigInput = {
566
583
  * note of that type gets when it sets no
567
584
  * `img:` of its own. Default `{}` — a
568
585
  * content module that ships no items
569
- * declares none.
586
+ * declares none. A repository feeding
587
+ * two systems declares a **list** of
588
+ * `{ system, builders }` registries
589
+ * instead, and the accepted type
590
+ * vocabulary is their union (#58).
570
591
  */
571
- itemBuilders?: Record<string, ItemBuilderEntry> | undefined;
592
+ itemBuilders?: Record<string, ItemBuilderEntry> | readonly ItemRegistrySpec[] | undefined;
572
593
  /**
573
594
  * Packs to compile. More than one entry
574
595
  * may share a `type`: a note then names
@@ -657,11 +678,34 @@ export type ContentBuildConfig = {
657
678
  * simply undocumented (#22).
658
679
  */
659
680
  itemFields: Readonly<Record<string, readonly object[]>>;
681
+ /**
682
+ * Derived: the same builders, kept per
683
+ * declaring system. `{}` for the single
684
+ * registry form, which names no system
685
+ * (#58).
686
+ */
687
+ itemBuildersBySystem: Readonly<Record<string, Readonly<Record<string, Function>>>>;
688
+ /**
689
+ * Derived: the default art, per system.
690
+ */
691
+ itemArtBySystem: Readonly<Record<string, Readonly<Record<string, string>>>>;
692
+ /**
693
+ * Derived: the declared fields, per system.
694
+ */
695
+ itemFieldsBySystem: Readonly<Record<string, Readonly<Record<string, readonly object[]>>>>;
696
+ /**
697
+ * Derived: the types
698
+ * more than one registry declares — the
699
+ * ones the flat tables cannot answer for
700
+ * without choosing a system for the caller.
701
+ */
702
+ itemTypesBySeveralSystems: ReadonlySet<string>;
660
703
  /**
661
704
  * Derived: the keys of
662
705
  * {@link ContentBuildConfigInput.itemBuilders},
663
- * so the accepted item types and the builder
664
- * table are one list (#1504).
706
+ * unioned across every declared registry, so
707
+ * the accepted item types and the builder
708
+ * tables are one list (#1504).
665
709
  */
666
710
  itemTypes: ReadonlySet<string>;
667
711
  /**
@@ -77,7 +77,7 @@ export function mergeReferences(sets: Iterable<ReferenceSet>): ReferenceSet;
77
77
  * questions: one says the package is broken, the other that it carries
78
78
  * something nobody could see a use for.
79
79
  */
80
- export function analyzeCoverage({ langSource, langFile, references, retained, roots, }: {
80
+ export function analyzeCoverage({ langSource, langFile, references, retained, roots }: {
81
81
  langSource: string;
82
82
  langFile: string;
83
83
  references: ReferenceSet;
package/types/e2e.d.mts CHANGED
@@ -156,7 +156,7 @@ export function parseFastArgs(argv: string[], build: Record<string, {
156
156
  * @param {(message: string) => void} [opts.log] - Progress reporting.
157
157
  * @returns {Promise<{worldDir: string, world: E2EWorld}>} Where it landed.
158
158
  */
159
- export function seedTestWorld({ config, packageJson, env, log, }: {
159
+ export function seedTestWorld({ config, packageJson, env, log }: {
160
160
  config: object;
161
161
  packageJson: object;
162
162
  env?: NodeJS.ProcessEnv | undefined;
@@ -181,7 +181,7 @@ export function seedTestWorld({ config, packageJson, env, log, }: {
181
181
  * @returns {Promise<void>} Resolves once the world is active.
182
182
  * @throws {Error} On a licence failure or a timeout.
183
183
  */
184
- export function waitForWorld({ url, container, stage, timeoutMs, log, }: {
184
+ export function waitForWorld({ url, container, stage, timeoutMs, log }: {
185
185
  url: string;
186
186
  container: string;
187
187
  stage: string;
@@ -204,7 +204,7 @@ export function waitForWorld({ url, container, stage, timeoutMs, log, }: {
204
204
  * @param {(message: string) => void} [opts.log] - Progress reporting.
205
205
  * @returns {number} The suite's exit status.
206
206
  */
207
- export function runSuite({ command, args, cwd, env, log, }: {
207
+ export function runSuite({ command, args, cwd, env, log }: {
208
208
  command: string[];
209
209
  args?: string[] | undefined;
210
210
  cwd: string;
@@ -265,7 +265,7 @@ export function e2eRun({ config, packageJson, mode, suiteArgs, env, log, }: {
265
265
  * @param {(message: string) => void} [opts.log] - Progress reporting.
266
266
  * @returns {Promise<number>} The suite's exit status.
267
267
  */
268
- export function e2eFast({ config, argv, env, log, }: {
268
+ export function e2eFast({ config, argv, env, log }: {
269
269
  config: object;
270
270
  argv?: string[] | undefined;
271
271
  env?: NodeJS.ProcessEnv | undefined;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Whether a value is a well-formed address segment.
3
+ *
4
+ * A blank value is **not** valid: this answers "is this an acceptable segment",
5
+ * never "is a segment present". Presence is a separate question, asked
6
+ * wherever the value is required, and conflating the two would report a missing
7
+ * key as a charset violation.
8
+ *
9
+ * @param {unknown} value - The candidate segment.
10
+ * @returns {boolean} `true` when it matches {@link ADDRESS_SEGMENT_PATTERN}.
11
+ */
12
+ export function isAddressSegment(value: unknown): boolean;
13
+ /**
14
+ * The one charset every segment of a canonical address is held to (#59).
15
+ *
16
+ * An address is a hyphen-joined tuple — `sohl-skill-clmb` — and it is read back
17
+ * by **counting segments**, with a fixed meaning per position. That is sound
18
+ * for exactly one reason: the hyphen is *purely* a separator, because no
19
+ * segment may contain one. Take that away and reading an address needs a
20
+ * vocabulary to match against, a longest-match rule, and an answer for every
21
+ * name that is a prefix of another — none of which exist.
22
+ *
23
+ * So the charset is not a tidiness rule. It is the premise the address grammar
24
+ * rests on, and the issue's word for how it should be held is **enforced rather
25
+ * than assumed**: a value that breaks it is refused where it is written, not
26
+ * discovered later as addresses that fail to parse and report nothing about
27
+ * why. `harn-adventures` was that case — its keys read as four segments and
28
+ * failed as a `null` return.
29
+ *
30
+ * This module is a **leaf with no local imports**, so the validator a
31
+ * consumer's `package-build.config.mjs` reaches (`content-config.mjs`) can name
32
+ * it without closing a cycle around that file.
33
+ *
34
+ * @module
35
+ */
36
+ /**
37
+ * The shape every address segment must match: ASCII letters and digits only.
38
+ *
39
+ * Case is deliberately **not** constrained. Hundreds of authored shortcodes are
40
+ * mixed-case and collide with nothing, and case has no bearing on the
41
+ * separator, which is the whole of what this pattern is protecting.
42
+ *
43
+ * @type {RegExp}
44
+ */
45
+ export const ADDRESS_SEGMENT_PATTERN: RegExp;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The **alias** namespace: what a note can be called, and who may claim a name.
3
+ *
4
+ * A wikilink resolves through one of two namespaces, and the pipe chooses
5
+ * which (#131): `[[x|…]]` is an *address*, parsed by the address grammar;
6
+ * `[[x]]` is an *alias*, looked up here. This module owns the second half —
7
+ * what goes into the index, how a key is spelled, and what happens when two
8
+ * notes claim one name.
9
+ *
10
+ * **An alias is scoped to the claiming note's own type.** The key is
11
+ * `(type, alias)`, so `Shock` may be a `skill` in one place and a `trauma` in
12
+ * another without the two ever meeting. A link resolves against the *source*
13
+ * note's type, which is why a bare name reaches a sibling and never a
14
+ * cross-type target — that one is written as an address.
15
+ *
16
+ * **Three sources, all authored.** `aliases`, `name.aliases`, and `name.full`.
17
+ * Each is something a person wrote down as a name for the note, which is
18
+ * exactly what a bare `[[…]]` cites.
19
+ *
20
+ * **The filename is deliberately not one of them**, and it used to be — every
21
+ * one of the three copies of this index added `basename(file, ".md")` with
22
+ * underscores turned to spaces. That admitted keys no author could ever cite
23
+ * and no author had ever written:
24
+ *
25
+ * - `_Introduction.md` yields the alias `" introduction"`, *with a leading
26
+ * space*. A wikilink target is trimmed, so nothing can ever match it. In one
27
+ * repository thirteen notes — one per documentation section — claimed that
28
+ * key, making it the largest alias collision in the corpus and every one of
29
+ * its claimants blameless.
30
+ * - `README.md` yields `readme`, claimed once per section for the same reason.
31
+ *
32
+ * Since a collision is now a build failure rather than a silent deletion, an
33
+ * index entry that cannot be cited can only ever *cause* one. Removing the
34
+ * source was measured first, across all five content trees: not one link that
35
+ * resolves today resolves through the filename alone, so nothing loses a
36
+ * target — while the collision count falls without a note being edited.
37
+ *
38
+ * @module
39
+ */
40
+ /**
41
+ * Every alias a note claims, in the order the sources are consulted.
42
+ *
43
+ * @param {object} fm - Parsed frontmatter.
44
+ * @returns {string[]} The claimed aliases, each a non-empty string.
45
+ */
46
+ export function aliasesOf(fm: object): string[];
47
+ /**
48
+ * The index key one note's claim on one alias is filed under.
49
+ *
50
+ * Stated here so the three indexes — the pack build's, the site build's and
51
+ * the link checker's — cannot spell it differently. All three already used
52
+ * `type|alias`, lowercased; the risk was never that they disagreed today.
53
+ *
54
+ * @param {string} type - The claiming note's content type.
55
+ * @param {string} alias - The alias, as authored.
56
+ * @returns {string} The key.
57
+ */
58
+ export function aliasKey(type: string, alias: string): string;
59
+ /**
60
+ * One alias claimed by more than one note of a single type.
61
+ *
62
+ * @typedef {object} AliasCollision
63
+ * @property {string} key - The index key, `type|alias`.
64
+ * @property {string} type - The type both claimants share.
65
+ * @property {string} alias - The alias, as the first claimant wrote it.
66
+ * @property {unknown[]} claimants - Every note claiming it, in walk order.
67
+ */
68
+ /**
69
+ * Build the type-scoped alias index, and report every collision in it.
70
+ *
71
+ * **A collision resolves to nothing, and is reported naming every claimant.**
72
+ * Both halves matter. Resolving to whichever note happened to be walked first
73
+ * makes a link silently point at the wrong document, and which one it is
74
+ * depends on directory order. Reporting it at the *citing* note blames a file
75
+ * whose author did nothing wrong — whoever added the second claimant broke
76
+ * every existing citation (#13) — so the claimants are kept rather than
77
+ * discarded along with the entry.
78
+ *
79
+ * @template T
80
+ * @param {Iterable<{type: string, aliases: Iterable<string>, value: T}>} entries
81
+ * One per note: the type that scopes its claims, the aliases it claims, and
82
+ * whatever the caller wants an alias to resolve to.
83
+ * @param {object} [opts]
84
+ * @param {(a: T, b: T) => boolean} [opts.same] - Whether two values are the
85
+ * same note. Defaults to identity; a caller whose values are freshly built
86
+ * records supplies its own.
87
+ * @returns {{byKey: Map<string, T>, claims: Map<string, T[]>,
88
+ * collisions: AliasCollision[]}} `byKey` omits every colliding key, so a
89
+ * lookup in it can never resolve an ambiguous alias.
90
+ */
91
+ export function indexAliases<T>(entries: Iterable<{
92
+ type: string;
93
+ aliases: Iterable<string>;
94
+ value: T;
95
+ }>, { same }?: {
96
+ same?: ((a: T, b: T) => boolean) | undefined;
97
+ }): {
98
+ byKey: Map<string, T>;
99
+ claims: Map<string, T[]>;
100
+ collisions: AliasCollision[];
101
+ };
102
+ /**
103
+ * One alias claimed by more than one note of a single type.
104
+ */
105
+ export type AliasCollision = {
106
+ /**
107
+ * - The index key, `type|alias`.
108
+ */
109
+ key: string;
110
+ /**
111
+ * - The type both claimants share.
112
+ */
113
+ type: string;
114
+ /**
115
+ * - The alias, as the first claimant wrote it.
116
+ */
117
+ alias: string;
118
+ /**
119
+ * - Every note claiming it, in walk order.
120
+ */
121
+ claimants: unknown[];
122
+ };
@@ -11,8 +11,9 @@
11
11
  * @property {number} compiled - Notes that became a document.
12
12
  * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
13
13
  * @property {number} skippedOther - Notes this pass does not claim.
14
- * @property {number} declined - Notes refused because they declare a retired
15
- * frontmatter field. Counted as errors, never as skips.
14
+ * @property {number} declined - Notes this pack **refused** one declaring a
15
+ * retired frontmatter field, or one routed to a system pack whose system it
16
+ * says nothing about (#58). Counted as errors, never as skips.
16
17
  */
17
18
  /**
18
19
  * The shared walk → filter → expand → convert → build → write → count loop.
@@ -79,6 +80,25 @@ export class BasePackCompiler {
79
80
  * @type {readonly string[]}
80
81
  */
81
82
  static readsPackOutputOf: readonly string[];
83
+ /**
84
+ * Whether this pass's document **is** a system's data, and therefore takes
85
+ * only notes that carry that system's block (#58).
86
+ *
87
+ * A pack may declare a `system:` — `harn-ensemble` ships an `actors-hm3`
88
+ * and an `actors-sohl` from one tree — and the note-side half of that is
89
+ * the block named after the system. A note carrying no such block has
90
+ * nothing to say about it, so compiling it there would emit a **hollow
91
+ * document**: a subtype, and none of the fields the subtype exists for.
92
+ *
93
+ * False by default, because most passes write documents that are not
94
+ * system data at all. A JournalEntry of prose is the same document under
95
+ * either system, and a journals pack that declared one must not turn every
96
+ * doc note in the tree into a finding. The Item and Actor passes say so;
97
+ * anything else that genuinely writes a system's data says so too.
98
+ *
99
+ * @type {boolean}
100
+ */
101
+ static requiresSystemBlock: boolean;
82
102
  /**
83
103
  * @param {object} options
84
104
  * @param {string} options.contentBase - Root of the content tree.
@@ -111,6 +131,21 @@ export class BasePackCompiler {
111
131
  folderResolver: (path: string | null) => string | null;
112
132
  /** @type {number} */
113
133
  errorCount: number;
134
+ /**
135
+ * Emitted-`system` findings, one per `documentType|subtype|field` (#155).
136
+ *
137
+ * A key the compiler writes is on **every** document of a subtype, so
138
+ * reporting it where it is found would print the same sentence 3,126 times
139
+ * and bury the one that is not systemic. Collected here instead and flushed
140
+ * once at the end of the pass, keyed so the class of defect is reported
141
+ * once and the first document carrying it names a file a reader can open.
142
+ *
143
+ * @type {Map<string, {message: string, file: string|undefined}>}
144
+ */
145
+ emittedFindings: Map<string, {
146
+ message: string;
147
+ file: string | undefined;
148
+ }>;
114
149
  /**
115
150
  * The pack this pass writes, and the Foundry document type it holds.
116
151
  *
@@ -178,6 +213,22 @@ export class BasePackCompiler {
178
213
  * routes to no pack at all — a build failure, never a silent drop.
179
214
  */
180
215
  routesHere(fm: object): boolean;
216
+ /**
217
+ * Whether a claimed, routed note may become this pack's document at all.
218
+ *
219
+ * The pack-eligibility gate, and it fails rather than skipping: a note that
220
+ * routed *here* and carries nothing for this pack's system is an authoring
221
+ * mistake with a hollow document at the end of it, not a note that belongs
222
+ * to another pass. Skipping it quietly is how a whole tree compiles to
223
+ * documents nobody can use — the failure mode #1502 and #56 are both
224
+ * instances of.
225
+ *
226
+ * @param {object} fm - The note's frontmatter.
227
+ * @returns {boolean} True when the note may be compiled here.
228
+ * @throws {Error} When this pack's system is absent from the note. The
229
+ * error carries a `position` where the note's own file can be read.
230
+ */
231
+ eligibleFor(fm: object): boolean;
181
232
  /**
182
233
  * Whether this pass claims a note. **Required.**
183
234
  *
@@ -265,6 +316,82 @@ export class BasePackCompiler {
265
316
  line?: number;
266
317
  column?: number;
267
318
  }): void;
319
+ /**
320
+ * Report every `<system>.system` key the receiving subtype does not declare
321
+ * (#58).
322
+ *
323
+ * An **error**, not a warning: Foundry drops an unknown `system` key at
324
+ * construction without a word, so the alternative is a document shipped
325
+ * with a field the author wrote and nobody will ever see. Each finding is
326
+ * located at the offending key where the file can be read, so it points at
327
+ * a line rather than at a note.
328
+ *
329
+ * Silent where nothing can answer — no published schema, or a subtype the
330
+ * artifact does not name. `content-build lint` says that out loud once for
331
+ * the whole build rather than once per note.
332
+ *
333
+ * @param {object} fm - The note's frontmatter.
334
+ * @param {string} block - The system block to read.
335
+ * @param {string} documentType - `Item`, `Actor`, …
336
+ * @param {string} subType - The subtype this note compiles into.
337
+ * @returns {number} How many findings were reported.
338
+ */
339
+ reportUndeclaredSystemData(fm: object, block: string, documentType: string, subType: string): number;
340
+ /**
341
+ * Record every `system` key the *compiled document* carries that the
342
+ * receiving subtype does not declare (#155).
343
+ *
344
+ * The sibling of {@link BasePackCompiler#reportUndeclaredSystemData}, and
345
+ * the half that sees what no declaration states. A compiler writes keys of
346
+ * its own alongside the declared fields — `shortcode`, `actionDefs`,
347
+ * `notes`, `docHtml`, `archetype` — and neither the field-declaration check
348
+ * nor the authored-`system` check can see them, so until this nothing
349
+ * compared them at all. Foundry's discard is the same silent one either
350
+ * way.
351
+ *
352
+ * Called with the block **after** the builder, the authored merge and any
353
+ * conditional fields have all written into it, so what is checked is what
354
+ * the pack file receives.
355
+ *
356
+ * Recorded rather than reported: see {@link BasePackCompiler#emittedFindings}
357
+ * for why, and {@link BasePackCompiler#reportEmittedFindings} for where they
358
+ * come out.
359
+ *
360
+ * @param {object} system - The `system` block just assembled.
361
+ * @param {object} opts
362
+ * @param {object} opts.fm - The note's frontmatter.
363
+ * @param {string} opts.block - The system block the note writes.
364
+ * @param {string} opts.documentType - `Item`, `Actor`, …
365
+ * @param {string} opts.subType - The subtype this note compiles into.
366
+ * @param {string} opts.type - The note's content type.
367
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
368
+ * declaration, which tells a builder emission from a compiler one.
369
+ * @param {object} [opts.config] - The resolved build configuration.
370
+ * @returns {number} How many findings were new to this pass.
371
+ */
372
+ reportEmittedSystemData(system: object, { fm, block, documentType, subType, type, fields, config }: {
373
+ fm: object;
374
+ block: string;
375
+ documentType: string;
376
+ subType: string;
377
+ type: string;
378
+ fields?: readonly {
379
+ to?: string;
380
+ }[] | undefined;
381
+ config?: object | undefined;
382
+ }): number;
383
+ /**
384
+ * Emit the collected emitted-`system` findings, once each.
385
+ *
386
+ * An **error**, for the reason #60 made its sibling one: the value is gone
387
+ * at load and the build says nothing, and severity that varied by *which
388
+ * part of the build wrote the key* would make the less fixable half the
389
+ * quieter one. What varies is the message, which says whose fix it is —
390
+ * see {@link module:engine/schema-check.emittedUndeclaredMessage}.
391
+ *
392
+ * @returns {number} How many were reported.
393
+ */
394
+ reportEmittedFindings(): number;
268
395
  /**
269
396
  * One note → one document. **Required.**
270
397
  *
@@ -367,8 +494,9 @@ export type PassStats = {
367
494
  */
368
495
  skippedOther: number;
369
496
  /**
370
- * - Notes refused because they declare a retired
371
- * frontmatter field. Counted as errors, never as skips.
497
+ * - Notes this pack **refused** one declaring a
498
+ * retired frontmatter field, or one routed to a system pack whose system it
499
+ * says nothing about (#58). Counted as errors, never as skips.
372
500
  */
373
501
  declined: number;
374
502
  };
@@ -2,11 +2,11 @@
2
2
  * The URL section a note routes to.
3
3
  *
4
4
  * A `doc` is narrative content whose only identity is its subtype label, so it
5
- * routes by `category`; every other type names its own section.
5
+ * routes by `subType`; every other type names its own section.
6
6
  *
7
7
  * @param {object} fm - Parsed frontmatter.
8
8
  * @returns {string|undefined} The section, or `undefined` when the note has
9
- * none — a `doc` with no category has no address and is not published.
9
+ * none — a `doc` with no subtype has no address and is not published.
10
10
  */
11
11
  export function sectionOf(fm: object): string | undefined;
12
12
  /**
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Every field path any subtype of a published schema declares.
3
+ *
4
+ * `own` and `inherited` are collapsed here, unlike
5
+ * {@link module:engine/schema-check}, and for the same reason that module keeps
6
+ * them apart: it asks two questions of one artifact and only one of them wants
7
+ * the inherited set. This asks the single question "is this field defined
8
+ * anywhere in the system", for which an inherited field is defined.
9
+ *
10
+ * @param {object} artifact - A `version: 1` schema artifact.
11
+ * @returns {Set<string>} The paths, `system.` prefix stripped as the artifact
12
+ * stores them.
13
+ */
14
+ export function declaredPaths(artifact: object): Set<string>;
15
+ /**
16
+ * What an author is told about a target no schema declares.
17
+ *
18
+ * Names the version, because a field may be perfectly well defined on the
19
+ * system's `main` and simply unreleased — the same distinction
20
+ * {@link undeclaredMessage} draws, and the difference between "the
21
+ * specification is wrong" and "the schema has not caught up".
22
+ *
23
+ * @param {object} finding - `{system, systemVersion, noteType, source, target}`.
24
+ * @returns {string} The message.
25
+ */
26
+ export function undeclaredTargetMessage({ system, systemVersion, noteType, source, target }: object): string;
27
+ /**
28
+ * Check every `system.*` target the specification names.
29
+ *
30
+ * @param {object} opts
31
+ * @param {import("./content-format.mjs").ContentFormat} opts.format - The
32
+ * parsed specification.
33
+ * @param {Record<string, object>} opts.schemas - System id → its published
34
+ * `version: 1` schema artifact. A system absent from this map is counted
35
+ * `unchecked` rather than passed.
36
+ * @returns {{findings: object[], checked: number, unchecked: Record<string, number>}}
37
+ * Findings ready for `emitDiagnostic`, how many claims were resolved, and how
38
+ * many were left unresolved per system.
39
+ */
40
+ export function checkSchemaTargets({ format, schemas }: {
41
+ format: import("./content-format.mjs").ContentFormat;
42
+ schemas: Record<string, object>;
43
+ }): {
44
+ findings: object[];
45
+ checked: number;
46
+ unchecked: Record<string, number>;
47
+ };
48
+ /**
49
+ * The systems the specification names.
50
+ *
51
+ * Taken from the columns of its mapping tables rather than from a list written
52
+ * here, so a system added to the document is measured with no code change.
53
+ * Cached per parsed document, since the answer is the same for every note.
54
+ *
55
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
56
+ * specification.
57
+ * @returns {ReadonlySet<string>} The system ids.
58
+ */
59
+ export function systemsNamed(format: import("./content-format.mjs").ContentFormat): ReadonlySet<string>;
60
+ /**
61
+ * Measure one note against the vocabulary the format declares for its type.
62
+ *
63
+ * @param {object} note - `{file, raw, fm}` — as the link index hands one over.
64
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
65
+ * specification.
66
+ * @param {object} [opts]
67
+ * @param {"warning"|"error"} [opts.severity="warning"] - What the findings are.
68
+ * A report by default; `--strict` raises it.
69
+ * @returns {object[]} Findings, each carrying the `class` it belongs to
70
+ * alongside the fields `emitDiagnostic` reads.
71
+ */
72
+ export function measureNote(note: object, format: import("./content-format.mjs").ContentFormat, { severity }?: {
73
+ severity?: "error" | "warning" | undefined;
74
+ }): object[];
75
+ /**
76
+ * Measure a corpus, and count what it finds by class.
77
+ *
78
+ * The counts are the point as much as the findings: #127 promotes a class to
79
+ * fatal when its count reaches zero, so a run that prints them is the epic's
80
+ * progress bar.
81
+ *
82
+ * @param {Iterable<object>} notes - `{file, raw, fm}` for each authored note.
83
+ * @param {import("./content-format.mjs").ContentFormat} format - The parsed
84
+ * specification.
85
+ * @param {object} [opts]
86
+ * @param {boolean} [opts.strict=false] - Report the findings as errors rather
87
+ * than warnings. #127 turns this on one slice at a time.
88
+ * @returns {{findings: object[], notes: number, byClass: Record<string, number>}}
89
+ */
90
+ export function measureCorpus(notes: Iterable<object>, format: import("./content-format.mjs").ContentFormat, { strict }?: {
91
+ strict?: boolean | undefined;
92
+ }): {
93
+ findings: object[];
94
+ notes: number;
95
+ byClass: Record<string, number>;
96
+ };
97
+ /**
98
+ * What an author is told when the specification and the declaration disagree.
99
+ *
100
+ * @param {object} finding - `{noteType, source, target, name, to}`.
101
+ * @returns {string} The message.
102
+ */
103
+ export function fieldDriftMessage({ noteType, source, target, name, to }: object): string;
104
+ /**
105
+ * Check the specification's per-type tables against the field declarations that
106
+ * compile them (#136).
107
+ *
108
+ * @param {object} opts
109
+ * @param {import("./content-format.mjs").ContentFormat} opts.format - The
110
+ * parsed specification.
111
+ * @param {Record<string, readonly object[]>} opts.itemFields - Item type → its
112
+ * `fields` declaration, as `itemBuilders` carries it.
113
+ * @param {string} opts.system - Which system column of the mapping tables these
114
+ * declarations compile. Supplied by the caller rather than assumed here: the
115
+ * declarations belong to one system, and the document maps onto several.
116
+ * @param {"warning"|"error"} [opts.severity="error"] - What a contradiction is.
117
+ * @returns {{findings: object[], coverage: object[], checked: string[],
118
+ * skipped: {spec: string[], registry: string[]}, fields: number}}
119
+ * Contradictions ready for `emitDiagnostic`, the per-type coverage, the types
120
+ * compared, the types out of reach on each side, and how many field pairs
121
+ * were compared.
122
+ */
123
+ export function checkDeclaredFields({ format, itemFields, system, severity }: {
124
+ format: import("./content-format.mjs").ContentFormat;
125
+ itemFields: Record<string, readonly object[]>;
126
+ system: string;
127
+ severity?: "error" | "warning" | undefined;
128
+ }): {
129
+ findings: object[];
130
+ coverage: object[];
131
+ checked: string[];
132
+ skipped: {
133
+ spec: string[];
134
+ registry: string[];
135
+ };
136
+ fields: number;
137
+ };
138
+ /**
139
+ * Top-level keys the format names as the note's own, whatever its type.
140
+ *
141
+ * Top level is otherwise **open** — an unrecognised key there is a Hugo or
142
+ * theme parameter this build has no standing to refuse — so this set exists
143
+ * only to keep a note's own identity fields from being mistaken for a
144
+ * misplaced `data` property where the two share a name. `type` and `subType`
145
+ * are the pair that matters: several mapping tables name `subType` as a shared
146
+ * source, and it is authored at top level by design.
147
+ *
148
+ * @type {ReadonlySet<string>}
149
+ */
150
+ export const NOTE_LEVEL_KEYS: ReadonlySet<string>;
151
+ /**
152
+ * Where one declared field sends one authored value.
153
+ */
154
+ export type FieldPair = {
155
+ /**
156
+ * - The authored key, as the declaration spells it.
157
+ */
158
+ name: string;
159
+ /**
160
+ * - The emitted path, `system.` prefix stripped.
161
+ */
162
+ to: string;
163
+ };