@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/CHANGELOG.md +115 -0
- package/README.md +238 -2
- package/bin/package-build.mjs +482 -49
- package/bin/report.mjs +13 -4
- package/config.mjs +380 -1
- package/container.mjs +699 -0
- package/coverage.mjs +626 -0
- package/e2e.mjs +876 -0
- package/index.mjs +11 -2
- package/lang.mjs +2 -2
- package/package.json +34 -17
- package/templates.mjs +224 -0
- package/types/config.d.mts +129 -0
- package/types/container.d.mts +359 -0
- package/types/coverage.d.mts +182 -0
- package/types/e2e.d.mts +351 -0
- package/types/index.d.mts +4 -1
- package/types/templates.d.mts +74 -0
- package/text.mjs +0 -74
- package/types/text.d.mts +0 -51
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
|
+
}
|