@heroiclands/package-build 22.4.6 → 22.5.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 (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CONTENT.md +192 -1
  3. package/bin/content-build.mjs +26 -4
  4. package/content-config.mjs +8 -3
  5. package/docs/api.md +99 -26
  6. package/docs/configuration.md +20 -1
  7. package/docs/content-format.md +238 -0
  8. package/docs/diagnostics.md +44 -0
  9. package/engine/content-index.mjs +63 -21
  10. package/engine/content-links.mjs +60 -6
  11. package/engine/content-tables.mjs +29 -2
  12. package/engine/frontmatter-lint.mjs +2 -0
  13. package/engine/holdings.mjs +416 -0
  14. package/engine/index-records.mjs +25 -0
  15. package/engine/index.mjs +16 -0
  16. package/engine/map-graphviz.mjs +12 -3
  17. package/engine/map-places.mjs +33 -3
  18. package/engine/market-class.mjs +178 -0
  19. package/engine/metadata-index.mjs +7 -4
  20. package/engine/note-state.mjs +213 -0
  21. package/engine/note-vocabulary.mjs +59 -2
  22. package/engine/pdf-build.mjs +4 -2
  23. package/engine/populations.mjs +557 -0
  24. package/engine/site-build.mjs +96 -10
  25. package/engine/site-maps.mjs +165 -0
  26. package/engine/sql-tables.mjs +157 -4
  27. package/engine/stub-lint.mjs +237 -0
  28. package/engine/wikilink-syntax.mjs +27 -1
  29. package/package.json +1 -1
  30. package/types/engine/content-index.d.mts +19 -5
  31. package/types/engine/content-links.d.mts +3 -3
  32. package/types/engine/content-tables.d.mts +2 -1
  33. package/types/engine/holdings.d.mts +157 -0
  34. package/types/engine/index-records.d.mts +22 -0
  35. package/types/engine/index.d.mts +6 -0
  36. package/types/engine/map-graphviz.d.mts +8 -1
  37. package/types/engine/map-places.d.mts +26 -0
  38. package/types/engine/market-class.d.mts +62 -0
  39. package/types/engine/note-state.d.mts +110 -0
  40. package/types/engine/note-vocabulary.d.mts +25 -0
  41. package/types/engine/populations.d.mts +121 -0
  42. package/types/engine/site-build.d.mts +37 -6
  43. package/types/engine/site-maps.d.mts +81 -0
  44. package/types/engine/stub-lint.d.mts +51 -0
  45. package/types/engine/wikilink-syntax.d.mts +19 -1
@@ -0,0 +1,557 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * Whether a world's population figures agree from the region down.
16
+ *
17
+ * A place states `data.population` and an affiliation states how many people
18
+ * it counts. Read across the whole corpus those figures make claims about each
19
+ * other, and four of those claims can be checked: the bodies holding a region
20
+ * fit inside it, the regions inside it fit inside it, a settlement fits inside
21
+ * every place that contains it, and a page citing a figure states the figure
22
+ * that note states.
23
+ *
24
+ * This is {@link module:engine/holdings}'s sibling and reads the same two
25
+ * keys — a place's `data.parents` for geography, an affiliation's
26
+ * `data.domains` for tenure. Where that module asks whether anybody holds a
27
+ * place, this one asks whether the numbers add up.
28
+ *
29
+ * **Every finding is a warning.** A figure out of step with its neighbours is
30
+ * a thing an author wants to see while the note is open, and the same gap can
31
+ * be deliberate for a while: a region whose settlements are half written will
32
+ * disagree with itself until the rest are. Nothing here fails a build.
33
+ *
34
+ * **Figures are authored to two significant digits**, so a parent rounded
35
+ * below the true sum of its children is not a finding.
36
+ * {@link POPULATION_TOLERANCE} is the widest half-unit error such a figure can
37
+ * carry.
38
+ *
39
+ * **A stated figure is compared; an absent one is not.** A place or an
40
+ * affiliation with no `population` contributes nothing to a sum and is never
41
+ * the subject of a finding, so a half-written corpus is quiet rather than
42
+ * noisy. A dependency's places and affiliations take part in the *structure* —
43
+ * a fetched entry carries the `parents` and `domains` its record stated — and
44
+ * a fetched index carries no figure, so a dependency's people are counted
45
+ * nowhere.
46
+ *
47
+ * **There is deliberately no urban-share rule.** A share computed from the
48
+ * named settlements against a region's total measures nothing: the settlement
49
+ * layer names the notable places and is complete nowhere, so the ratio says
50
+ * how much of a region has been written rather than how much of it is urban.
51
+ * As a gate it moved figures rather than finding faults.
52
+ *
53
+ * @module
54
+ */
55
+
56
+ import { positionOfFrontmatterPath, positionOfLiteral } from "./diagnostics.mjs";
57
+ import { shortcodesOf } from "./holdings.mjs";
58
+
59
+ /**
60
+ * How far a parent's figure may fall below the sum of what it contains before
61
+ * that is a finding.
62
+ *
63
+ * A figure written to two significant digits carries a half-unit of error in
64
+ * the second digit, and that error is widest at the bottom of a decade — half
65
+ * a unit on `1.0` is five per cent, against half a unit on `9.9` at a twentieth
66
+ * of that. The widest case is the one a tolerance has to admit, so the whole
67
+ * scale is held to five per cent.
68
+ *
69
+ * @type {number}
70
+ */
71
+ export const POPULATION_TOLERANCE = 1.05;
72
+
73
+ /**
74
+ * The rules this module enforces, each `name` the prefix its findings carry.
75
+ *
76
+ * The list is the module's own statement of what it checks: a reader learns
77
+ * the whole surface from it, and the suite derives a case per entry from it, so
78
+ * a rule cannot be added without evidence that it fires.
79
+ *
80
+ * @type {ReadonlyArray<{name: string, describe: string}>}
81
+ */
82
+ export const POPULATION_RULES = Object.freeze([
83
+ Object.freeze({
84
+ name: "over-held land",
85
+ describe:
86
+ "The polities whose `domains` name a place count more people than the place " +
87
+ "states. A polity subordinate to another holding the same place is already " +
88
+ "counted in that polity's figure, and a settlement is exempt, its holder's " +
89
+ "figure counting a hinterland the settlement does not.",
90
+ }),
91
+ Object.freeze({
92
+ name: "over-full region",
93
+ describe:
94
+ "The regions whose `parents` name a place count more people than the place " +
95
+ "states. A region naming both its parent and its grandparent counts under the " +
96
+ "nearer of the two.",
97
+ }),
98
+ Object.freeze({
99
+ name: "oversized settlement",
100
+ describe:
101
+ "A settlement counts more people than a place containing it states — its own " +
102
+ "region, or any place above that.",
103
+ }),
104
+ Object.freeze({
105
+ name: "disputed figure",
106
+ describe:
107
+ "A `doc` note carries a population beside a wikilink to the place or " +
108
+ "affiliation it belongs to, and the two disagree. A figure is read as a " +
109
+ "population only where it carries the `~` approximation marker.",
110
+ }),
111
+ ]);
112
+
113
+ /**
114
+ * The corpus one link index describes, derived once and kept beside it.
115
+ *
116
+ * Every rule asks a question about the whole tree, and the answer does not
117
+ * change between notes, so the walk happens on the first note linted and the
118
+ * rest read it.
119
+ *
120
+ * @type {WeakMap<object, Corpus>}
121
+ */
122
+ const CORPUS = new WeakMap();
123
+
124
+ /**
125
+ * One place or affiliation as the rules read it.
126
+ *
127
+ * @typedef {object} PopulationNode
128
+ * @property {string} shortcode Lower case.
129
+ * @property {"place"|"affiliation"} type
130
+ * @property {string} subType
131
+ * @property {string} title What a finding calls it.
132
+ * @property {number} [population] The stated figure, absent where unstated.
133
+ * @property {string[]} parents A place's enclosing places, an affiliation's
134
+ * superiors.
135
+ * @property {string[]} domains What an affiliation holds.
136
+ */
137
+
138
+ /**
139
+ * The whole tree, indexed the three ways the rules read it.
140
+ *
141
+ * @typedef {object} Corpus
142
+ * @property {Map<string, PopulationNode>} places By shortcode.
143
+ * @property {Map<string, PopulationNode>} affiliations By shortcode.
144
+ * @property {Map<string, string[]>} childrenOf Place shortcode → the places
145
+ * whose `parents` name it.
146
+ * @property {Map<string, string[]>} holdersOf Place shortcode → the polities
147
+ * whose `domains` name it.
148
+ * @property {Map<string, PopulationNode>} byAddress `type-shortcode` → the node,
149
+ * for the citation rule.
150
+ */
151
+
152
+ /**
153
+ * A `data.population` value as a number, or `undefined`.
154
+ *
155
+ * A figure is a positive count. Anything else — a blank, a string of prose, a
156
+ * zero — states nothing this module can compare, and is read as unstated
157
+ * rather than as a figure of zero, which would make every sum a finding.
158
+ *
159
+ * @param {unknown} value - The authored value.
160
+ * @returns {number|undefined} The figure, or `undefined`.
161
+ */
162
+ function figureOf(value) {
163
+ const n = typeof value === "number" ? value : Number(value);
164
+ return Number.isFinite(n) && n > 0 ? n : undefined;
165
+ }
166
+
167
+ /**
168
+ * A node's `data` block, or an empty one.
169
+ *
170
+ * @param {object|undefined} fm - The frontmatter.
171
+ * @returns {object} The block.
172
+ */
173
+ function dataOf(fm) {
174
+ const data = fm?.data;
175
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
176
+ }
177
+
178
+ /**
179
+ * A count as a finding spells it.
180
+ *
181
+ * @param {number} n - The figure.
182
+ * @returns {string} Grouped in threes.
183
+ */
184
+ function counted(n) {
185
+ return n.toLocaleString("en-US");
186
+ }
187
+
188
+ /**
189
+ * Read one local note into a node, or `null` where it takes no part.
190
+ *
191
+ * @param {object} note - A note from the link index.
192
+ * @returns {PopulationNode|null} The node.
193
+ */
194
+ function localNode(note) {
195
+ const fm = note.fm ?? {};
196
+ const type = String(fm.type ?? note.type ?? "");
197
+ if (type !== "place" && type !== "affiliation") return null;
198
+ const shortcode = String(fm.shortcode ?? "").toLowerCase();
199
+ if (!shortcode) return null;
200
+ const data = dataOf(fm);
201
+ return {
202
+ shortcode,
203
+ type,
204
+ subType: String(fm.subType ?? ""),
205
+ title: String(fm.name?.full ?? shortcode),
206
+ population: figureOf(data.population),
207
+ parents: shortcodesOf(data.parents),
208
+ domains: type === "affiliation" ? shortcodesOf(data.domains) : [],
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Read a fetched index entry into a node, or `null` where it takes no part.
214
+ *
215
+ * A fetched entry carries the structure its record stated and no figure, so
216
+ * a dependency's place sits in the geography and counts nobody.
217
+ *
218
+ * @param {string} canonical - The entry's address.
219
+ * @param {object} entry - The entry.
220
+ * @returns {PopulationNode|null} The node.
221
+ */
222
+ function foreignNode(canonical, entry) {
223
+ const type = String(entry?.type ?? "");
224
+ if (type !== "place" && type !== "affiliation") return null;
225
+ const shortcode = canonical.split("-").pop()?.toLowerCase() ?? "";
226
+ if (!shortcode) return null;
227
+ return {
228
+ shortcode,
229
+ type,
230
+ subType: String(entry.subType ?? ""),
231
+ title: String(entry.name ?? shortcode),
232
+ parents: shortcodesOf(entry.parents),
233
+ domains: type === "affiliation" ? shortcodesOf(entry.domains) : [],
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The corpus a link index describes.
239
+ *
240
+ * A shortcode is declared once per type and the first declaration wins, so a
241
+ * local note shadows a dependency's the way the link resolver answers.
242
+ *
243
+ * @param {object} index - The link index.
244
+ * @returns {Corpus} The corpus.
245
+ */
246
+ function corpusOf(index) {
247
+ let found = CORPUS.get(index);
248
+ if (found) return found;
249
+
250
+ /** @type {Map<string, PopulationNode>} */
251
+ const places = new Map();
252
+ /** @type {Map<string, PopulationNode>} */
253
+ const affiliations = new Map();
254
+ /** @type {Map<string, PopulationNode>} */
255
+ const byAddress = new Map();
256
+
257
+ const take = (node) => {
258
+ if (!node) return;
259
+ const by = node.type === "place" ? places : affiliations;
260
+ if (by.has(node.shortcode)) return;
261
+ by.set(node.shortcode, node);
262
+ const address = `${node.type}-${node.shortcode}`;
263
+ if (!byAddress.has(address)) byAddress.set(address, node);
264
+ };
265
+
266
+ for (const note of index.notes ?? []) take(localNode(note));
267
+ for (const [canonical, entry] of index.foreign?.index ?? [])
268
+ take(foreignNode(canonical, entry));
269
+
270
+ /** @type {Map<string, string[]>} */
271
+ const childrenOf = new Map();
272
+ for (const node of places.values()) {
273
+ for (const parent of node.parents) {
274
+ const list = childrenOf.get(parent);
275
+ if (list) list.push(node.shortcode);
276
+ else childrenOf.set(parent, [node.shortcode]);
277
+ }
278
+ }
279
+
280
+ /** @type {Map<string, string[]>} */
281
+ const holdersOf = new Map();
282
+ for (const node of affiliations.values()) {
283
+ if (node.subType !== "polity") continue;
284
+ for (const domain of node.domains) {
285
+ const list = holdersOf.get(domain);
286
+ if (list) list.push(node.shortcode);
287
+ else holdersOf.set(domain, [node.shortcode]);
288
+ }
289
+ }
290
+
291
+ found = { places, affiliations, childrenOf, holdersOf, byAddress };
292
+ CORPUS.set(index, found);
293
+ return found;
294
+ }
295
+
296
+ /**
297
+ * Every place below one, by `parents`.
298
+ *
299
+ * @param {Corpus} corpus - The corpus.
300
+ * @param {string} shortcode - Where to start.
301
+ * @returns {Set<string>} The descendants, the starting place excluded.
302
+ */
303
+ function descendants(corpus, shortcode) {
304
+ const seen = new Set();
305
+ const queue = [...(corpus.childrenOf.get(shortcode) ?? [])];
306
+ while (queue.length) {
307
+ const next = queue.pop();
308
+ if (!next || seen.has(next)) continue;
309
+ seen.add(next);
310
+ queue.push(...(corpus.childrenOf.get(next) ?? []));
311
+ }
312
+ return seen;
313
+ }
314
+
315
+ /**
316
+ * Every place above one, by `parents`.
317
+ *
318
+ * @param {Corpus} corpus - The corpus.
319
+ * @param {string} shortcode - Where to start.
320
+ * @returns {string[]} The ancestors, nearest first, the starting place
321
+ * excluded and a cycle broken.
322
+ */
323
+ function ancestors(corpus, shortcode) {
324
+ const seen = new Set();
325
+ const out = [];
326
+ const queue = [...(corpus.places.get(shortcode)?.parents ?? [])];
327
+ while (queue.length) {
328
+ const next = queue.shift();
329
+ if (!next || next === shortcode || seen.has(next)) continue;
330
+ const node = corpus.places.get(next);
331
+ if (!node) continue;
332
+ seen.add(next);
333
+ out.push(next);
334
+ queue.push(...node.parents);
335
+ }
336
+ return out;
337
+ }
338
+
339
+ /**
340
+ * The polities holding a place, each counted once.
341
+ *
342
+ * A polity subordinate to another polity that holds the same place is already
343
+ * inside that polity's figure, so only the topmost of a chain counts.
344
+ *
345
+ * @param {Corpus} corpus - The corpus.
346
+ * @param {string} shortcode - The place.
347
+ * @returns {PopulationNode[]} The holders.
348
+ */
349
+ function topHolders(corpus, shortcode) {
350
+ const holders = corpus.holdersOf.get(shortcode) ?? [];
351
+ const held = new Set(holders);
352
+ return holders
353
+ .map((name) => corpus.affiliations.get(name))
354
+ .filter(
355
+ /** @returns {node is PopulationNode} */
356
+ (node) => Boolean(node) && !(node?.parents ?? []).some((p) => held.has(p)),
357
+ );
358
+ }
359
+
360
+ /**
361
+ * The regions immediately inside a place.
362
+ *
363
+ * A place may name both its region and that region's continent as parents.
364
+ * It is counted under the nearer, so a region naming a second parent that is
365
+ * itself inside this place is left to that parent.
366
+ *
367
+ * @param {Corpus} corpus - The corpus.
368
+ * @param {string} shortcode - The enclosing place.
369
+ * @returns {PopulationNode[]} The regions.
370
+ */
371
+ function nearestRegions(corpus, shortcode) {
372
+ const inside = descendants(corpus, shortcode);
373
+ const out = [];
374
+ for (const child of corpus.childrenOf.get(shortcode) ?? []) {
375
+ const node = corpus.places.get(child);
376
+ if (!node || node.subType !== "region") continue;
377
+ if (node.parents.some((p) => p !== shortcode && inside.has(p))) continue;
378
+ out.push(node);
379
+ }
380
+ return out;
381
+ }
382
+
383
+ /**
384
+ * A finding on the note's own `data.population`.
385
+ *
386
+ * @param {object} note - The note.
387
+ * @param {string} rule - The rule's name.
388
+ * @param {string} message - What it found, after the name.
389
+ * @returns {object} The finding.
390
+ */
391
+ function atFigure(note, rule, message) {
392
+ return {
393
+ file: note.file,
394
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "population"]),
395
+ severity: "warning",
396
+ message: `${rule}: ${message}`,
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Check a place's population against the corpus around it.
402
+ *
403
+ * Declared on the `place` vocabulary beside `data.population`, so the lint
404
+ * runs it with the index every reference check resolves through. Without an
405
+ * index the questions cannot be asked and nothing is reported.
406
+ *
407
+ * Three rules, each reported on the note being linted: `over-held land` and
408
+ * `over-full region` on the place whose figure is exceeded, and
409
+ * `oversized settlement` on the settlement that exceeds one.
410
+ *
411
+ * @param {object} note - The note, as the link index hands it over.
412
+ * @param {object} [opts]
413
+ * @param {object} [opts.index] - The link index, holding the whole corpus.
414
+ * @returns {object[]} Findings.
415
+ */
416
+ export function checkPopulation(note, { index } = {}) {
417
+ if (!index) return [];
418
+ const fm = note.fm ?? {};
419
+ if (String(fm.type ?? "") !== "place") return [];
420
+ const shortcode = String(fm.shortcode ?? "").toLowerCase();
421
+ if (!shortcode) return [];
422
+ const stated = figureOf(dataOf(fm).population);
423
+ if (stated === undefined) return [];
424
+
425
+ const corpus = corpusOf(index);
426
+ const self = corpus.places.get(shortcode);
427
+ const title = self?.title ?? shortcode;
428
+ const subType = self?.subType ?? String(fm.subType ?? "");
429
+ const allowed = stated * POPULATION_TOLERANCE;
430
+ const findings = [];
431
+
432
+ // A settlement is exempt: the polity holding a city-state counts the
433
+ // hinterland the city does not, so its figure is properly the larger.
434
+ if (subType !== "settlement") {
435
+ const holders = topHolders(corpus, shortcode);
436
+ const sum = holders.reduce((total, node) => total + (node.population ?? 0), 0);
437
+ if (sum > allowed) {
438
+ findings.push(
439
+ atFigure(
440
+ note,
441
+ "over-held land",
442
+ `the polities holding ${title} count ${counted(sum)} people against its ` +
443
+ `stated ${counted(stated)}`,
444
+ ),
445
+ );
446
+ }
447
+ }
448
+
449
+ const regions = nearestRegions(corpus, shortcode);
450
+ const inside = regions.reduce((total, node) => total + (node.population ?? 0), 0);
451
+ if (inside > allowed) {
452
+ findings.push(
453
+ atFigure(
454
+ note,
455
+ "over-full region",
456
+ `the regions inside ${title} count ${counted(inside)} people against its ` +
457
+ `stated ${counted(stated)}`,
458
+ ),
459
+ );
460
+ }
461
+
462
+ if (subType === "settlement") {
463
+ for (const name of ancestors(corpus, shortcode)) {
464
+ const above = corpus.places.get(name);
465
+ const room = above?.population;
466
+ if (room === undefined || stated <= room * POPULATION_TOLERANCE) continue;
467
+ findings.push(
468
+ atFigure(
469
+ note,
470
+ "oversized settlement",
471
+ `${title} holds ${counted(stated)}, more than the ${counted(room)} ` +
472
+ `stated by ${above?.title}`,
473
+ ),
474
+ );
475
+ }
476
+ }
477
+
478
+ return findings;
479
+ }
480
+
481
+ /**
482
+ * A wikilink to a place or an affiliation followed by an approximate figure.
483
+ *
484
+ * The figure must carry `~`, which is how a note marks a number as an
485
+ * estimate. That is what separates a population from every other number a
486
+ * table puts beside a place — a distance, a stage count, a tally of days — and
487
+ * without it a routes table would be read as a census.
488
+ *
489
+ * The span between the link and the figure crosses neither a newline nor
490
+ * another wikilink, so a row's figure belongs to that row's link.
491
+ *
492
+ * **The figure is read whole or not at all.** A number carrying a magnitude
493
+ * letter, a decimal, a percent sign or a dash into a second number is prose
494
+ * about a figure rather than the figure — `~1M`, `~1.5M`, `~5%`, `~12–14M` —
495
+ * and reading its leading digits would compare `1` against a million. Those
496
+ * are left alone, so an aside stays an aside and the rule holds the tables
497
+ * that state a count exactly.
498
+ *
499
+ * @type {RegExp}
500
+ */
501
+ const CITED =
502
+ /\[\[((?:place|affiliation)-[a-z0-9.]+)(?:\\?\|[^\]]*)?\]\]([^[\n]{0,80}?)~([0-9](?:[0-9,]*[0-9])?)(?![\w%]|[.,]\d|[-–—])/g;
503
+
504
+ /**
505
+ * Check the populations a `doc` note cites against the notes they belong to.
506
+ *
507
+ * A reference page restates figures the place and affiliation notes own, and
508
+ * restated figures drift. Each is located at the figure it wrote, counting
509
+ * repeats so two rows carrying one number land on their own lines.
510
+ *
511
+ * A citation naming a note this package does not hold is skipped: a fetched
512
+ * index carries no figure, so there is nothing to disagree with.
513
+ *
514
+ * Declared as the `doc` vocabulary's type-level check.
515
+ *
516
+ * @param {object} note - The note, as the link index hands it over.
517
+ * @param {object} [opts]
518
+ * @param {object} [opts.index] - The link index, holding the whole corpus.
519
+ * @returns {object[]} Findings.
520
+ */
521
+ export function checkCitedPopulations(note, { index } = {}) {
522
+ if (!index) return [];
523
+ if (String(note.fm?.type ?? "") !== "doc") return [];
524
+ const raw = String(note.raw ?? "");
525
+ const body = String(note.body ?? "");
526
+ if (!body) return [];
527
+
528
+ const corpus = corpusOf(index);
529
+ const offset = raw.length - body.length;
530
+ const findings = [];
531
+
532
+ CITED.lastIndex = 0;
533
+ for (const match of body.matchAll(CITED)) {
534
+ const [, address, , figure] = match;
535
+ const target = corpus.byAddress.get(address.toLowerCase());
536
+ if (!target || target.population === undefined) continue;
537
+ const claimed = Number(figure.replace(/,/g, ""));
538
+ if (!Number.isFinite(claimed) || claimed === target.population) continue;
539
+
540
+ // Which `~figure` in the file this is, so a number written twice is
541
+ // located twice rather than both findings landing on the first.
542
+ const needle = `~${figure}`;
543
+ const at = offset + (match.index ?? 0) + match[0].length - needle.length;
544
+ const occurrence = raw.slice(0, at).split(needle).length;
545
+
546
+ findings.push({
547
+ file: note.file,
548
+ ...positionOfLiteral(raw, needle, occurrence),
549
+ severity: "warning",
550
+ message:
551
+ `disputed figure: ${target.title} is carried at ${counted(claimed)} here and ` +
552
+ `${counted(target.population)} on its own note`,
553
+ });
554
+ }
555
+
556
+ return findings;
557
+ }