@heroiclands/package-build 0.4.0 → 0.6.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.
package/coverage.mjs ADDED
@@ -0,0 +1,626 @@
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 the keys a package **references** and the keys it **declares** are
16
+ * the same set.
17
+ *
18
+ * A missing key renders in the interface as its own raw key string; a key
19
+ * nothing references is dead weight a translator is nonetheless asked to
20
+ * translate. Both are invisible until someone plays in another language, which
21
+ * is to say invisible.
22
+ *
23
+ * The two directions are deliberately not the same severity:
24
+ *
25
+ * - **A referenced key that is not declared is an error.** Something will
26
+ * render as `SOHL.Skill.label` to a player.
27
+ * - **A declared key nothing references is advisory.** No scan can see every
28
+ * way a key is reached — a prefix held in a variable, a value read back out
29
+ * of a document — so a package that ships one is not broken, and refusing to
30
+ * ship it over one would teach everybody to switch the guard off.
31
+ *
32
+ * **What is generic, and what is not.** `{{localize}}`, `game.i18n.localize`,
33
+ * a key in a string literal, a key built from a template literal, a DataModel's
34
+ * `LOCALIZATION_PREFIXES` — those are Foundry, and they live here. A repository
35
+ * that *generates* keys by a convention of its own (Song of Heroic Lands mints
36
+ * one per member of an enum) contributes them through a module it names in
37
+ * configuration, the same shape as `assetTransform` and `manifestFlags`: only
38
+ * that repository can know the rule, and only this package can compare the
39
+ * result against the file.
40
+ *
41
+ * Everything here is pure — source text in, references or findings out.
42
+ *
43
+ * @module
44
+ */
45
+
46
+ import ts from "typescript";
47
+ import { positionOfLiteral } from "@heroiclands/content-build/engine/diagnostics";
48
+
49
+ /**
50
+ * One place a key is referenced, and how firmly.
51
+ *
52
+ * @typedef {object} KeyReference
53
+ * @property {string} key - The localization key, in full.
54
+ * @property {string} file - Where it is referenced, relative to the repository
55
+ * root — the reference is the finding's site, not the localization file.
56
+ * @property {number} [line] - 1-based line, when it can be established.
57
+ * @property {number} [column] - 1-based column, likewise.
58
+ * @property {boolean} [exact] - When true the key must be declared verbatim,
59
+ * even if it happens to be a prefix of keys that are. A *generated* key is
60
+ * minted whole, so keys sitting beneath it do not vouch for it; an ordinary
61
+ * textual reference to a family name does not have that property.
62
+ * @property {string} [origin] - The verb phrase naming how the key is
63
+ * referenced, for the message: `references` by default, so a contributor of
64
+ * generated keys can say `defineType generates` instead.
65
+ */
66
+
67
+ /**
68
+ * Everything one scan learned about how a file addresses localization.
69
+ *
70
+ * @typedef {object} ReferenceSet
71
+ * @property {KeyReference[]} keys - Concrete keys, each at its site.
72
+ * @property {string[]} namespaces - Prefixes whose leaves are never named in
73
+ * source: a DataModel's `LOCALIZATION_PREFIXES`, the static head of a key
74
+ * built at runtime. A namespace vouches for *itself* being reachable, never
75
+ * for the keys beneath it.
76
+ * @property {string[]} patterns - Key shapes a dynamic construction can build,
77
+ * with `*` standing for one segment: `` `SOHL.Month.${i}.label` `` is
78
+ * `SOHL.Month.*.label`, and vouches for exactly what that expression can
79
+ * produce.
80
+ * @property {CoverageFinding[]} findings - What the scan could not resolve, in
81
+ * its own words.
82
+ */
83
+
84
+ /**
85
+ * A finding about coverage.
86
+ *
87
+ * Unlike the rules in {@link module:lang}, these carry their own `file`: one
88
+ * run spans the localization file and every source that references it, so a
89
+ * single path supplied by the caller could not be right for all of them.
90
+ *
91
+ * @typedef {object} CoverageFinding
92
+ * @property {string} file - Path the finding is about.
93
+ * @property {number} [line] - 1-based line, when known.
94
+ * @property {number} [column] - 1-based column, when known.
95
+ * @property {"error"|"warning"} severity - How it should be treated.
96
+ * @property {string} message - What is wrong, in one sentence.
97
+ */
98
+
99
+ /** A key segment carries only these characters. */
100
+ const SEGMENT_CHARS = "[A-Za-z0-9_]";
101
+
102
+ /**
103
+ * Escape a string for literal use inside a `RegExp`.
104
+ *
105
+ * @param {string} text - The literal.
106
+ * @returns {string} It, with every metacharacter escaped.
107
+ */
108
+ function escapeRegExp(text) {
109
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ }
111
+
112
+ /**
113
+ * The roots a set of declared keys uses.
114
+ *
115
+ * Derived rather than configured by default, because a package's roots are
116
+ * already a fact about its localization file — asking for them again is one
117
+ * more thing to state and to get wrong. A repository states them only when it
118
+ * references a root the file does not yet declare at all.
119
+ *
120
+ * @param {Iterable<string>} keys - The declared keys.
121
+ * @returns {string[]} The distinct first segments, in name order.
122
+ */
123
+ export function keyRootsOf(keys) {
124
+ const roots = new Set();
125
+ for (const key of keys) {
126
+ const root = key.split(".")[0];
127
+ if (root) roots.add(root);
128
+ }
129
+ return [...roots].sort();
130
+ }
131
+
132
+ /**
133
+ * The two patterns every scan works from, built for one set of roots.
134
+ *
135
+ * The alternation is ordered longest first: it is tried in order, so `TYPE`
136
+ * ahead of `TYPES` would claim the first four characters of every `TYPES.…`
137
+ * key and leave the `S` to be read as the start of a segment.
138
+ *
139
+ * @param {readonly string[]} roots - The key roots.
140
+ * @returns {{whole: RegExp, scan: RegExp}} `whole` tests a complete string;
141
+ * `scan` finds tokens inside a longer text.
142
+ */
143
+ function patternsFor(roots) {
144
+ const alternation = roots
145
+ .slice()
146
+ .sort((a, b) => b.length - a.length || a.localeCompare(b))
147
+ .map(escapeRegExp)
148
+ .join("|");
149
+ const body = `(?:${alternation})\\.${SEGMENT_CHARS}+(?:\\.${SEGMENT_CHARS}+)*`;
150
+ return {
151
+ whole: new RegExp(`^${body}$`),
152
+ // Not preceded by a word character or a dot, so the `TYPES.base` inside
153
+ // Foundry's own `BEHAVIOR.TYPES.base` is not read as a key of this
154
+ // package's.
155
+ scan: new RegExp(`(?<![A-Za-z0-9_.])${body}`, "g"),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * An empty reference set, for a caller with nothing to contribute.
161
+ *
162
+ * @returns {ReferenceSet} The set.
163
+ */
164
+ function emptySet() {
165
+ return { keys: [], namespaces: [], patterns: [], findings: [] };
166
+ }
167
+
168
+ /**
169
+ * Where an offset sits in a text.
170
+ *
171
+ * @param {string} source - The text.
172
+ * @param {number} index - A 0-based offset into it.
173
+ * @returns {{line: number, column: number}} The 1-based position.
174
+ */
175
+ function positionAt(source, index) {
176
+ const before = source.slice(0, index);
177
+ return {
178
+ line: before.split("\n").length,
179
+ column: index - before.lastIndexOf("\n"),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The static namespace a dynamically built key belongs to.
185
+ *
186
+ * `SOHL.Actor.` yields `SOHL.Actor`; a head that stops mid-segment because a
187
+ * substitution completes it (`SOHL.Actor.Skill`) yields `SOHL.Actor`.
188
+ *
189
+ * @param {string} head - The literal text before the first substitution.
190
+ * @param {readonly string[]} roots - The key roots.
191
+ * @returns {string|null} The namespace, or `null` when it is not under a root.
192
+ */
193
+ function namespaceOfHead(head, roots) {
194
+ const text = head.replace(/[A-Za-z0-9_]*$/, "").replace(/\.$/, "");
195
+ const under = roots.some((r) => text === r || text.startsWith(`${r}.`));
196
+ return text && under ? text : null;
197
+ }
198
+
199
+ /**
200
+ * The key shape a literal can produce, with `*` standing for one segment.
201
+ *
202
+ * A trailing dot is a substitution too — `'SOHL.Skill.' + kind` builds exactly
203
+ * what a template literal would, and the guard should not care which spelling a
204
+ * file used.
205
+ *
206
+ * @param {string} literal - Literal text, substitutions written as `${…}`.
207
+ * @returns {string|null} The pattern, or `null` when nothing is substituted.
208
+ */
209
+ function shapeOf(literal) {
210
+ if (literal.includes("${")) return literal.replace(/\$\{[^}]*\}/g, "*");
211
+ return literal.endsWith(".") ? `${literal}*` : null;
212
+ }
213
+
214
+ /**
215
+ * Read every localization reference out of a script.
216
+ *
217
+ * The **AST**, not the text, because a key named in a JSDoc `@example` is
218
+ * documentation: requiring it to exist would make the guard fail on prose, and
219
+ * counting it as a reference would let a comment keep a dead key alive.
220
+ *
221
+ * TypeScript's parser reads plain JavaScript too, so both go down one path
222
+ * rather than two that drift.
223
+ *
224
+ * @param {string} source - The file's contents.
225
+ * @param {object} options
226
+ * @param {string} options.file - Path to the file, for the findings.
227
+ * @param {readonly string[]} options.roots - The key roots.
228
+ * @returns {ReferenceSet} What the file references.
229
+ */
230
+ export function collectScriptReferences(source, { file, roots }) {
231
+ const { whole, scan } = patternsFor(roots);
232
+ const result = emptySet();
233
+ const sourceFile = ts.createSourceFile(
234
+ file,
235
+ source,
236
+ ts.ScriptTarget.Latest,
237
+ true,
238
+ );
239
+
240
+ // `parseDiagnostics` is TypeScript's own and not part of its published
241
+ // surface, hence the guard. A file that does not parse contributes no
242
+ // references at all, which would quietly read as "needs nothing and
243
+ // declares nothing" — worth saying out loud wherever it can be.
244
+ const parseErrors = sourceFile.parseDiagnostics ?? [];
245
+ if (parseErrors.length) {
246
+ const [first] = parseErrors;
247
+ result.findings.push({
248
+ file,
249
+ ...positionAt(source, first.start ?? 0),
250
+ severity: "error",
251
+ message:
252
+ "does not parse, so its localization keys cannot be read: " +
253
+ ts.flattenDiagnosticMessageText(first.messageText, " "),
254
+ });
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * Record a concrete key, located by searching the node it came from.
260
+ *
261
+ * @param {string} key - The key.
262
+ * @param {number} from - Offset to search from — the node's own start, so
263
+ * an earlier occurrence elsewhere in the file is not credited to it.
264
+ */
265
+ const addKey = (key, from) => {
266
+ const at = source.indexOf(key, from);
267
+ result.keys.push({
268
+ key,
269
+ file,
270
+ ...(at === -1 ? {} : positionAt(source, at)),
271
+ });
272
+ };
273
+
274
+ /**
275
+ * Whether a node is a string with no substitutions in it.
276
+ *
277
+ * @param {ts.Node|undefined} node - The node.
278
+ * @returns {boolean} Whether its `.text` is the whole literal.
279
+ */
280
+ const isPlainString = (node) =>
281
+ Boolean(
282
+ node &&
283
+ (ts.isStringLiteral(node) ||
284
+ ts.isNoSubstitutionTemplateLiteral(node)),
285
+ );
286
+
287
+ /**
288
+ * Read the concrete keys out of one literal chunk of a template.
289
+ *
290
+ * A chunk that abuts a substitution is *open* at that end, and a token
291
+ * touching an open end is only half a key — the expression completes it.
292
+ * Crediting it anyway invents a key the file does not contain
293
+ * (`SOHL.Skill.Action.` from `` `SOHL.Skill.Action.${kind}Test` ``) and
294
+ * then reports it missing.
295
+ *
296
+ * @param {string} chunk - The literal text.
297
+ * @param {object} ends
298
+ * @param {boolean} ends.openStart - Whether a substitution precedes it.
299
+ * @param {boolean} ends.openEnd - Whether one follows it.
300
+ * @param {number} from - Offset to locate the keys from.
301
+ */
302
+ const addChunkKeys = (chunk, { openStart, openEnd }, from) => {
303
+ for (const match of chunk.matchAll(scan)) {
304
+ const start = match.index ?? 0;
305
+ if (openStart && start === 0) continue;
306
+ // A trailing dot is not a segment, so a token followed by only one
307
+ // is touching the end just as much as a token flush against it.
308
+ if (openEnd && /^\.?$/.test(chunk.slice(start + match[0].length))) {
309
+ continue;
310
+ }
311
+ addKey(match[0], from);
312
+ }
313
+ };
314
+
315
+ /** Elements of a `LOCALIZATION_PREFIXES` array, which are not keys. */
316
+ const prefixLiterals = new Set();
317
+
318
+ const visit = (node) => {
319
+ if (
320
+ ts.isStringLiteral(node) &&
321
+ whole.test(node.text) &&
322
+ !prefixLiterals.has(node)
323
+ ) {
324
+ addKey(node.text, node.getStart());
325
+ }
326
+
327
+ // A template with no substitutions is one closed chunk. Its keys are
328
+ // not string-literal nodes — inline markup in a helper puts them inside
329
+ // the literal's text — so they would otherwise be invisible.
330
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
331
+ addChunkKeys(
332
+ node.text,
333
+ { openStart: false, openEnd: false },
334
+ node.getStart(),
335
+ );
336
+ }
337
+
338
+ if (ts.isTemplateExpression(node)) {
339
+ const spans = node.templateSpans;
340
+ addChunkKeys(
341
+ node.head.text,
342
+ { openStart: false, openEnd: true },
343
+ node.getStart(),
344
+ );
345
+ spans.forEach((span, index) => {
346
+ addChunkKeys(
347
+ span.literal.text,
348
+ { openStart: true, openEnd: index < spans.length - 1 },
349
+ node.getStart(),
350
+ );
351
+ });
352
+
353
+ const namespace = namespaceOfHead(node.head.text, roots);
354
+ if (namespace) {
355
+ result.namespaces.push(namespace);
356
+ let literal = node.head.text;
357
+ for (const span of node.templateSpans) {
358
+ literal += "${}" + span.literal.text;
359
+ }
360
+ const pattern = shapeOf(literal);
361
+ if (pattern) result.patterns.push(pattern);
362
+ }
363
+ }
364
+
365
+ // A DataModel names the prefix and Foundry localizes the leaves under
366
+ // it, so those leaves are never named in any source file.
367
+ if (
368
+ (ts.isPropertyDeclaration(node) || ts.isPropertyAssignment(node)) &&
369
+ node.name?.getText(sourceFile).replace(/["']/g, "") ===
370
+ "LOCALIZATION_PREFIXES" &&
371
+ node.initializer &&
372
+ ts.isArrayLiteralExpression(node.initializer)
373
+ ) {
374
+ for (const element of node.initializer.elements) {
375
+ if (!isPlainString(element)) continue;
376
+ // A prefix names a family; Foundry mints the leaves. Counting
377
+ // it as a concrete key as well would let a DataModel's
378
+ // declaration vouch for a key of the same name.
379
+ prefixLiterals.add(element);
380
+ result.namespaces.push(element.text);
381
+ }
382
+ }
383
+
384
+ ts.forEachChild(node, visit);
385
+ };
386
+ visit(sourceFile);
387
+
388
+ return result;
389
+ }
390
+
391
+ /**
392
+ * Read every localization reference out of a template.
393
+ *
394
+ * A text scan, because a template has no AST worth building for this: its keys
395
+ * sit in `{{localize "…"}}` calls and in helper hashes, and nothing in a `.hbs`
396
+ * file resembles a comment closely enough to mislead one.
397
+ *
398
+ * @param {string} source - The file's contents.
399
+ * @param {object} options
400
+ * @param {string} options.file - Path to the file, for the findings.
401
+ * @param {readonly string[]} options.roots - The key roots.
402
+ * @returns {ReferenceSet} What the template references.
403
+ */
404
+ export function collectTemplateReferences(source, { file, roots }) {
405
+ const { scan } = patternsFor(roots);
406
+ const result = emptySet();
407
+
408
+ for (const match of source.matchAll(scan)) {
409
+ const token = match[0];
410
+ const index = match.index ?? 0;
411
+ const rest = source.slice(index + token.length);
412
+
413
+ // The token is a *prefix* when something is appended to it: a
414
+ // substitution, or a literal that stops on a dot.
415
+ if (!/^(?:\$\{|\.(?:\$\{|["'`]))/.test(rest)) {
416
+ result.keys.push({
417
+ key: token,
418
+ file,
419
+ ...positionAt(source, index),
420
+ });
421
+ continue;
422
+ }
423
+
424
+ // A substitution written directly against the token completes its last
425
+ // segment, so the namespace is one segment shorter than the token.
426
+ const namespace =
427
+ rest.startsWith("${") ?
428
+ token.replace(/\.[A-Za-z0-9_]*$/, "")
429
+ : token;
430
+ result.namespaces.push(namespace);
431
+
432
+ // Recover the whole literal so its shape, not merely its head, decides
433
+ // what it vouches for. The quote that opened it is the character before
434
+ // the token; without one there is nothing to recover, and the namespace
435
+ // stands alone.
436
+ const quote = source[index - 1];
437
+ if (quote === "'" || quote === '"' || quote === "`") {
438
+ const end = source.indexOf(quote, index);
439
+ const pattern = shapeOf(
440
+ source.slice(index, end === -1 ? undefined : end),
441
+ );
442
+ if (pattern) result.patterns.push(pattern);
443
+ }
444
+ }
445
+
446
+ return result;
447
+ }
448
+
449
+ /**
450
+ * Combine reference sets into one.
451
+ *
452
+ * Keys are kept whole — every site that references a missing key is worth
453
+ * naming — while namespaces and patterns are de-duplicated, since neither says
454
+ * anything about where it came from.
455
+ *
456
+ * @param {Iterable<ReferenceSet>} sets - The sets to combine.
457
+ * @returns {ReferenceSet} One set holding all of them.
458
+ */
459
+ export function mergeReferences(sets) {
460
+ const merged = emptySet();
461
+ const namespaces = new Set();
462
+ const patterns = new Set();
463
+ for (const set of sets) {
464
+ merged.keys.push(...(set.keys ?? []));
465
+ merged.findings.push(...(set.findings ?? []));
466
+ for (const namespace of set.namespaces ?? []) namespaces.add(namespace);
467
+ for (const pattern of set.patterns ?? []) patterns.add(pattern);
468
+ }
469
+ merged.namespaces = [...namespaces];
470
+ merged.patterns = [...patterns];
471
+ return merged;
472
+ }
473
+
474
+ /**
475
+ * Turn a key pattern into the expression that matches what it can build.
476
+ *
477
+ * @param {string} pattern - A shape, `*` standing for one segment.
478
+ * @returns {RegExp} The matcher.
479
+ */
480
+ function matcherFor(pattern) {
481
+ return new RegExp(
482
+ `^${pattern.split("*").map(escapeRegExp).join("[^.]+")}$`,
483
+ );
484
+ }
485
+
486
+ /**
487
+ * Compare what a package declares against what it references.
488
+ *
489
+ * @param {object} options
490
+ * @param {string} options.langSource - The reference localization file's text.
491
+ * @param {string} options.langFile - Its path, for the findings about it.
492
+ * @param {ReferenceSet} options.references - Everything that references it.
493
+ * @param {readonly string[]} [options.retained] - Key prefixes to leave out of
494
+ * the advisory half. Each is a repository's statement that the keys under it
495
+ * are reached in a way no scan can see; the honest fix for an unreferenced
496
+ * key is still to delete it.
497
+ * @param {readonly string[]} [options.roots] - The key roots. Derived from the
498
+ * declared keys when absent.
499
+ * @returns {{findings: CoverageFinding[], unreferenced: CoverageFinding[],
500
+ * stats: object}} What must be fixed, what is merely worth reading, and what
501
+ * the run looked at. The two are separate because they are different
502
+ * questions: one says the package is broken, the other that it carries
503
+ * something nobody could see a use for.
504
+ */
505
+ export function analyzeCoverage({
506
+ langSource,
507
+ langFile,
508
+ references,
509
+ retained = [],
510
+ roots,
511
+ }) {
512
+ let declared;
513
+ try {
514
+ declared = JSON.parse(langSource);
515
+ } catch (err) {
516
+ // Nothing further can be said about a file that does not parse, and
517
+ // every key in the package would otherwise report as undeclared.
518
+ return {
519
+ findings: [
520
+ {
521
+ file: langFile,
522
+ severity: "error",
523
+ message: `not valid JSON: ${err.message}`,
524
+ },
525
+ ],
526
+ unreferenced: [],
527
+ stats: {
528
+ declared: 0,
529
+ referenced: 0,
530
+ namespaces: 0,
531
+ patterns: 0,
532
+ missing: 0,
533
+ unreferenced: 0,
534
+ },
535
+ };
536
+ }
537
+
538
+ const declaredKeys = Object.keys(declared);
539
+ const declaredSet = new Set(declaredKeys);
540
+ const known = roots ?? keyRootsOf(declaredKeys);
541
+ const namespaces = new Set(references.namespaces ?? []);
542
+
543
+ /**
544
+ * Whether a token is a family name rather than a key in its own right.
545
+ *
546
+ * @param {string} token - The referenced token.
547
+ * @returns {boolean} Whether it is declared as a namespace, or something
548
+ * declared sits beneath it.
549
+ */
550
+ const isNamespace = (token) => {
551
+ if (namespaces.has(token)) return true;
552
+ const prefix = `${token}.`;
553
+ for (const key of declaredSet) if (key.startsWith(prefix)) return true;
554
+ return false;
555
+ };
556
+
557
+ const missing = [];
558
+ const seen = new Set();
559
+ for (const reference of references.keys ?? []) {
560
+ const { key, file } = reference;
561
+ if (declaredSet.has(key)) continue;
562
+ if (!reference.exact && isNamespace(key)) continue;
563
+ // One finding per site, not per occurrence: a file that localizes the
564
+ // same missing key in six rows is one thing to fix.
565
+ const site = `${key} ${file}`;
566
+ if (seen.has(site)) continue;
567
+ seen.add(site);
568
+ missing.push({
569
+ file,
570
+ ...(reference.line === undefined ? {} : { line: reference.line }),
571
+ ...(reference.line !== undefined && reference.column !== undefined ?
572
+ { column: reference.column }
573
+ : {}),
574
+ severity: "error",
575
+ message:
576
+ `${reference.origin ?? "references"} "${key}", which ` +
577
+ `${langFile} does not declare`,
578
+ });
579
+ }
580
+
581
+ const referenced = new Set(
582
+ (references.keys ?? []).map((reference) => reference.key),
583
+ );
584
+ const shapes = (references.patterns ?? []).map(matcherFor);
585
+ // Foundry localizes a DataModel's field labels and hints off the declared
586
+ // prefix, so no source names them one by one.
587
+ const fieldShapes = [...namespaces].map(
588
+ (namespace) =>
589
+ new RegExp(
590
+ `^${escapeRegExp(namespace)}\\.FIELDS\\.` +
591
+ `[A-Za-z0-9_.]+\\.(?:label|hint)$`,
592
+ ),
593
+ );
594
+
595
+ const isReferenced = (key) =>
596
+ referenced.has(key) ||
597
+ shapes.some((shape) => shape.test(key)) ||
598
+ fieldShapes.some((shape) => shape.test(key));
599
+ const isRetained = (key) =>
600
+ retained.some((prefix) => key === prefix || key.startsWith(prefix));
601
+
602
+ const unreferenced = declaredKeys
603
+ .filter((key) =>
604
+ known.some((root) => key === root || key.startsWith(`${root}.`)),
605
+ )
606
+ .filter((key) => !isReferenced(key) && !isRetained(key))
607
+ .map((key) => ({
608
+ file: langFile,
609
+ ...positionOfLiteral(langSource, `"${key}"`),
610
+ severity: "warning",
611
+ message: `key "${key}" is unreferenced`,
612
+ }));
613
+
614
+ return {
615
+ findings: [...missing, ...(references.findings ?? [])],
616
+ unreferenced,
617
+ stats: {
618
+ declared: declaredKeys.length,
619
+ referenced: referenced.size,
620
+ namespaces: namespaces.size,
621
+ patterns: shapes.length,
622
+ missing: missing.length,
623
+ unreferenced: unreferenced.length,
624
+ },
625
+ };
626
+ }