@heroiclands/package-build 22.17.0 → 22.17.2

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 (37) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/content-commands.mjs +114 -37
  3. package/config.mjs +54 -3
  4. package/docs/api.md +4 -14
  5. package/docs/commands.md +89 -52
  6. package/docs/configuration.md +49 -22
  7. package/docs/reference/format-details.md +27 -45
  8. package/docs/reference/note-types.md +1 -1
  9. package/engine/actor-compiler.mjs +5 -5
  10. package/engine/address-diff.mjs +32 -279
  11. package/engine/art-fields.mjs +8 -5
  12. package/engine/content-format.yaml +4 -4
  13. package/engine/content-index.mjs +28 -3
  14. package/engine/content-lint.mjs +3 -182
  15. package/engine/frontmatter-lint.mjs +112 -59
  16. package/engine/helpers.mjs +15 -3
  17. package/engine/index.mjs +0 -3
  18. package/engine/map-graphviz.mjs +4 -1
  19. package/engine/note-frontmatter.mjs +1 -0
  20. package/engine/note-vocabulary.mjs +1 -13
  21. package/engine/readability-lint.mjs +196 -18
  22. package/engine/subtype-registry.mjs +2 -6
  23. package/engine/systems.mjs +11 -13
  24. package/package.json +5 -1
  25. package/types/config.d.mts +14 -2
  26. package/types/engine/actor-compiler.d.mts +5 -5
  27. package/types/engine/address-diff.d.mts +18 -107
  28. package/types/engine/art-fields.d.mts +4 -5
  29. package/types/engine/frontmatter-lint.d.mts +14 -0
  30. package/types/engine/helpers.d.mts +12 -1
  31. package/types/engine/index.d.mts +0 -1
  32. package/types/engine/note-vocabulary.d.mts +0 -9
  33. package/types/engine/readability-lint.d.mts +11 -5
  34. package/types/engine/subtype-registry.d.mts +2 -6
  35. package/types/engine/systems.d.mts +6 -6
  36. package/engine/note-renames.mjs +0 -134
  37. package/types/engine/note-renames.d.mts +0 -102
@@ -8,11 +8,18 @@ import path from "node:path";
8
8
  import { unified } from "unified";
9
9
  import remarkParse from "remark-parse";
10
10
  import remarkFrontmatter from "remark-frontmatter";
11
+ import remarkGfm from "remark-gfm";
11
12
  import remarkRetext from "remark-retext";
12
13
  import retextEnglish from "retext-english";
13
14
  import retextReadability from "retext-readability";
14
15
  import retextSimplify from "retext-simplify";
15
16
  import retextStringify from "retext-stringify";
17
+ import { flesch } from "flesch";
18
+ import { syllable } from "syllable";
19
+ import { daleChall } from "dale-chall";
20
+
21
+ const FAMILIAR_WORDS = new Set(daleChall);
22
+ const NOMINALIZATION = /(?:tion|sion|ment|ness|ity|ance|ence|ism)$/i;
16
23
 
17
24
  /** Files in a content tree, excluding configured directory names. */
18
25
  export function proseFiles(root, skipDirectories = []) {
@@ -31,28 +38,84 @@ export function proseFiles(root, skipDirectories = []) {
31
38
 
32
39
  /** Keep source offsets while excluding HeroicLands inline addresses and expressions. */
33
40
  function maskMarkup(source) {
34
- return source.replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) =>
35
- match.replace(/[^\n]/g, " "),
36
- );
41
+ return source
42
+ .replace(/!?\[\[[^\]\n]+\]\]|\{\{[^}\n]+\}\}/g, (match) => match.replace(/[^\n]/g, " "))
43
+ .replace(/^:::sql[^\n]*\n[\s\S]*?^:::[ \t]*$/gm, (match) => match.replace(/[^\n]/g, " "))
44
+ .replace(/^:::[^\n]*$/gm, (match) => match.replace(/[^\n]/g, " "));
37
45
  }
38
46
 
