@heroiclands/package-build 9.0.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 (78) hide show
  1. package/CHANGELOG.md +694 -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 +343 -27
  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
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The declared tags a note of this type may carry, flattened.
3
+ *
4
+ * @param {string} type - The note's type.
5
+ * @param {object} [groups] - The grouped declaration.
6
+ * @returns {readonly string[]} The tags, in declaration order.
7
+ */
8
+ export function declaredTags(type: string, groups?: object): readonly string[];
9
+ /**
10
+ * The `data:` keys a note type may carry.
11
+ *
12
+ * @param {string} type - The note's `type`.
13
+ * @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
14
+ * to read, defaulting to {@link NOTE_VOCABULARY}.
15
+ * @returns {readonly DataFieldSpec[]|undefined} The declaration, or `undefined`
16
+ * when the type declares none — which is not the same as declaring an empty
17
+ * one, and is why the lint makes no claim rather than refusing every key.
18
+ */
19
+ export function dataFields(type: string, vocabulary?: Readonly<Record<string, TypeVocabulary>>): readonly DataFieldSpec[] | undefined;
20
+ /**
21
+ * The `subType` values a note type declares.
22
+ *
23
+ * @param {string} type - The note's `type`.
24
+ * @param {Readonly<Record<string, TypeVocabulary>>} [vocabulary] - The registry
25
+ * to read, defaulting to {@link NOTE_VOCABULARY}.
26
+ * @returns {readonly string[]|null|undefined} The closed set; `null` when the
27
+ * type has a `subType` whose values are not yet enumerated; `undefined` when
28
+ * it has no `subType` at all — see {@link TypeVocabulary}.
29
+ */
30
+ export function subTypes(type: string, vocabulary?: Readonly<Record<string, TypeVocabulary>>): readonly string[] | null | undefined;
31
+ /**
32
+ * Every note type this toolchain compiles, and the closed vocabulary it
33
+ * declares.
34
+ *
35
+ * Taken from the content-format specification, one `### type:` section per
36
+ * entry. Where the specification and the shape notes are authored in today
37
+ * disagree, the specification wins on the **name** — that is what a `data:`
38
+ * key will be called — and the disagreement is recorded on the field rather
39
+ * than resolved silently.
40
+ *
41
+ * @type {Readonly<Record<string, TypeVocabulary>>}
42
+ */
43
+ /**
44
+ * The tags that **classify** a note, grouped by what they classify (#172).
45
+ *
46
+ * `tags:` lives at the open top level and most tags belong there: a theme, a
47
+ * region, a working state is the author's own and this build has no opinion
48
+ * about it. A classifying tag is different, because something queries it — a
49
+ * settlement tagged `village` appears in the list of villages and an untagged
50
+ * one does not, so `vilage` does not merely look wrong, it removes the note from
51
+ * an index while the index still renders a table that looks complete.
52
+ *
53
+ * **This list is not a closed set.** An unrecognised tag is legal, because the
54
+ * region is open; what is reported is a **near miss** — a tag close enough to a
55
+ * declared one to be a typo of it.
56
+ *
57
+ * **Each group names the types it applies to**, and that scope is what makes the
58
+ * check sound rather than noisy. A place's kinds are only a place's: `azravan`
59
+ * on a faith, `barter` on an economy note and `secret` on three lore notes all
60
+ * sit within a typo's distance of `caravan`, `border` and `sacred`, and not one
61
+ * is a mistake. Checked against every group at once the rule was wrong on every
62
+ * note it touched; scoped to the type it is wrong on none. `types: null` is a
63
+ * group any note may carry.
64
+ *
65
+ * Kind and character are separate groups because one slot could not hold both: a
66
+ * fishing village is a `village` that is `fishing`, and the single-valued field
67
+ * this replaced had to spell it `Fishing Village` as a value of its own.
68
+ */
69
+ export const DECLARED_TAGS: Readonly<{
70
+ /** What a place *is*. */
71
+ placeKind: Readonly<{
72
+ types: string[];
73
+ tags: readonly string[];
74
+ }>;
75
+ /** What a place is known for. */
76
+ placeCharacter: Readonly<{
77
+ types: string[];
78
+ tags: readonly string[];
79
+ }>;
80
+ /** A place's scale, where the subtype does not distinguish it. */
81
+ placeScale: Readonly<{
82
+ types: string[];
83
+ tags: readonly string[];
84
+ }>;
85
+ /** Which kind of body a being belongs to — a station rather than a rank. */
86
+ beingStation: Readonly<{
87
+ types: string[];
88
+ tags: readonly string[];
89
+ }>;
90
+ /** A note's working state, which any note may carry. */
91
+ state: Readonly<{
92
+ types: null;
93
+ tags: readonly string[];
94
+ }>;
95
+ }>;
96
+ export const NOTE_VOCABULARY: Readonly<{
97
+ being: Readonly<{
98
+ subTypes: null;
99
+ data: readonly DataFieldSpec[];
100
+ }>;
101
+ affiliation: Readonly<{
102
+ subTypes: readonly string[];
103
+ data: readonly DataFieldSpec[];
104
+ }>;
105
+ affliction: Readonly<{
106
+ subTypes: readonly string[];
107
+ data: readonly DataFieldSpec[];
108
+ }>;
109
+ armorgear: Readonly<{
110
+ data: readonly DataFieldSpec[];
111
+ }>;
112
+ attribute: Readonly<{
113
+ data: readonly DataFieldSpec[];
114
+ }>;
115
+ concoctiongear: Readonly<{
116
+ subTypes: readonly string[];
117
+ data: readonly (DataFieldSpec | Readonly<{
118
+ describe: "How many of the thing there are; one when unstated.";
119
+ shape: "number";
120
+ kind: "number";
121
+ name: "quantity";
122
+ }>)[];
123
+ }>;
124
+ containergear: Readonly<{
125
+ data: readonly DataFieldSpec[];
126
+ }>;
127
+ miscgear: Readonly<{
128
+ data: readonly (DataFieldSpec | Readonly<{
129
+ describe: "How many of the thing there are; one when unstated.";
130
+ shape: "number";
131
+ kind: "number";
132
+ name: "quantity";
133
+ }>)[];
134
+ }>;
135
+ mystery: Readonly<{
136
+ subTypes: readonly string[];
137
+ data: readonly DataFieldSpec[];
138
+ }>;
139
+ mysticalability: Readonly<{
140
+ subTypes: readonly string[];
141
+ data: readonly DataFieldSpec[];
142
+ }>;
143
+ projectilegear: Readonly<{
144
+ subTypes: readonly string[];
145
+ data: readonly (DataFieldSpec | Readonly<{
146
+ describe: "How many of the thing there are; one when unstated.";
147
+ shape: "number";
148
+ kind: "number";
149
+ name: "quantity";
150
+ }>)[];
151
+ }>;
152
+ skill: Readonly<{
153
+ subTypes: readonly string[];
154
+ data: readonly DataFieldSpec[];
155
+ }>;
156
+ trauma: Readonly<{
157
+ subTypes: readonly string[];
158
+ data: readonly DataFieldSpec[];
159
+ }>;
160
+ weapongear: Readonly<{
161
+ data: readonly DataFieldSpec[];
162
+ }>;
163
+ doc: Readonly<{
164
+ subTypes: readonly string[];
165
+ data: readonly never[];
166
+ }>;
167
+ macro: Readonly<{
168
+ data: readonly never[];
169
+ }>;
170
+ homepage: Readonly<{
171
+ data: readonly never[];
172
+ }>;
173
+ map: Readonly<{
174
+ subTypes: readonly string[];
175
+ data: readonly ({
176
+ describe: string;
177
+ shape: "string";
178
+ kind: "string";
179
+ name: string;
180
+ } | {
181
+ describe: string;
182
+ shape: "list";
183
+ kind: "list";
184
+ name: string;
185
+ } | {
186
+ describe: string;
187
+ shape: "number";
188
+ kind: "number";
189
+ name: string;
190
+ } | {
191
+ describe: string;
192
+ shape: "as authored";
193
+ name: string;
194
+ })[];
195
+ }>;
196
+ }>;
197
+ /**
198
+ * One `data:` key a note type may carry.
199
+ *
200
+ * A deliberate subset of {@link import ("./field-spec.mjs").FieldSpec}: no `to`,
201
+ * because nothing here builds anything yet. Reading `data.*` through into a
202
+ * document's `system` block is the passthrough slice (#126), and claiming an
203
+ * emitted path this does not produce would be a lie in the one place a reader
204
+ * would trust it.
205
+ */
206
+ export type DataFieldSpec = {
207
+ /**
208
+ * - The key under `data:`, dotted for a nested one
209
+ * (`charges.value`).
210
+ */
211
+ name: string;
212
+ /**
213
+ * - The value's
214
+ * shape, for the lint. Absent means no claim is made about the value — which
215
+ * is the honest answer wherever the specification's stated shape and the
216
+ * shape notes are authored in today disagree.
217
+ */
218
+ kind?: "string" | "number" | "boolean" | "map" | "list" | undefined;
219
+ /**
220
+ * - Human-readable shape, for a finding and for
221
+ * documentation.
222
+ */
223
+ shape?: string | undefined;
224
+ /**
225
+ * - One line, for the author-facing reference.
226
+ */
227
+ describe: string;
228
+ };
229
+ /**
230
+ * What one note type declares.
231
+ *
232
+ * `subTypes` is three-valued, and the difference matters:
233
+ *
234
+ * - **omitted** — the type has no `subType` at all, and a note carrying one is
235
+ * a finding. A `weapon` is the deliberate case: SoHL distinguishes a
236
+ * weapon's uses by strike mode rather than by kind.
237
+ * - **`null`** — the type has a `subType` whose values the specification does
238
+ * not yet enumerate. Presence is permitted and the value is unchecked.
239
+ * - **a list** — the closed set of values, and anything else is a finding.
240
+ */
241
+ export type TypeVocabulary = {
242
+ /**
243
+ * - The `data:` keys, closed.
244
+ */
245
+ data: readonly DataFieldSpec[];
246
+ /**
247
+ * - The top-level `subType`
248
+ * values, as above.
249
+ */
250
+ subTypes?: readonly string[] | null | undefined;
251
+ };
@@ -46,9 +46,10 @@ export function locateConfigError(err: unknown, configPath?: string): unknown;
46
46
  * - **`rootDir`** is the configuration's own directory, always. A data file