39
- /** Source-aligned findings for one Markdown note. */
40
- export async function analyzeProse(file, source, { age = 21, threshold = 5, minWords = 8 } = {}) {
41
- const text = maskMarkup(source);
42
- const virtualFile = { value: text, path: file, messages: [] };
43
- const processor = unified()
47
+ /** Select complete paragraphs while keeping their original source positions. */
48
+ function proseTree(source) {
49
+ const processor = unified().use(remarkParse).use(remarkFrontmatter).use(remarkGfm);
50
+ const tree = processor.parse(maskMarkup(source));
51
+ const keepInline = (node) => {
52
+ if (
53
+ ["inlineCode", "image", "imageReference", "html", "footnoteReference"].includes(
54
+ node.type,
55
+ )
56
+ ) {
57
+ return false;
58
+ }
59
+ if (node.children) node.children = node.children.filter(keepInline);
60
+ return true;
61
+ };
62
+ const paragraphs = [];
63
+ const collect = (node) => {
64
+ if (node.type === "paragraph") paragraphs.push(node);
65
+ else if (node.type === "blockquote")
66
+ for (const child of node.children ?? []) collect(child);
67
+ };
68
+ for (const node of tree.children) collect(node);
69
+ tree.children = paragraphs;
70
+ for (const paragraph of tree.children)
71
+ paragraph.children = paragraph.children.filter(keepInline);
72
+ return tree;
73
+ }
74
+
75
+ /** Count readable body words, including headings and lists, for score coverage. */
76
+ function bodyWordCount(source) {
77
+ const tree = unified()
44
78
  .use(remarkParse)
45
79
  .use(remarkFrontmatter)
46
- .use(
47
- remarkRetext,
48
- unified()
49
- .use(retextEnglish)
50
- .use(retextReadability, { age, threshold: threshold / 7, minWords })
51
- .use(retextSimplify)
52
- .use(retextStringify),
53
- );
54
- await processor.run(processor.parse(virtualFile), virtualFile);
55
- return virtualFile.messages.map((message) => {
80
+ .use(remarkGfm)
81
+ .parse(maskMarkup(source));
82
+ const visit = (node) => {
83
+ if (
84
+ ["yaml", "code", "table", "html", "inlineCode", "image", "imageReference"].includes(
85
+ node.type,
86
+ )
87
+ ) {
88
+ return 0;
89
+ }
90
+ if (node.type === "text")
91
+ return (node.value.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? []).length;
92
+ return (node.children ?? []).reduce((sum, child) => sum + visit(child), 0);
93
+ };
94
+ return visit(tree);
95
+ }
96
+
97
+ /** Bridge selected Markdown paragraphs to retext for lint and scoring. */
98
+ async function retextProse(file, source, options = {}, inspect) {
99
+ const virtualFile = { value: maskMarkup(source), path: file, messages: [] };
100
+ const analysis = unified().use(retextEnglish);
101
+ if (options.rules !== "simplify" && options.rules !== "none") {
102
+ analysis.use(retextReadability, {
103
+ age: options.age ?? 21,
104
+ threshold: (options.threshold ?? 5) / 7,
105
+ minWords: options.minWords ?? 8,
106
+ });
107
+ }
108
+ if (options.rules === "simplify" || options.rules === "all") analysis.use(retextSimplify);
109
+ if (inspect) analysis.use(() => (tree) => inspect(tree));
110
+ analysis.use(retextStringify);
111
+ await unified().use(remarkRetext, analysis).run(proseTree(source), virtualFile);
112
+ return virtualFile.messages;
113
+ }
114
+
115
+ /** Source-aligned findings for one Markdown note. */
116
+ export async function analyzeProse(file, source, options = {}) {
117
+ const messages = await retextProse(file, source, options);
118
+ return messages.map((message) => {
56
119
  const position = message.place?.start;
57
120
  const sentence = message.ancestors?.find((node) => node.type === "SentenceNode");
58
121
  const span = sentence?.position;
@@ -84,3 +147,118 @@ export async function lintProse(root, options, skipDirectories = []) {
84
147
  }
85
148
  return findings;
86
149
  }
150
+
151
+ /** Concatenate the text leaves in a retext word. */
152
+ function wordText(node) {
153
+ return node.value ?? (node.children ?? []).map(wordText).join("");
154
+ }
155
+
156
+ /** Count the words and syllables in one retext sentence. */
157
+ function sentenceCounts(sentence) {
158
+ const words = [];
159
+ const visit = (node) => {
160
+ if (node.type === "WordNode") words.push(wordText(node));
161
+ else for (const child of node.children ?? []) visit(child);
162
+ };
163
+ visit(sentence);
164
+ return {
165
+ words: words.length,
166
+ syllables: words.reduce((sum, word) => sum + syllable(word), 0),
167
+ unfamiliar: words.filter((word) => !FAMILIAR_WORDS.has(word.toLowerCase())).length,
168
+ nominalizations: words.filter((word) => NOMINALIZATION.test(word)).length,
169
+ };
170
+ }
171
+
172
+ /** Score the complete prose paragraphs in one note. */
173
+ export async function scoreProse(file, source, { minWords = 80, bands = {} } = {}) {
174
+ const sentences = [];
175
+ await retextProse(file, source, { rules: "none" }, (tree) => {
176
+ const visit = (node) => {
177
+ if (node.type === "SentenceNode") sentences.push(sentenceCounts(node));
178
+ else for (const child of node.children ?? []) visit(child);
179
+ };
180
+ visit(tree);
181
+ });
182
+ const totals = sentences.reduce(
183
+ (sum, sentence) => ({
184
+ words: sum.words + sentence.words,
185
+ syllables: sum.syllables + sentence.syllables,
186
+ unfamiliar: sum.unfamiliar + sentence.unfamiliar,
187
+ nominalizations: sum.nominalizations + sentence.nominalizations,
188
+ }),
189
+ { words: 0, syllables: 0, unfamiliar: 0, nominalizations: 0 },
190
+ );
191
+ const longestSentenceWords = Math.max(0, ...sentences.map((sentence) => sentence.words));
192
+ const count = sentences.filter((sentence) => sentence.words > 0).length;
193
+ const metrics =
194
+ count && totals.words ?
195
+ {
196
+ flesch: flesch({ sentence: count, word: totals.words, syllable: totals.syllables }),
197
+ syllablesPerWord: totals.syllables / totals.words,
198
+ meanSentenceWords: totals.words / count,
199
+ longestSentenceWords,
200
+ unfamiliarWordPercent: (100 * totals.unfamiliar) / totals.words,
201
+ nominalizationsPer1000Words: (1000 * totals.nominalizations) / totals.words,
202
+ }
203
+ : null;
204
+ const insufficient = totals.words < minWords;
205
+ const violations = [];
206
+ if (!insufficient) {
207
+ for (const [metric, band] of Object.entries(bands)) {
208
+ if (band.min !== undefined && metrics[metric] < band.min) {
209
+ violations.push(`${metric} below ${band.min}`);
210
+ }
211
+ if (band.max !== undefined && metrics[metric] > band.max) {
212
+ violations.push(`${metric} above ${band.max}`);
213
+ }
214
+ }
215
+ }
216
+ const bodyWords = bodyWordCount(source);
217
+ return {
218
+ file,
219
+ ...totals,
220
+ bodyWords,
221
+ coveragePercent: bodyWords ? (100 * totals.words) / bodyWords : 0,
222
+ sentences: count,
223
+ longestSentenceWords,
224
+ metrics,
225
+ insufficient,
226
+ violations,
227
+ };
228
+ }
229
+
230
+ /** Score a note or content tree and pool counts for its aggregate. */
231
+ export async function scoreProseTree(root, options, skipDirectories = []) {
232
+ const notes = [];
233
+ for (const file of proseFiles(root, skipDirectories)) {
234
+ notes.push(await scoreProse(file, fs.readFileSync(file, "utf8"), options));
235
+ }
236
+ const scored = notes.filter((note) => !note.insufficient);
237
+ const totals = scored.reduce(
238
+ (sum, note) => ({
239
+ words: sum.words + note.words,
240
+ syllables: sum.syllables + note.syllables,
241
+ sentences: sum.sentences + note.sentences,
242
+ unfamiliar: sum.unfamiliar + note.unfamiliar,
243
+ nominalizations: sum.nominalizations + note.nominalizations,
244
+ }),
245
+ { words: 0, syllables: 0, sentences: 0, unfamiliar: 0, nominalizations: 0 },
246
+ );
247
+ return {
248
+ notes,
249
+ summary: {
250
+ ...totals,
251
+ scored: scored.length,
252
+ insufficient: notes.length - scored.length,
253
+ outside: scored.filter((note) => note.violations.length).length,
254
+ flesch:
255
+ totals.words && totals.sentences ?
256
+ flesch({
257
+ sentence: totals.sentences,
258
+ word: totals.words,
259
+ syllable: totals.syllables,
260
+ })
261
+ : null,
262
+ },
263
+ };
264
+ }
@@ -43,10 +43,6 @@ import { HM3_DOCUMENT_SUBTYPES } from "../hm3/document-subtypes.mjs";
43
43
  /**
44
44
  * The note-type → document-subtype maps this toolchain ships.
45
45
  *
46
- * Two, since `hm3/` landed — and it joined this list rather than the
47
- * claim table growing a second copy of the same fact, which is what the list
48
- * was for.
49
- *
50
46
  * The union is what makes the vocabulary wider than any one repository's
51
47
  * configuration: `armorlocation` is a real content type because HM3 maps it,
52
48
  * however a given repository is configured, so a tree full of them is a
@@ -90,7 +86,7 @@ export const KNOWN_DOCUMENT_SUBTYPE_MAPS = Object.freeze([
90
86
  export const DEFAULT_DOCUMENT_SUBTYPES = SOHL_DOCUMENT_SUBTYPES;
91
87
 
92
88
  /**
93
- * Every system this toolchain ships a map for, by id.
89
+ * Every system identifier with declared system-specific document types.
94
90
  *
95
91
  * Derived from {@link KNOWN_DOCUMENT_SUBTYPE_MAPS}, so registering a map is the
96
92
  * whole of adding a system to every reader that asks "which systems are there?"
@@ -156,7 +152,7 @@ export const ACTOR_TYPES = Object.freeze(
156
152
  /**
157
153
  * The map one system ships, by its id.
158
154
  *
159
- * @param {string|undefined} system - The system id (`"sohl"`, `"hm3"`).
155
+ * @param {string|undefined} system - The system id.
160
156
  * @returns {import("./document-subtypes.mjs").DocumentSubtypeMap|undefined} Its
161
157
  * map, or `undefined` where this toolchain ships none for it.
162
158
  */
@@ -20,10 +20,8 @@
20
20
  * address grammar asks on every note, and the issue's answer is that it comes
21
21
  * from a **registry rather than from a hardcoded set**: known system ids are
22
22
  * declared here, once, an unknown value is an error, and _adding a system is a
23
- * data change_. Before this module the same fact was spelled in as many places
24
- * as needed it — a `NO_SYSTEM` constant in the content index, a `map.system`
25
- * per document-subtype map, a system-keyed table per compiler — none of which
26
- * could refuse a value none of the others had heard of.
23
+ * data change_. The same fact is read by address parsing, configuration and
24
+ * linting.
27
25
  *
28
26
  * **`none` is a word, deliberately.** It is not a YAML null (`null`, `~`, or an
29
27
  * empty value), because those spell "nobody filled this in", and a note that
@@ -38,8 +36,8 @@
38
36
  * the configuration loader, by a compiler, and by a lint without closing a
39
37
  * cycle around any of them. In particular the ids are *declared* here rather
40
38
  * than derived from `KNOWN_DOCUMENT_SUBTYPE_MAPS` — deriving them would drag
41
- * `sohl/` and `hm3/` behind every import of this file, for a list of two
42
- * words. `tests/systems.test.ts` holds the two in step instead, which is where
39
+ * system-specific modules behind every import of this file. `tests/systems.test.ts`
40
+ * holds the registry and note-key vocabulary in step, which is where
43
41
  * the agreement between a registry and its implementations belongs.
44
42
  *
45
43
  * @module
@@ -66,11 +64,11 @@ export const NO_SYSTEM = "none";
66
64
  export const NOTE_SYSTEM = "note";
67
65
 
68
66
  /**
69
- * Every game system this toolchain compiles for.
67
+ * Every game system identifier this toolchain recognizes.
70
68
  *
71
- * The registry, and the only place the list is written down. Adding a system
72
- * is an edit to this set plus the map and compilers that make it real — a data
73
- * change, not a search for every place a name was spelled.
69
+ * The registry, and the only place the list is written down. A recognized id
70
+ * can have no system-specific document types; adding one of those requires a
71
+ * subtype map and compiler as well.
74
72
  *
75
73
  * `none` is **not** a member: it marks the absence of a system, and a caller
76
74
  * asking "is this a system" and a caller asking "is this a well-formed
@@ -79,7 +77,7 @@ export const NOTE_SYSTEM = "note";
79
77
  *
80
78
  * @type {ReadonlySet<string>}
81
79
  */
82
- export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3"]));
80
+ export const SYSTEM_IDS = Object.freeze(new Set(["sohl", "hm3", "dnd5e"]));
83
81
 
84
82
  /**
85
83
  * Everything the `<system>` segment of an address may say.
@@ -127,8 +125,8 @@ export function isSystemSegment(value) {
127
125
  *
128
126
  * The nullish case is called out by name because it is the likely mistake and
129
127
  * the least legible failure: a YAML `null`, a `~`, or a key written with no
130
- * value at all reaches here as `undefined`, and "expected one of sohl, hm3,
131
- * none" would leave a reader hunting for the value they cannot see.
128
+ * value at all reaches here as `undefined`, and a list of accepted values
129
+ * would leave a reader hunting for the value they cannot see.
132
130
  *
133
131
  * @param {unknown} value - The offending value.
134
132
  * @param {string} [where] - What carried it, for the message — a note path, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.17.0",
3
+ "version": "22.17.2",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -137,10 +137,12 @@
137
137
  "acorn": "^8.18.0",
138
138
  "archiver": "^8.0.0",
139
139
  "classic-level": "^3.0.0",
140
+ "dale-chall": "^2.0.1",
140
141
  "dotenv": "^18.0.0",
141
142
  "eslint": "^10.10.0",
142
143
  "eslint-plugin-yml": "^3.8.1",
143
144
  "fflate": "^0.8.3",
145
+ "flesch": "^2.0.1",
144
146
  "glob": "^13.0.6",
145
147
  "gray-matter": "^4.0.3",
146
148
  "handlebars": "^4.7.9",
@@ -153,6 +155,7 @@
153
155
  "pagefind": "^1.5.2",
154
156
  "prettier": "^3.9.6",
155
157
  "remark-frontmatter": "^5.0.0",
158
+ "remark-gfm": "^4.0.1",
156
159
  "remark-parse": "^11.0.0",
157
160
  "remark-retext": "^6.0.1",
158
161
  "retext-english": "^5.0.0",
@@ -162,6 +165,7 @@
162
165
  "sharp": "^0.35.4",
163
166
  "smol-toml": "^1.8.0",
164
167
  "ssh2-sftp-client": "^12.1.1",
168
+ "syllable": "^5.0.1",
165
169
  "typescript": "^6.0.3",
166
170
  "unidecode": "^1.1.0",
167
171
  "unified": "^11.0.5",
@@ -94,7 +94,8 @@ export const DERIVED_MANIFEST_KEYS: Readonly<Record<string, string>>;
94
94
  *
95
95
  * @typedef {object} PackageBuildConfig
96
96
  * @property {string} rootDir The repository root, from package-build.
97
- * @property {{age: number, threshold: number, minWords: number}} proseLint Optional prose analysis settings.
97
+ * @property {{age: number, threshold: number, minWords: number, rules: string}} proseLint Optional prose lint settings.
98
+ * @property {{minWords: number, bands: Record<string, {min?: number, max?: number}>}} proseScore Optional note score settings.
98
99
  * @property {string} packageKind `systems` or `modules`.
99
100
  * @property {string} packageId The Foundry package id.
100
101
  * @property {string} artifact Derived: `system` or `module`.
@@ -198,12 +199,23 @@ export type PackageBuildConfig = {
198
199
  */
199
200
  rootDir: string;
200
201
  /**
201
- * Optional prose analysis settings.
202
+ * Optional prose lint settings.
202
203
  */
203
204
  proseLint: {
204
205
  age: number;
205
206
  threshold: number;
206
207
  minWords: number;
208
+ rules: string;
209
+ };
210
+ /**
211
+ * Optional note score settings.
212
+ */
213
+ proseScore: {
214
+ minWords: number;
215
+ bands: Record<string, {
216
+ min?: number;
217
+ max?: number;
218
+ }>;
207
219
  };
208
220
  /**
209
221
  * `systems` or `modules`.
@@ -230,11 +230,11 @@ export class SystemActorCompiler extends BasePackCompiler {
230
230
  * The actor's two pieces of art, resolved, with the being default beneath
231
231
  * them.
232
232
  *
233
- * **The default is chosen from the note's tags**, which only a compiler can
234
- * read: a `character` falls back to one file and a `creature` to another,
235
- * and both are addresses in the package that ships them. A tree whose index
236
- * cannot answer that address — a dependency not yet fetched — falls through
237
- * to the subtype's own default, so the document is never left with no art.
233
+ * **The default follows the note's `subType`**, with tags as a compatibility
234
+ * fallback for content trees that have not migrated. Both addresses belong
235
+ * to the package that ships them. A tree whose index cannot answer an
236
+ * address — a dependency not yet fetched — falls through to the document
237
+ * subtype's own default, so the document is never left with no art.
238
238
  *
239
239
  * `tokenIcon` unset follows `icon`, and the fallback is applied after
240
240
  * resolution rather than before: a note naming an icon and no token icon
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * The one spelling of an address in this space: `type:shortcode`.
3
3
  *
4
- * Written by both readers — the compiled packs on each side of the diff, and
5
- * the declarations read out of the tree — so a predecessor a note names and an
6
- * address a pack publishes cannot come apart over punctuation or case. The
7
- * shortcode is used **verbatim**, not lowercased: `readItemAddresses` reads it
8
- * off the compiled document, where `Tabri` is stored as authored, and folding
9
- * case here would join two addresses the packs keep apart.
4
+ * Used for addresses read from compiled documents. The shortcode is used
5
+ * **verbatim**, not lowercased: folding case would join two addresses the packs
6
+ * keep apart.
10
7
  *
11
8
  * @param {string} type - The Foundry document subtype, not the note type.
12
9
  * @param {string} shortcode - The address's `system.shortcode`.
@@ -39,89 +36,24 @@ export function readItemAddresses(dirs: readonly string[]): Map<string, {
39
36
  shortcode: string;
40
37
  file: string;
41
38
  }>;
42
- /**
43
- * Every rename the tree's notes **declare**, as old address → where it went.
44
- *
45
- * Read from the content tree rather than from compiled output, because a
46
- * declaration is authored and the compiled document does not carry it: nothing
47
- * downstream consumes `renamedFrom:`, so emitting it into every pack to let one
48
- * diagnostic read it back would put a build-time note in shipped data forever.
49
- * The tree is already read by this module for the same reason
50
- * ({@link noteFilesById}) — to place a finding where its author can fix it.
51
- *
52
- * **A declaration is keyed by document subtype, not by note type.** The address
53
- * space is the one consumers resolve against, and it is spelled in compiled
54
- * documents: `hm3` compiles a `projectile` note into a `missilegear` item,
55
- * so that is the address a rename of it moves. {@link referencedSubtype} is the
56
- * function that already answers this for a being's embedded `(type, shortcode)`
57
- * references, so both sides read the same rule rather than a second copy of it.
58
- *
59
- * **An entry is emitted for every system that maps the type**, whether or not
60
- * the note declares that system's block. Over-emitting is inert — the diff uses
61
- * an entry only when the baseline published the old address *and* this build
62
- * publishes the new one, and a system the note does not compile for satisfies
63
- * neither — while asking which blocks a note declares would put a second,
64
- * subtly different answer to that question in a third place.
65
- *
66
- * **First claim wins on a collision.** Two notes naming one predecessor is a
67
- * contradiction — an address has one successor — and it is reported as an error
68
- * by `engine/content-lint.mjs`, where both notes are in hand and can both be
69
- * named. Picking one here keeps this a map; it is not a resolution, and nothing
70
- * rests on which one it picked.
71
- *
72
- * @param {string} contentBase - Root of the content tree.
73
- * @param {object} opts
74
- * @param {readonly string[]} [opts.skipDirectories] - The corpus scope. Stated
75
- * by the caller, never defaulted — see {@link addressCorpus}.
76
- * @param {readonly object[]} [opts.maps] - The document-subtype maps.
77
- * @param {object} [opts.config] - The resolved build configuration.
78
- * @param {readonly object[]} [opts.records] - Index records the caller already
79
- * derived, shared with {@link noteFilesById} so one command reads one corpus.
80
- * @param {object[]} [opts.problems] - Collects the notes the index cannot
81
- * record, so one of them does not abort the diff before it reports.
82
- * @returns {Map<string, {to: string, file: string, shortcode: string}>} Old
83
- * address → the address the declaring note publishes at now, and that note.
84
- */
85
- export function declaredPredecessors(contentBase: string, { skipDirectories, maps, config, records, problems }?: {
86
- skipDirectories?: readonly string[] | undefined;
87
- maps?: readonly object[] | undefined;
88
- config?: object | undefined;
89
- records?: readonly object[] | undefined;
90
- problems?: object[] | undefined;
91
- }): Map<string, {
92
- to: string;
93
- file: string;
94
- shortcode: string;
95
- }>;
96
39
  /**
97
40
  * Every address the baseline published that this build does not.
98
41
  *
99
- * An address that merely *arrived* is not a finding: adding one breaks nobody.
100
- * The arrivals are read only to answer the one question that matters about a
101
- * departure — is the document still here under another name?
42
+ * An address that arrived is not a finding. A departed address is a rename
43
+ * only when its document id appears at another current address; otherwise it
44
+ * is a withdrawal.
102
45
  *
103
46
  * @param {Map<string, object>} baseline - The released address space.
104
47
  * @param {Map<string, object>} current - This build's address space.
105
48
  * @param {object} opts
106
- * @param {string} opts.baseline - What the baseline is, for the message —
107
- * conventionally `<package>@<version>`.
108
- * @param {Map<string, {to: string, file: string}>} [opts.predecessors] - The
109
- * declared renames, from {@link declaredPredecessors}. Omitted, the diff
110
- * falls back to the id join alone and reports an unpinned rename as a
111
- * withdrawal, which is what it did before.
112
- * @returns {Array<object>} One finding per departed address, in address order
113
- * so two runs read the same. `kind` is `"renamed"` (with `to`, and `declared`
114
- * when it was the note's word rather than an id match) or `"withdrawn"`.
49
+ * @param {string} opts.baseline - What the baseline is, for the message.
50
+ * @returns {Array<object>} One finding per departed address, in address order.
115
51
  */
116
- export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label, predecessors }: {
52
+ export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label }: {
117
53
  baseline: string;
118
- predecessors?: Map<string, {
119
- to: string;
120
- file: string;
121
- }> | undefined;
122
54
  }): Array<object>;
123
55
  /**
124
- * Every content note in a tree, indexed by the document id it authors.
56
+ * Every content note in a tree, indexed by the document id it compiles under.
125
57
  *
126
58
  * The address space is read from compiled output because that is what actually
127
59
  * ships; the tree is read only to place a finding somewhere a reader can open
@@ -135,7 +67,7 @@ export function diffItemAddresses(baseline: Map<string, object>, current: Map<st
135
67
  * @param {object} [opts.config] - The resolved build configuration, which the
136
68
  * id is derived against. See {@link addressCorpus} for why that matters.
137
69
  * @param {readonly object[]} [opts.records] - Index records the caller already
138
- * derived, shared with {@link declaredPredecessors}.
70
+ * derived by the caller.
139
71
  * @param {object[]} [opts.problems] - Collects the notes the index cannot
140
72
  * record, so one of them does not abort the diff before it reports.
141
73
  * @returns {Map<string, string>} Document id → the note's absolute path.
@@ -149,22 +81,13 @@ export function noteFilesById(contentBase: string, { skipDirectories, config, re
149
81
  /**
150
82
  * Where to send the reader for one finding.
151
83
  *
152
- * A rename is fixed in the note that made it, so a finding whose id is still in
153
- * this tree is reported at that note's `shortcode:` line — the line the author
154
- * just edited. A withdrawal has no such note by definition, so it degrades to
155
- * the baseline document, which is the only artefact left that records the
156
- * address existing. When neither is readable the position is **dropped**, never
157
- * defaulted to `1:1`.
158
- *
159
- * A **declared** rename knows its note without any lookup — the declaration is
160
- * how it was found — and is reported at the `renamedFrom:` line rather than the
161
- * `shortcode:` line, because that is the line the finding is about and the one
162
- * the author deletes once the declaration has done its work.
84
+ * A matched document is located at its current `shortcode:` line. A withdrawal
85
+ * is located in the baseline artifact, the remaining evidence for its address.
86
+ * When neither source has a usable position, the location is dropped.
163
87
  *
164
88
  * @param {object} finding - One finding from {@link diffItemAddresses}.
165
89
  * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
166
- * @returns {{file?: string, line?: number, column?: number}} Spreadable
167
- * position fields for {@link formatDiagnostic}.
90
+ * @returns {{file?: string, line?: number, column?: number}} Position fields.
168
91
  */
169
92
  export function locateAddressFinding(finding: object, noteFiles: Map<string, string>): {
170
93
  file?: string;
@@ -174,21 +97,9 @@ export function locateAddressFinding(finding: object, noteFiles: Map<string, str
174
97
  /**
175
98
  * What one finding says, without a locator or a severity.
176
99
  *
177
- * The rename message names the identity it matched on, because that is what
178
- * separates this from a spelling suggestion: the reader can check the id in
179
- * both artefacts. The withdrawal message names no successor, because none is
180
- * known — and says so, rather than leaving the reader to wonder whether one was
181
- * looked for.
182
- *
183
- * **A declared rename says it is declared**. The two claims are not
184
- * equally checkable: an id match is a fact in the artefacts, while a
185
- * declaration is an author's word, and a reader deciding whether to trust the
186
- * successor needs to know which one they have. Saying "the same document" of a
187
- * declared rename would spend the id match's credibility on it.
188
- *
189
- * The consequence sentence is the same for both, because it is the same
190
- * consequence: the old address stops resolving either way, and that is what
191
- * the reader has to act on.
100
+ * A matched document names the address where it now appears. A withdrawal
101
+ * names no successor because the current build publishes no document with
102
+ * that identity.
192
103
  *
193
104
  * @param {object} finding - One finding from {@link diffItemAddresses}.
194
105
  * @returns {string} The message.
@@ -142,14 +142,13 @@ export function artPathname(index: object, value: unknown, defaultType: string,
142
142
  type?: string;
143
143
  };
144
144
  /**
145
- * The default art address a being's own tags choose, or `null`.
145
+ * The default art address a being's subtype chooses, or `null`.
146
146
  *
147
- * `character` means a **person**, not a human, and `creature` everything else;
148
- * a being carries exactly one of the two, which is what makes the choice a
149
- * lookup rather than a precedence rule.
147
+ * An NPC and a character use person art; a creature uses creature art. The
148
+ * tag lookup preserves defaults for package trees that have not migrated.
150
149
  *
151
150
  * @param {object} fm - The note's frontmatter.
152
- * @returns {string|null} The address, or `null` for a note carrying neither tag.
151
+ * @returns {string|null} The address, or `null` when no subtype or legacy tag applies.
153
152
  */
154
153
  export function beingDefaultArt(fm: object): string | null;
155
154
  /**
@@ -12,6 +12,20 @@
12
12
  * @returns {boolean} Whether it is acceptable.
13
13
  */
14
14
  export function matchesKind(value: unknown, kind: string, context?: object): boolean;
15
+ /**
16
+ * Resolve system-block addresses declared by the system's own field schema.
17
+ * @param {object} note - A note from the link index.
18
+ * @param {object} opts
19
+ * @param {object} opts.index - The address index.
20
+ * @param {Record<string, readonly object[]>} [opts.schemas] - Note schemas.
21
+ * @param {Readonly<Record<string, object>>} [opts.systems] - Declared system blocks.
22
+ * @returns {object[]} Located findings for unresolved addresses.
23
+ */
24
+ export function systemAddressFindings(note: object, { index, schemas, systems }?: {
25
+ index: object;
26
+ schemas?: Record<string, readonly object[]> | undefined;
27
+ systems?: Readonly<Record<string, object>> | undefined;
28
+ }): object[];
15
29
  /**
16
30
  * Check one note against its type's schema.
17
31
  *