47
47
  * cannot write `import.meta.dirname`, and any absolute path it wrote instead
48
48
  * would be one machine's — so authoring it is rejected rather than honoured.
49
- * - **`itemBuilders`** is a *name* (`sohl`), resolved against the built-in
50
- * registries. A registry of a consumer's own is code, and code goes in an
51
- * `.mjs` configuration.
49
+ * - **`itemBuilders`** is a *name* (`sohl`) — or a list of names, for a tree
50
+ * feeding more than one system (#58) resolved against the built-in
51
+ * registries. A registry's name is the system it belongs to. A registry of a
52
+ * consumer's own is code, and code goes in an `.mjs` configuration.
52
53
  * - **`stats.systemVersion`** is derived from the adjacent `package.json` when
53
54
  * the configuration does not state it. Stating it is still allowed: a
54
55
  * repository shipping content *for* another package (a module declaring
@@ -6,14 +6,14 @@
6
6
  *
7
7
  * @param {readonly object[]} packs - The resolved `packs` list from
8
8
  * `defineConfig`.
9
- * @returns {{resolve: (fm: object, docType: string) => string,
10
- * resolveOrNull: (fm: object, docType: string) => string|undefined,
9
+ * @returns {{resolve: (fm: object, docType: string, system?: string) => string,
10
+ * resolveOrNull: (fm: object, docType: string, system?: string) => string|undefined,
11
11
  * packsOfType: (docType: string) => string[],
12
12
  * defaultOf: (docType: string) => string|undefined}} The router.
13
13
  */
14
14
  export function createPackRouter(packs: readonly object[]): {
15
- resolve: (fm: object, docType: string) => string;
16
- resolveOrNull: (fm: object, docType: string) => string | undefined;
15
+ resolve: (fm: object, docType: string, system?: string) => string;
16
+ resolveOrNull: (fm: object, docType: string, system?: string) => string | undefined;
17
17
  packsOfType: (docType: string) => string[];
18
18
  defaultOf: (docType: string) => string | undefined;
19
19
  };
@@ -12,11 +12,15 @@
12
12
  * @param {readonly string[]} [opts.paths] - Files or directories to check
13
13
  * instead of the whole root.
14
14
  * @param {boolean} [opts.write=false] - Rewrite unformatted files in place
15
- * rather than reporting them.
15
+ * rather than reporting them. Each file is formatted to a fixpoint (up to
16
+ * {@link MAX_FORMAT_PASSES} passes), so a written tree is one a second run
17
+ * leaves alone; a file that will not converge is reported and left unchanged
18
+ * (#125).
16
19
  * @param {object} [opts.prettier] - The Prettier module, for tests.
17
20
  * @returns {Promise<{findings: Array<{file: string, severity: string,
18
21
  * message: string}>, checked: number, written: string[]}>} The findings, how
19
- * many files were considered, and what was rewritten.
22
+ * many files were considered, and what was rewritten. `--write` reports
23
+ * findings too — a file it cannot parse, or cannot format to a fixpoint.
20
24
  */
21
25
  export function checkFormatting(root: string, opts?: {
22
26
  paths?: readonly string[] | undefined;
@@ -43,12 +43,83 @@ export function assertNoDraftField(fm: object | null | undefined, { file, absPat
43
43
  * both refusals need it and a second copy is a second thing to keep correct.
44
44
  *
45
45
  * @param {string|undefined} absPath - The note's file.
46
- * @param {string} key - The top-level frontmatter key.
46
+ * @param {string} key - The frontmatter key.
47
+ * @param {string} [value] - When given, prefer the occurrence whose line also
48
+ * carries this text — so a finding about one entry of a block opens on that
49
+ * entry rather than on the key that introduces it.
47
50
  * @returns {{line?: number, column?: number}|undefined} Spreadable position
48
51
  * fields, dropped rather than guessed when the file cannot be read or the key
49
52
  * cannot be found — as `formatDiagnostic` requires.
50
53
  */
51
- export function locateFrontmatterKey(absPath: string | undefined, key: string): {
54
+ export function locateFrontmatterKey(absPath: string | undefined, key: string, value?: string): {
52
55
  line?: number;
53
56
  column?: number;
54
57
  } | undefined;
58
+ /**
59
+ * What a note writing a renamed field is told, in one place.
60
+ *
61
+ * Shared by the compile-time report and the frontmatter lint, because an author
62
+ * meets whichever runs first and they should read the same. It names the key to
63
+ * write rather than a value to correct — no value makes the retired spelling
64
+ * right — and it says the note compiles either way, so a reader knows this is a
65
+ * rename to schedule rather than a build to unbreak.
66
+ *
67
+ * @param {string} retired - The spelling the note used.
68
+ * @param {string} current - What to write instead.
69
+ * @param {string} [file] - The note's path, named in the message. Omit it where
70
+ * the caller emits through a diagnostic, whose locator already starts the
71
+ * line — repeating it prints the path twice.
72
+ * @returns {string} The message, unpunctuated at the end as a finding is.
73
+ */
74
+ export function retiredAliasMessage(retired: string, current: string, file?: string): string;
75
+ /**
76
+ * Whether a note writes the retired spelling of a field, wherever it put it.
77
+ *
78
+ * Both regions are searched, because {@link sohlField} reads both: a note that
79
+ * moved the key to the top level without renaming it has done half the
80
+ * migration, and should be told so rather than passing in silence.
81
+ *
82
+ * @param {object|null|undefined} fm - Parsed frontmatter.
83
+ * @param {string} current - The field's current name.
84
+ * @returns {boolean} Whether the retired spelling is declared.
85
+ */
86
+ export function declaresRetiredAlias(fm: object | null | undefined, current: string): boolean;
87
+ /**
88
+ * Read a field that has a retired spelling, the current name winning.
89
+ *
90
+ * This is the whole of the retirement window's behaviour, in one function, so
91
+ * the compiler and the linter cannot disagree about which value a note carries.
92
+ * Resolution within each spelling is {@link sohlField}'s — the `sohl:` block
93
+ * first, then the note's top level — so a renamed field keeps working wherever
94
+ * it was already written while the canonical home is the top level.
95
+ *
96
+ * A blank value counts as absent: `img:` cleared in an editor means the note
97
+ * names no art there, and falling through to the retired spelling is what an
98
+ * author part-way through the rename means by it.
99
+ *
100
+ * @param {object|null|undefined} fm - Parsed frontmatter.
101
+ * @param {string} current - The field's current name.
102
+ * @returns {any} The value, or `undefined` when neither spelling carries one.
103
+ */
104
+ export function readAliasedField(fm: object | null | undefined, current: string): any;
105
+ /**
106
+ * The current field name a retired spelling was renamed to.
107
+ *
108
+ * Keyed by the **current** name, because that is what a type's schema declares
109
+ * and what every reader asks for; the value is the spelling still honoured.
110
+ * The table is therefore scoped by the schema without saying so twice: an alias
111
+ * applies to a note only where that note's type declares the current field, so
112
+ * `image` is retired on a map — which declares `img` — and remains an unknown
113
+ * key anywhere else.
114
+ *
115
+ * **`img` (#142).** Every note type names its artwork `img`, at the note's top
116
+ * level, and resolves it the same way. A map alone named its background art
117
+ * `image` and read it out of the `sohl:` block — two spellings for one idea,
118
+ * with nothing to reconcile them, and a specification that had to hedge rather
119
+ * than state a rule. Art is not system-specific: a Scene is a core Foundry
120
+ * document and HM3 would want the identical one, so the field belongs beside
121
+ * every other note's `img`, not inside a system block.
122
+ *
123
+ * @type {Readonly<Record<string, string>>}
124
+ */
125
+ export const RETIRED_FIELD_ALIASES: Readonly<Record<string, string>>;
@@ -85,6 +85,56 @@ export function compareFields({ builders, artifact, documentType, subtypeOf, }:
85
85
  unemitted: object[];
86
86
  skipped: string[];
87
87
  };
88
+ /**
89
+ * What a **compiled document** carries in `system`, against what the receiving
90
+ * subtype declares (#155).
91
+ *
92
+ * The third of the three checks, and the only one whose emitted set is
93
+ * *observed*. {@link compareFields} reads the `itemBuilders` declarations and
94
+ * {@link checkAuthoredSystemData} reads a note's `<system>.system`; between them
95
+ * they miss every key a compiler writes on its own initiative, which is not a
96
+ * residue — it is `shortcode`, `actionDefs`, `notes`, `docHtml` and `archetype`.
97
+ *
98
+ * **The keys come from the object the compiler built, after a JSON round trip.**
99
+ * That is exactly what the pack file receives, so a key whose value is
100
+ * `undefined` — dropped by `JSON.stringify`, never written, nothing for Foundry
101
+ * to discard — is correctly not a finding. Reading the assembled block is also
102
+ * the only derivation that cannot go stale: a compiler that grows a key is
103
+ * checked on the next build without anyone remembering to add it to a list.
104
+ *
105
+ * **Authored paths are left alone.** A note's own `<system>.system` is reported
106
+ * by {@link checkAuthoredSystemData}, which can point at the line the author
107
+ * wrote; reporting it again here would be the same defect twice, once without a
108
+ * position.
109
+ *
110
+ * **A subtree the artifact does not describe is not checked**, rather than
111
+ * reported wholesale — see {@link enumeratedContainers} for the
112
+ * `TypedSchemaField` case that makes the distinction load-bearing.
113
+ *
114
+ * @param {object} opts
115
+ * @param {object} opts.system - The `system` block the compiler assembled.
116
+ * @param {SchemaArtifact} opts.artifact - The receiving system's schemas.
117
+ * @param {string} opts.documentType - `Item`, `Actor`, …
118
+ * @param {string} opts.subtype - The document subtype being emitted.
119
+ * @param {string} opts.type - The content type whose note produced it, for the
120
+ * message.
121
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
122
+ * declaration, which decides a finding's {@link EmissionFinding.origin}.
123
+ * @param {ReadonlySet<string>} [opts.authored] - Paths the note authored, left
124
+ * to the note-side check.
125
+ * @returns {EmissionFinding[]} One per undeclared path, shallowest-first.
126
+ */
127
+ export function compareEmittedSystem({ system, artifact, documentType, subtype, type, fields, authored, }: {
128
+ system: object;
129
+ artifact: SchemaArtifact;
130
+ documentType: string;
131
+ subtype: string;
132
+ type: string;
133
+ fields?: readonly {
134
+ to?: string;
135
+ }[] | undefined;
136
+ authored?: ReadonlySet<string> | undefined;
137
+ }): EmissionFinding[];
88
138
  /**
89
139
  * The published schema this build should check itself against, or `null`.
90
140
  *
@@ -140,6 +190,107 @@ export function undeclaredMessage(finding: object): string;
140
190
  * @returns {string} The message.
141
191
  */
142
192
  export function unemittedMessage(finding: object): string;
193
+ /**
194
+ * What an author is told about an emitted key the target system does not
195
+ * define — in the terms of whoever can actually fix it.
196
+ *
197
+ * The failure is identical in both cases and the remedies are not, which is
198
+ * why the two are told apart at all:
199
+ *
200
+ * | origin | who writes it | what fixes it |
201
+ * | --- | --- | --- |
202
+ * | `builder` | a `fields:` entry in this repository's `itemBuilders` | change the field's `to`, or get the system to declare it |
203
+ * | `compiler` | this package, on every document of the subtype | **nothing here** — the system must declare it, or this package must be pinned to a build that does not write it |
204
+ *
205
+ * A `compiler` finding is the one worth spelling out, because the obvious first
206
+ * move — go and look for the field in `itemBuilders` — leads nowhere: there is
207
+ * no declaration to correct. It means the build is running ahead of the system
208
+ * it compiles for, and the version named in the message is what says so.
209
+ *
210
+ * @param {EmissionFinding} finding - One entry from
211
+ * {@link compareEmittedSystem}.
212
+ * @returns {string} The message.
213
+ */
214
+ export function emittedUndeclaredMessage(finding: EmissionFinding): string;
215
+ /**
216
+ * What a note authors under `<system>.system`, against what the receiving
217
+ * subtype declares (#58).
218
+ *
219
+ * The **note-side** half of the check `compareFields` performs on the
220
+ * declarations. A field list is checked once for the whole build because it is
221
+ * the same for every document; an authored `system` block is a property of one
222
+ * note, so it is checked where that note is compiled and reported against that
223
+ * note's file.
224
+ *
225
+ * It is the same failure either way, and the reason both halves exist: Foundry
226
+ * discards an unknown `system` key at construction and says nothing, so a
227
+ * mistyped path is lost at load while the build reports success.
228
+ *
229
+ * **Silent where there is nothing to check against.** A module pinning a system
230
+ * version released before the artifact existed, or a subtype the artifact does
231
+ * not name, produces no findings — the same stance `compareFields` takes, where
232
+ * an unknown subtype is a routing question rather than a field one. The
233
+ * whole-build check in `content-build lint` is where a missing artifact is said
234
+ * out loud, once, instead of per note.
235
+ *
236
+ * @param {object} fm - The note's frontmatter.
237
+ * @param {object} opts
238
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
239
+ * @param {string} opts.documentType - `Item`, `Actor`, …
240
+ * @param {string} opts.subType - The document subtype the note compiles into.
241
+ * @param {object} [opts.config] - The resolved build configuration.
242
+ * @returns {{path: string, message: string}[]} One finding per undeclared path,
243
+ * shallowest-first.
244
+ */
245
+ export function checkAuthoredSystemData(fm: object, { block, documentType, subType, config }: {
246
+ block: string;
247
+ documentType: string;
248
+ subType: string;
249
+ config?: object | undefined;
250
+ }): {
251
+ path: string;
252
+ message: string;
253
+ }[];
254
+ /**
255
+ * The `system` block a compiler just assembled, against what the receiving
256
+ * subtype declares (#155).
257
+ *
258
+ * The build-time face of {@link compareEmittedSystem}: it resolves the schema
259
+ * the way every other check here does — the system's own committed artifact, or
260
+ * the cached one from the release a module pins — and attaches the message a
261
+ * reader sees.
262
+ *
263
+ * **Silent where there is nothing to check against**, exactly as its two
264
+ * siblings are: a module pinning a system version released before the artifact
265
+ * existed, or a subtype the artifact does not name, produces no findings.
266
+ * `content-build lint` is where a missing artifact is said out loud, once.
267
+ *
268
+ * @param {object} system - The `system` block the compiler produced.
269
+ * @param {object} opts
270
+ * @param {object} opts.fm - The note's frontmatter, for the authored paths this
271
+ * check leaves to {@link checkAuthoredSystemData}.
272
+ * @param {string} opts.block - The system block to read, e.g. `"sohl"`.
273
+ * @param {string} opts.documentType - `Item`, `Actor`, …
274
+ * @param {string} opts.subType - The document subtype the note compiles into.
275
+ * @param {string} opts.type - The note's content type, for the message.
276
+ * @param {readonly {to?: string}[]} [opts.fields] - The type's field
277
+ * declaration, which decides each finding's origin.
278
+ * @param {object} [opts.config] - The resolved build configuration.
279
+ * @returns {(EmissionFinding & {message: string})[]} One per undeclared path.
280
+ */
281
+ export function checkEmittedSystemData(system: object, { fm, block, documentType, subType, type, fields, config }: {
282
+ fm: object;
283
+ block: string;
284
+ documentType: string;
285
+ subType: string;
286
+ type: string;
287
+ fields?: readonly {
288
+ to?: string;
289
+ }[] | undefined;
290
+ config?: object | undefined;
291
+ }): (EmissionFinding & {
292
+ message: string;
293
+ })[];
143
294
  /**
144
295
  * The artifact version this module reads.
145
296
  *
@@ -174,3 +325,34 @@ export type SchemaArtifact = {
174
325
  inherited: string[];
175
326
  }>>;
176
327
  };
328
+ /**
329
+ * One `system` key a compiled document carries that its subtype does not
330
+ * declare.
331
+ */
332
+ export type EmissionFinding = {
333
+ /**
334
+ * - The content type whose note produced the document.
335
+ */
336
+ type: string;
337
+ /**
338
+ * - The document subtype it compiled into.
339
+ */
340
+ subtype: string;
341
+ /**
342
+ * - `Item`, `Actor`, …
343
+ */
344
+ documentType: string;
345
+ /**
346
+ * - The undeclared path, dotted.
347
+ */
348
+ field: string;
349
+ /**
350
+ * - The version checked against.
351
+ */
352
+ systemVersion: string;
353
+ /**
354
+ * - What wrote it. See
355
+ * {@link emittedUndeclaredMessage}.
356
+ */
357
+ origin: "builder" | "compiler";
358
+ };