@noctcore/lint-meta-rules 0.2.0 → 0.3.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/README.md +12 -0
- package/dist/chunk-Z7TXSZR4.js +63 -0
- package/dist/i18n.cjs +317 -0
- package/dist/i18n.d.cts +60 -0
- package/dist/i18n.d.ts +60 -0
- package/dist/i18n.js +266 -0
- package/dist/index.js +13 -49
- package/docs/rules/translation-dead-keys.md +98 -0
- package/package.json +24 -3
package/README.md
CHANGED
|
@@ -69,3 +69,15 @@ over the whole catalog.
|
|
|
69
69
|
|
|
70
70
|
Every factory is callable with no arguments (all options default), so `createAllRules()` and
|
|
71
71
|
per-factory defaults work out of the box; supply options to retarget a rule at your own repo.
|
|
72
|
+
|
|
73
|
+
### `@noctcore/lint-meta-rules/i18n`
|
|
74
|
+
|
|
75
|
+
Whole-program translation checks live on a separate entry point, because they run ESLint's parser
|
|
76
|
+
and scope analysis (reusing the i18n visitor behind `noctcore-contracts/translation-key-exists`).
|
|
77
|
+
The main entry never loads ESLint; this one needs the optional peers `eslint` and
|
|
78
|
+
`@typescript-eslint/parser`. Its factories are not part of `RULE_FACTORIES` / `createAllRules()`:
|
|
79
|
+
they are inert until you point them at your catalogs.
|
|
80
|
+
|
|
81
|
+
| Factory | Category | What it enforces |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| [`createTranslationDeadKeysRule`](./docs/rules/translation-dead-keys.md) | source-text | Every catalog key is reachable: named by a translation call, or spelled by some string in the source. |
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/rules/shared.ts
|
|
2
|
+
function dirOf(rel, basename = "package.json") {
|
|
3
|
+
return rel.replace(new RegExp(`/${escapeRegExp(basename)}$`), "");
|
|
4
|
+
}
|
|
5
|
+
function baseName(pathLike) {
|
|
6
|
+
const parts = pathLike.split("/").filter(Boolean);
|
|
7
|
+
return parts[parts.length - 1] ?? pathLike;
|
|
8
|
+
}
|
|
9
|
+
function escapeRegExp(value) {
|
|
10
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
function countLines(text) {
|
|
13
|
+
const lines = text.split("\n");
|
|
14
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
15
|
+
return lines.length;
|
|
16
|
+
}
|
|
17
|
+
function recursiveGlobs(roots, extensions) {
|
|
18
|
+
return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
|
|
19
|
+
}
|
|
20
|
+
var DEFAULT_WORKFLOW_GLOBS = [
|
|
21
|
+
".github/workflows/*.yml",
|
|
22
|
+
".github/workflows/*.yaml"
|
|
23
|
+
];
|
|
24
|
+
var DEFAULT_SKIP_DIRS = [
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"dist",
|
|
28
|
+
".turbo",
|
|
29
|
+
"coverage"
|
|
30
|
+
];
|
|
31
|
+
function globFiles(glob, globs, skipDirs = []) {
|
|
32
|
+
const skip = new Set(skipDirs);
|
|
33
|
+
const found = /* @__PURE__ */ new Set();
|
|
34
|
+
for (const pattern of globs) {
|
|
35
|
+
for (const rel of glob(pattern)) {
|
|
36
|
+
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return [...found].sort();
|
|
40
|
+
}
|
|
41
|
+
function stripYamlComment(line) {
|
|
42
|
+
return line.replace(/(^|\s)#.*$/u, "");
|
|
43
|
+
}
|
|
44
|
+
function unquote(value) {
|
|
45
|
+
return value.trim().replace(/^(['"])(.*)\1$/u, "$2");
|
|
46
|
+
}
|
|
47
|
+
function anywhereGlobs(baseNames) {
|
|
48
|
+
return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
dirOf,
|
|
53
|
+
baseName,
|
|
54
|
+
escapeRegExp,
|
|
55
|
+
countLines,
|
|
56
|
+
recursiveGlobs,
|
|
57
|
+
DEFAULT_WORKFLOW_GLOBS,
|
|
58
|
+
DEFAULT_SKIP_DIRS,
|
|
59
|
+
globFiles,
|
|
60
|
+
stripYamlComment,
|
|
61
|
+
unquote,
|
|
62
|
+
anywhereGlobs
|
|
63
|
+
};
|
package/dist/i18n.cjs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/i18n.ts
|
|
31
|
+
var i18n_exports = {};
|
|
32
|
+
__export(i18n_exports, {
|
|
33
|
+
createTranslationDeadKeysRule: () => createTranslationDeadKeysRule,
|
|
34
|
+
deriveNamespaces: () => deriveNamespaces
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(i18n_exports);
|
|
37
|
+
|
|
38
|
+
// src/i18n/translation-dead-keys.ts
|
|
39
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
40
|
+
var import_eslint_plugin_contracts = require("@noctcore/eslint-plugin-contracts");
|
|
41
|
+
var tsParser = __toESM(require("@typescript-eslint/parser"), 1);
|
|
42
|
+
var import_utils = require("@typescript-eslint/utils");
|
|
43
|
+
var import_eslint = require("eslint");
|
|
44
|
+
|
|
45
|
+
// src/rules/shared.ts
|
|
46
|
+
function escapeRegExp(value) {
|
|
47
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
+
}
|
|
49
|
+
var DEFAULT_SKIP_DIRS = [
|
|
50
|
+
"node_modules",
|
|
51
|
+
".git",
|
|
52
|
+
"dist",
|
|
53
|
+
".turbo",
|
|
54
|
+
"coverage"
|
|
55
|
+
];
|
|
56
|
+
function globFiles(glob, globs, skipDirs = []) {
|
|
57
|
+
const skip = new Set(skipDirs);
|
|
58
|
+
const found = /* @__PURE__ */ new Set();
|
|
59
|
+
for (const pattern of globs) {
|
|
60
|
+
for (const rel of glob(pattern)) {
|
|
61
|
+
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...found].sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/i18n/translation-dead-keys.ts
|
|
68
|
+
var DEFAULT_ID = "translation-dead-keys";
|
|
69
|
+
var NS_PLACEHOLDER = "{ns}";
|
|
70
|
+
var SOURCE_FILES = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
|
|
71
|
+
function namespacesFromFileTemplate(ctx, file) {
|
|
72
|
+
const segments = file.split("/");
|
|
73
|
+
if (segments.filter((segment) => segment.includes(NS_PLACEHOLDER)).length !== 1) return [];
|
|
74
|
+
const pattern = file.replaceAll(NS_PLACEHOLDER, "*");
|
|
75
|
+
const capture = new RegExp(
|
|
76
|
+
`^${file.split(NS_PLACEHOLDER).map((part) => escapeRegExp(part).replaceAll("\\*", "[^/]*")).join("([^/]+)")}$`,
|
|
77
|
+
"u"
|
|
78
|
+
);
|
|
79
|
+
const found = [];
|
|
80
|
+
for (const rel of ctx.glob(pattern)) {
|
|
81
|
+
const namespace = capture.exec(rel)?.[1];
|
|
82
|
+
if (namespace !== void 0) found.push(namespace);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
function namespacesFromKeyPath(ctx, file, keyPath) {
|
|
87
|
+
const segments = keyPath.split(".");
|
|
88
|
+
if (segments.indexOf(NS_PLACEHOLDER) !== segments.length - 1) return null;
|
|
89
|
+
const text = ctx.read(file);
|
|
90
|
+
if (text === null) return [];
|
|
91
|
+
let node = JSON.parse(text);
|
|
92
|
+
for (const segment of segments.slice(0, -1)) {
|
|
93
|
+
if (node === null || typeof node !== "object" || !Object.hasOwn(node, segment)) return [];
|
|
94
|
+
node = node[segment];
|
|
95
|
+
}
|
|
96
|
+
return node !== null && typeof node === "object" ? Object.keys(node) : [];
|
|
97
|
+
}
|
|
98
|
+
function deriveNamespaces(ctx, catalogs, defaultNamespace) {
|
|
99
|
+
const found = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const source of catalogs) {
|
|
101
|
+
const fileTemplated = source.file.includes(NS_PLACEHOLDER);
|
|
102
|
+
const keyPathTemplated = source.keyPath?.includes(NS_PLACEHOLDER) ?? false;
|
|
103
|
+
if (!fileTemplated && !keyPathTemplated) {
|
|
104
|
+
found.add(source.namespace ?? defaultNamespace);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const derived = fileTemplated ? namespacesFromFileTemplate(ctx, source.file) : namespacesFromKeyPath(ctx, source.file, source.keyPath ?? "");
|
|
108
|
+
if (derived === null || fileTemplated && keyPathTemplated) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`cannot enumerate the namespaces of catalog ${JSON.stringify(source)}: list them in \`namespaces\``
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
for (const namespace of derived) found.add(namespace);
|
|
114
|
+
}
|
|
115
|
+
return [...found].sort();
|
|
116
|
+
}
|
|
117
|
+
function holePattern(fragments) {
|
|
118
|
+
if (fragments.join("").replace(/[.:_\-/\s]/gu, "").length < 2) return null;
|
|
119
|
+
return new RegExp(`^${fragments.map(escapeRegExp).join(".*")}$`, "u");
|
|
120
|
+
}
|
|
121
|
+
function textFragments(root) {
|
|
122
|
+
const fragments = [""];
|
|
123
|
+
let sawText = false;
|
|
124
|
+
const hole = () => {
|
|
125
|
+
if (fragments.length === 1 || fragments[fragments.length - 1] !== "") fragments.push("");
|
|
126
|
+
};
|
|
127
|
+
const text = (value) => {
|
|
128
|
+
sawText = true;
|
|
129
|
+
fragments[fragments.length - 1] += value;
|
|
130
|
+
};
|
|
131
|
+
const visit = (node) => {
|
|
132
|
+
if (node.type === import_utils.AST_NODE_TYPES.BinaryExpression && node.operator === "+") {
|
|
133
|
+
visit(node.left);
|
|
134
|
+
visit(node.right);
|
|
135
|
+
} else if (node.type === import_utils.AST_NODE_TYPES.Literal && typeof node.value === "string") {
|
|
136
|
+
text(node.value);
|
|
137
|
+
} else if (node.type === import_utils.AST_NODE_TYPES.TemplateLiteral) {
|
|
138
|
+
node.quasis.forEach((quasi, index) => {
|
|
139
|
+
if (index > 0) hole();
|
|
140
|
+
text(quasi.value.cooked ?? "");
|
|
141
|
+
});
|
|
142
|
+
} else {
|
|
143
|
+
hole();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
visit(root);
|
|
147
|
+
return sawText ? fragments : null;
|
|
148
|
+
}
|
|
149
|
+
function collectReach(ctx, files, options, reach) {
|
|
150
|
+
const settings = (0, import_eslint_plugin_contracts.translationSettingsOf)(options);
|
|
151
|
+
const fallbackNamespaces = options.fallbackNamespaces ?? [];
|
|
152
|
+
const keySeparator = settings.keySeparator;
|
|
153
|
+
const addPrefix = (namespace, prefix) => {
|
|
154
|
+
reach.prefixes.set(namespace, [...reach.prefixes.get(namespace) ?? [], prefix]);
|
|
155
|
+
};
|
|
156
|
+
const onUsage = (usage) => {
|
|
157
|
+
if (usage.kind === "key") {
|
|
158
|
+
for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) {
|
|
159
|
+
for (const key of usage.keys) {
|
|
160
|
+
reach.exact.add(`${namespace}\0${key}`);
|
|
161
|
+
if (usage.returnObjects && keySeparator !== false) addPrefix(namespace, `${key}${keySeparator}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else if (usage.kind === "prefix") {
|
|
165
|
+
for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) addPrefix(namespace, usage.prefix);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const addText = (node) => {
|
|
169
|
+
const fragments = textFragments(node);
|
|
170
|
+
if (fragments === null) return;
|
|
171
|
+
if (fragments.length === 1) {
|
|
172
|
+
if (fragments[0] !== "") reach.literals.add(fragments[0] ?? "");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const pattern = holePattern(fragments);
|
|
176
|
+
if (pattern !== null) reach.patterns.push(pattern);
|
|
177
|
+
};
|
|
178
|
+
const collector = {
|
|
179
|
+
defaultOptions: [],
|
|
180
|
+
meta: { type: "problem", schema: [], messages: { never: "never reported" } },
|
|
181
|
+
create(context) {
|
|
182
|
+
return {
|
|
183
|
+
...(0, import_eslint_plugin_contracts.createTranslationVisitor)(context, settings, onUsage),
|
|
184
|
+
Literal(node) {
|
|
185
|
+
if (typeof node.value === "string" && node.value !== "") reach.literals.add(node.value);
|
|
186
|
+
},
|
|
187
|
+
TemplateLiteral(node) {
|
|
188
|
+
if (!isConcatOperand(node)) addText(node);
|
|
189
|
+
},
|
|
190
|
+
BinaryExpression(node) {
|
|
191
|
+
if (node.operator === "+" && !isConcatOperand(node)) addText(node);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const linter = new import_eslint.Linter({ cwd: ctx.root });
|
|
197
|
+
const config = [
|
|
198
|
+
{
|
|
199
|
+
// Not `**/*`: flat config treats a match-everything pattern as universal
|
|
200
|
+
// and applies it to no file on its own.
|
|
201
|
+
files: SOURCE_FILES,
|
|
202
|
+
languageOptions: {
|
|
203
|
+
parser: tsParser,
|
|
204
|
+
parserOptions: { ecmaFeatures: { jsx: true } }
|
|
205
|
+
},
|
|
206
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
207
|
+
plugins: { deadKeys: { rules: { collect: collector } } },
|
|
208
|
+
rules: { "deadKeys/collect": "error" }
|
|
209
|
+
}
|
|
210
|
+
];
|
|
211
|
+
const unparsed = [];
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
const text = ctx.read(file);
|
|
214
|
+
if (text === null) continue;
|
|
215
|
+
const messages = linter.verify(text, config, { filename: import_node_path.default.join(ctx.root, file) });
|
|
216
|
+
if (messages.some((message) => message.ruleId === null)) unparsed.push(file);
|
|
217
|
+
}
|
|
218
|
+
return unparsed;
|
|
219
|
+
}
|
|
220
|
+
function isConcatOperand(node) {
|
|
221
|
+
const parent = node.parent;
|
|
222
|
+
return parent?.type === import_utils.AST_NODE_TYPES.BinaryExpression && parent.operator === "+";
|
|
223
|
+
}
|
|
224
|
+
function allowPatterns(allow) {
|
|
225
|
+
return allow.map((pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u"));
|
|
226
|
+
}
|
|
227
|
+
function lookupForms(key, keySeparator) {
|
|
228
|
+
const forms = [key];
|
|
229
|
+
const lastSegmentStart = keySeparator === false ? 0 : key.lastIndexOf(keySeparator) + 1;
|
|
230
|
+
let base = key;
|
|
231
|
+
while (base.lastIndexOf("_") > lastSegmentStart) {
|
|
232
|
+
base = base.slice(0, base.lastIndexOf("_"));
|
|
233
|
+
forms.push(base);
|
|
234
|
+
}
|
|
235
|
+
return forms;
|
|
236
|
+
}
|
|
237
|
+
function isReached(namespace, key, reach, nsSeparator, keySeparator) {
|
|
238
|
+
for (const prefix of reach.prefixes.get(namespace) ?? []) {
|
|
239
|
+
if (key.startsWith(prefix)) return true;
|
|
240
|
+
}
|
|
241
|
+
const texts = [];
|
|
242
|
+
for (const form of lookupForms(key, keySeparator)) {
|
|
243
|
+
if (reach.exact.has(`${namespace}\0${form}`)) return true;
|
|
244
|
+
texts.push(form);
|
|
245
|
+
if (nsSeparator !== false) texts.push(`${namespace}${nsSeparator}${form}`);
|
|
246
|
+
}
|
|
247
|
+
if (texts.some((text) => reach.literals.has(text))) return true;
|
|
248
|
+
return reach.patterns.some((pattern) => texts.some((text) => pattern.test(text)));
|
|
249
|
+
}
|
|
250
|
+
function createTranslationDeadKeysRule(options = {}) {
|
|
251
|
+
const id = options.id ?? DEFAULT_ID;
|
|
252
|
+
const catalogs = options.catalogs ?? [];
|
|
253
|
+
const sourceGlobs = options.sourceGlobs ?? [];
|
|
254
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
255
|
+
const allow = allowPatterns(options.allow ?? []);
|
|
256
|
+
const ciCritical = options.ciCritical ?? true;
|
|
257
|
+
const defaultNamespace = options.defaultNamespace ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.defaultNamespace;
|
|
258
|
+
const nsSeparator = options.nsSeparator ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.nsSeparator;
|
|
259
|
+
const keySeparator = options.keySeparator ?? import_eslint_plugin_contracts.TRANSLATION_DEFAULTS.keySeparator;
|
|
260
|
+
return {
|
|
261
|
+
id,
|
|
262
|
+
category: "source-text",
|
|
263
|
+
ciCritical,
|
|
264
|
+
description: "Every translation catalog key must be reachable from the source: named by a translation call, or spelled by some string in the code.",
|
|
265
|
+
run(ctx) {
|
|
266
|
+
if (catalogs.length === 0 || sourceGlobs.length === 0) return [];
|
|
267
|
+
const violations = [];
|
|
268
|
+
const report = (file, message) => {
|
|
269
|
+
violations.push({ file, rule: id, message });
|
|
270
|
+
};
|
|
271
|
+
const namespaces = options.namespaces ?? deriveNamespaces(ctx, catalogs, defaultNamespace);
|
|
272
|
+
const keyed = [];
|
|
273
|
+
for (const namespace of namespaces) {
|
|
274
|
+
const loaded = (0, import_eslint_plugin_contracts.catalogsForNamespace)(namespace, catalogs, {
|
|
275
|
+
cwd: ctx.root,
|
|
276
|
+
defaultNamespace,
|
|
277
|
+
keySeparator
|
|
278
|
+
});
|
|
279
|
+
for (const reason of loaded.errors) report(reason.split(":")[0] ?? reason, `Catalog could not be loaded: ${reason}.`);
|
|
280
|
+
keyed.push({ namespace, catalogs: loaded.catalogs });
|
|
281
|
+
}
|
|
282
|
+
if (violations.length > 0) return violations;
|
|
283
|
+
const reach = { exact: /* @__PURE__ */ new Set(), prefixes: /* @__PURE__ */ new Map(), literals: /* @__PURE__ */ new Set(), patterns: [] };
|
|
284
|
+
const files = globFiles((pattern) => ctx.glob(pattern), sourceGlobs, skipDirs);
|
|
285
|
+
if (files.length === 0) {
|
|
286
|
+
report(sourceGlobs.join(", "), "No source file matches `sourceGlobs`, so every key would look dead. Fix the globs.");
|
|
287
|
+
return violations;
|
|
288
|
+
}
|
|
289
|
+
const unparsed = collectReach(ctx, files, options, reach);
|
|
290
|
+
if (unparsed.length > 0) {
|
|
291
|
+
for (const file of unparsed) {
|
|
292
|
+
report(file, "Could not analyse this file (a parse error, or not a JS/TS file), so the keys it reaches are unknown. Dead-key analysis is skipped until every source file is analysable.");
|
|
293
|
+
}
|
|
294
|
+
return violations;
|
|
295
|
+
}
|
|
296
|
+
for (const { namespace, catalogs: sources } of keyed) {
|
|
297
|
+
for (const catalog of sources) {
|
|
298
|
+
for (const key of [...catalog.leaves].sort()) {
|
|
299
|
+
if (isReached(namespace, key, reach, nsSeparator, keySeparator)) continue;
|
|
300
|
+
const qualified = `${namespace}:${key}`;
|
|
301
|
+
if (allow.some((pattern) => pattern.test(qualified))) continue;
|
|
302
|
+
report(
|
|
303
|
+
catalog.label.split("#")[0] ?? catalog.label,
|
|
304
|
+
`Translation key \`${qualified}\` (${catalog.label}) is named by no translation call and spelled by no string in the source. Delete it, or add it to \`allow\` if it is reached from outside the scanned files.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return violations;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
314
|
+
0 && (module.exports = {
|
|
315
|
+
createTranslationDeadKeysRule,
|
|
316
|
+
deriveNamespaces
|
|
317
|
+
});
|
package/dist/i18n.d.cts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { IMetaRule, IMetaCtx } from '@noctcore/harness';
|
|
2
|
+
import { TranslationKeyExistsOptions, CatalogSource } from '@noctcore/eslint-plugin-contracts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link createTranslationDeadKeysRule}.
|
|
6
|
+
*
|
|
7
|
+
* Every resolution option (`defaultNamespace`, `hooks`, `namespaceIdentifiers`,
|
|
8
|
+
* separators, ...) is the SAME option `noctcore-contracts/translation-key-exists`
|
|
9
|
+
* takes, and is resolved by the same code, so the two checks cannot disagree on
|
|
10
|
+
* which key a call site means. Pass them the same values.
|
|
11
|
+
*/
|
|
12
|
+
interface TranslationDeadKeysOptions extends Omit<TranslationKeyExistsOptions, 'catalogs' | 'dynamicKeys'> {
|
|
13
|
+
/** Rule id, for running more than one instance. Default `translation-dead-keys`. */
|
|
14
|
+
readonly id?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The catalogs whose keys must be reachable, in `translation-key-exists`'
|
|
17
|
+
* `CatalogSource` shape. List ONE language (the reference one): a key is dead
|
|
18
|
+
* or alive regardless of how many languages translate it. Empty = inert.
|
|
19
|
+
*/
|
|
20
|
+
readonly catalogs?: readonly CatalogSource[];
|
|
21
|
+
/**
|
|
22
|
+
* The namespaces to check. Default: derived from `catalogs`, where a `{ns}`
|
|
23
|
+
* file segment is globbed and a trailing `{ns}` keyPath segment lists the
|
|
24
|
+
* object's keys. Required when a `{ns}` placeholder sits anywhere else.
|
|
25
|
+
*/
|
|
26
|
+
readonly namespaces?: readonly string[];
|
|
27
|
+
/** Globs of every file that can reach a key (call sites AND key tables). Empty = inert. */
|
|
28
|
+
readonly sourceGlobs?: readonly string[];
|
|
29
|
+
/** Source paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
30
|
+
readonly skipDirs?: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* `ns:key` patterns known to be reached from outside the scanned source
|
|
33
|
+
* (server-sent codes, a CMS, another app). `*` matches any run of characters.
|
|
34
|
+
*/
|
|
35
|
+
readonly allow?: readonly string[];
|
|
36
|
+
/** Whether a dead key fails CI. Default `true`. */
|
|
37
|
+
readonly ciCritical?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** The namespaces `catalogs` supply, or throws when a placeholder cannot be enumerated. */
|
|
40
|
+
declare function deriveNamespaces(ctx: IMetaCtx, catalogs: readonly CatalogSource[], defaultNamespace: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Catalog keys nothing in the source can reach. A whole-program check, so it
|
|
43
|
+
* lives here and not in ESLint: a per-file rule never sees every call site, and
|
|
44
|
+
* keys flow as data (key tables, helper functions, server codes), which a
|
|
45
|
+
* call-site-only analysis cannot follow.
|
|
46
|
+
*
|
|
47
|
+
* CONSERVATIVE BY DESIGN. A key counts as reached when ANY of these holds:
|
|
48
|
+
* - a translation call resolves to it (same visitor as `translation-key-exists`),
|
|
49
|
+
* - a template key's static head is a prefix of it (`` t(`status.${s}`) ``),
|
|
50
|
+
* - any string literal in the source equals it, `ns:key`, an ancestor of it,
|
|
51
|
+
* or its plural/context base (`key` for `key_one`),
|
|
52
|
+
* - any template literal or `+` chain in the source can produce it,
|
|
53
|
+
* - it matches an `allow` pattern.
|
|
54
|
+
* And it reports NOTHING when a source file cannot be parsed. What is left was
|
|
55
|
+
* named by no call and spelled by no string: dead, or reached from outside the
|
|
56
|
+
* scanned source (see the rule doc's blind spots).
|
|
57
|
+
*/
|
|
58
|
+
declare function createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule;
|
|
59
|
+
|
|
60
|
+
export { type TranslationDeadKeysOptions, createTranslationDeadKeysRule, deriveNamespaces };
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { IMetaRule, IMetaCtx } from '@noctcore/harness';
|
|
2
|
+
import { TranslationKeyExistsOptions, CatalogSource } from '@noctcore/eslint-plugin-contracts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for {@link createTranslationDeadKeysRule}.
|
|
6
|
+
*
|
|
7
|
+
* Every resolution option (`defaultNamespace`, `hooks`, `namespaceIdentifiers`,
|
|
8
|
+
* separators, ...) is the SAME option `noctcore-contracts/translation-key-exists`
|
|
9
|
+
* takes, and is resolved by the same code, so the two checks cannot disagree on
|
|
10
|
+
* which key a call site means. Pass them the same values.
|
|
11
|
+
*/
|
|
12
|
+
interface TranslationDeadKeysOptions extends Omit<TranslationKeyExistsOptions, 'catalogs' | 'dynamicKeys'> {
|
|
13
|
+
/** Rule id, for running more than one instance. Default `translation-dead-keys`. */
|
|
14
|
+
readonly id?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The catalogs whose keys must be reachable, in `translation-key-exists`'
|
|
17
|
+
* `CatalogSource` shape. List ONE language (the reference one): a key is dead
|
|
18
|
+
* or alive regardless of how many languages translate it. Empty = inert.
|
|
19
|
+
*/
|
|
20
|
+
readonly catalogs?: readonly CatalogSource[];
|
|
21
|
+
/**
|
|
22
|
+
* The namespaces to check. Default: derived from `catalogs`, where a `{ns}`
|
|
23
|
+
* file segment is globbed and a trailing `{ns}` keyPath segment lists the
|
|
24
|
+
* object's keys. Required when a `{ns}` placeholder sits anywhere else.
|
|
25
|
+
*/
|
|
26
|
+
readonly namespaces?: readonly string[];
|
|
27
|
+
/** Globs of every file that can reach a key (call sites AND key tables). Empty = inert. */
|
|
28
|
+
readonly sourceGlobs?: readonly string[];
|
|
29
|
+
/** Source paths with any of these segments are skipped. Default `node_modules`, `.git`, `dist`, `.turbo`, `coverage`. */
|
|
30
|
+
readonly skipDirs?: readonly string[];
|
|
31
|
+
/**
|
|
32
|
+
* `ns:key` patterns known to be reached from outside the scanned source
|
|
33
|
+
* (server-sent codes, a CMS, another app). `*` matches any run of characters.
|
|
34
|
+
*/
|
|
35
|
+
readonly allow?: readonly string[];
|
|
36
|
+
/** Whether a dead key fails CI. Default `true`. */
|
|
37
|
+
readonly ciCritical?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** The namespaces `catalogs` supply, or throws when a placeholder cannot be enumerated. */
|
|
40
|
+
declare function deriveNamespaces(ctx: IMetaCtx, catalogs: readonly CatalogSource[], defaultNamespace: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Catalog keys nothing in the source can reach. A whole-program check, so it
|
|
43
|
+
* lives here and not in ESLint: a per-file rule never sees every call site, and
|
|
44
|
+
* keys flow as data (key tables, helper functions, server codes), which a
|
|
45
|
+
* call-site-only analysis cannot follow.
|
|
46
|
+
*
|
|
47
|
+
* CONSERVATIVE BY DESIGN. A key counts as reached when ANY of these holds:
|
|
48
|
+
* - a translation call resolves to it (same visitor as `translation-key-exists`),
|
|
49
|
+
* - a template key's static head is a prefix of it (`` t(`status.${s}`) ``),
|
|
50
|
+
* - any string literal in the source equals it, `ns:key`, an ancestor of it,
|
|
51
|
+
* or its plural/context base (`key` for `key_one`),
|
|
52
|
+
* - any template literal or `+` chain in the source can produce it,
|
|
53
|
+
* - it matches an `allow` pattern.
|
|
54
|
+
* And it reports NOTHING when a source file cannot be parsed. What is left was
|
|
55
|
+
* named by no call and spelled by no string: dead, or reached from outside the
|
|
56
|
+
* scanned source (see the rule doc's blind spots).
|
|
57
|
+
*/
|
|
58
|
+
declare function createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule;
|
|
59
|
+
|
|
60
|
+
export { type TranslationDeadKeysOptions, createTranslationDeadKeysRule, deriveNamespaces };
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SKIP_DIRS,
|
|
3
|
+
escapeRegExp,
|
|
4
|
+
globFiles
|
|
5
|
+
} from "./chunk-Z7TXSZR4.js";
|
|
6
|
+
|
|
7
|
+
// src/i18n/translation-dead-keys.ts
|
|
8
|
+
import path from "path";
|
|
9
|
+
import {
|
|
10
|
+
catalogsForNamespace,
|
|
11
|
+
createTranslationVisitor,
|
|
12
|
+
TRANSLATION_DEFAULTS,
|
|
13
|
+
translationSettingsOf
|
|
14
|
+
} from "@noctcore/eslint-plugin-contracts";
|
|
15
|
+
import * as tsParser from "@typescript-eslint/parser";
|
|
16
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
17
|
+
import { Linter } from "eslint";
|
|
18
|
+
var DEFAULT_ID = "translation-dead-keys";
|
|
19
|
+
var NS_PLACEHOLDER = "{ns}";
|
|
20
|
+
var SOURCE_FILES = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
|
|
21
|
+
function namespacesFromFileTemplate(ctx, file) {
|
|
22
|
+
const segments = file.split("/");
|
|
23
|
+
if (segments.filter((segment) => segment.includes(NS_PLACEHOLDER)).length !== 1) return [];
|
|
24
|
+
const pattern = file.replaceAll(NS_PLACEHOLDER, "*");
|
|
25
|
+
const capture = new RegExp(
|
|
26
|
+
`^${file.split(NS_PLACEHOLDER).map((part) => escapeRegExp(part).replaceAll("\\*", "[^/]*")).join("([^/]+)")}$`,
|
|
27
|
+
"u"
|
|
28
|
+
);
|
|
29
|
+
const found = [];
|
|
30
|
+
for (const rel of ctx.glob(pattern)) {
|
|
31
|
+
const namespace = capture.exec(rel)?.[1];
|
|
32
|
+
if (namespace !== void 0) found.push(namespace);
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
function namespacesFromKeyPath(ctx, file, keyPath) {
|
|
37
|
+
const segments = keyPath.split(".");
|
|
38
|
+
if (segments.indexOf(NS_PLACEHOLDER) !== segments.length - 1) return null;
|
|
39
|
+
const text = ctx.read(file);
|
|
40
|
+
if (text === null) return [];
|
|
41
|
+
let node = JSON.parse(text);
|
|
42
|
+
for (const segment of segments.slice(0, -1)) {
|
|
43
|
+
if (node === null || typeof node !== "object" || !Object.hasOwn(node, segment)) return [];
|
|
44
|
+
node = node[segment];
|
|
45
|
+
}
|
|
46
|
+
return node !== null && typeof node === "object" ? Object.keys(node) : [];
|
|
47
|
+
}
|
|
48
|
+
function deriveNamespaces(ctx, catalogs, defaultNamespace) {
|
|
49
|
+
const found = /* @__PURE__ */ new Set();
|
|
50
|
+
for (const source of catalogs) {
|
|
51
|
+
const fileTemplated = source.file.includes(NS_PLACEHOLDER);
|
|
52
|
+
const keyPathTemplated = source.keyPath?.includes(NS_PLACEHOLDER) ?? false;
|
|
53
|
+
if (!fileTemplated && !keyPathTemplated) {
|
|
54
|
+
found.add(source.namespace ?? defaultNamespace);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const derived = fileTemplated ? namespacesFromFileTemplate(ctx, source.file) : namespacesFromKeyPath(ctx, source.file, source.keyPath ?? "");
|
|
58
|
+
if (derived === null || fileTemplated && keyPathTemplated) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`cannot enumerate the namespaces of catalog ${JSON.stringify(source)}: list them in \`namespaces\``
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
for (const namespace of derived) found.add(namespace);
|
|
64
|
+
}
|
|
65
|
+
return [...found].sort();
|
|
66
|
+
}
|
|
67
|
+
function holePattern(fragments) {
|
|
68
|
+
if (fragments.join("").replace(/[.:_\-/\s]/gu, "").length < 2) return null;
|
|
69
|
+
return new RegExp(`^${fragments.map(escapeRegExp).join(".*")}$`, "u");
|
|
70
|
+
}
|
|
71
|
+
function textFragments(root) {
|
|
72
|
+
const fragments = [""];
|
|
73
|
+
let sawText = false;
|
|
74
|
+
const hole = () => {
|
|
75
|
+
if (fragments.length === 1 || fragments[fragments.length - 1] !== "") fragments.push("");
|
|
76
|
+
};
|
|
77
|
+
const text = (value) => {
|
|
78
|
+
sawText = true;
|
|
79
|
+
fragments[fragments.length - 1] += value;
|
|
80
|
+
};
|
|
81
|
+
const visit = (node) => {
|
|
82
|
+
if (node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+") {
|
|
83
|
+
visit(node.left);
|
|
84
|
+
visit(node.right);
|
|
85
|
+
} else if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
|
|
86
|
+
text(node.value);
|
|
87
|
+
} else if (node.type === AST_NODE_TYPES.TemplateLiteral) {
|
|
88
|
+
node.quasis.forEach((quasi, index) => {
|
|
89
|
+
if (index > 0) hole();
|
|
90
|
+
text(quasi.value.cooked ?? "");
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
hole();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
visit(root);
|
|
97
|
+
return sawText ? fragments : null;
|
|
98
|
+
}
|
|
99
|
+
function collectReach(ctx, files, options, reach) {
|
|
100
|
+
const settings = translationSettingsOf(options);
|
|
101
|
+
const fallbackNamespaces = options.fallbackNamespaces ?? [];
|
|
102
|
+
const keySeparator = settings.keySeparator;
|
|
103
|
+
const addPrefix = (namespace, prefix) => {
|
|
104
|
+
reach.prefixes.set(namespace, [...reach.prefixes.get(namespace) ?? [], prefix]);
|
|
105
|
+
};
|
|
106
|
+
const onUsage = (usage) => {
|
|
107
|
+
if (usage.kind === "key") {
|
|
108
|
+
for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) {
|
|
109
|
+
for (const key of usage.keys) {
|
|
110
|
+
reach.exact.add(`${namespace}\0${key}`);
|
|
111
|
+
if (usage.returnObjects && keySeparator !== false) addPrefix(namespace, `${key}${keySeparator}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} else if (usage.kind === "prefix") {
|
|
115
|
+
for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) addPrefix(namespace, usage.prefix);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const addText = (node) => {
|
|
119
|
+
const fragments = textFragments(node);
|
|
120
|
+
if (fragments === null) return;
|
|
121
|
+
if (fragments.length === 1) {
|
|
122
|
+
if (fragments[0] !== "") reach.literals.add(fragments[0] ?? "");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const pattern = holePattern(fragments);
|
|
126
|
+
if (pattern !== null) reach.patterns.push(pattern);
|
|
127
|
+
};
|
|
128
|
+
const collector = {
|
|
129
|
+
defaultOptions: [],
|
|
130
|
+
meta: { type: "problem", schema: [], messages: { never: "never reported" } },
|
|
131
|
+
create(context) {
|
|
132
|
+
return {
|
|
133
|
+
...createTranslationVisitor(context, settings, onUsage),
|
|
134
|
+
Literal(node) {
|
|
135
|
+
if (typeof node.value === "string" && node.value !== "") reach.literals.add(node.value);
|
|
136
|
+
},
|
|
137
|
+
TemplateLiteral(node) {
|
|
138
|
+
if (!isConcatOperand(node)) addText(node);
|
|
139
|
+
},
|
|
140
|
+
BinaryExpression(node) {
|
|
141
|
+
if (node.operator === "+" && !isConcatOperand(node)) addText(node);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const linter = new Linter({ cwd: ctx.root });
|
|
147
|
+
const config = [
|
|
148
|
+
{
|
|
149
|
+
// Not `**/*`: flat config treats a match-everything pattern as universal
|
|
150
|
+
// and applies it to no file on its own.
|
|
151
|
+
files: SOURCE_FILES,
|
|
152
|
+
languageOptions: {
|
|
153
|
+
parser: tsParser,
|
|
154
|
+
parserOptions: { ecmaFeatures: { jsx: true } }
|
|
155
|
+
},
|
|
156
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
157
|
+
plugins: { deadKeys: { rules: { collect: collector } } },
|
|
158
|
+
rules: { "deadKeys/collect": "error" }
|
|
159
|
+
}
|
|
160
|
+
];
|
|
161
|
+
const unparsed = [];
|
|
162
|
+
for (const file of files) {
|
|
163
|
+
const text = ctx.read(file);
|
|
164
|
+
if (text === null) continue;
|
|
165
|
+
const messages = linter.verify(text, config, { filename: path.join(ctx.root, file) });
|
|
166
|
+
if (messages.some((message) => message.ruleId === null)) unparsed.push(file);
|
|
167
|
+
}
|
|
168
|
+
return unparsed;
|
|
169
|
+
}
|
|
170
|
+
function isConcatOperand(node) {
|
|
171
|
+
const parent = node.parent;
|
|
172
|
+
return parent?.type === AST_NODE_TYPES.BinaryExpression && parent.operator === "+";
|
|
173
|
+
}
|
|
174
|
+
function allowPatterns(allow) {
|
|
175
|
+
return allow.map((pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u"));
|
|
176
|
+
}
|
|
177
|
+
function lookupForms(key, keySeparator) {
|
|
178
|
+
const forms = [key];
|
|
179
|
+
const lastSegmentStart = keySeparator === false ? 0 : key.lastIndexOf(keySeparator) + 1;
|
|
180
|
+
let base = key;
|
|
181
|
+
while (base.lastIndexOf("_") > lastSegmentStart) {
|
|
182
|
+
base = base.slice(0, base.lastIndexOf("_"));
|
|
183
|
+
forms.push(base);
|
|
184
|
+
}
|
|
185
|
+
return forms;
|
|
186
|
+
}
|
|
187
|
+
function isReached(namespace, key, reach, nsSeparator, keySeparator) {
|
|
188
|
+
for (const prefix of reach.prefixes.get(namespace) ?? []) {
|
|
189
|
+
if (key.startsWith(prefix)) return true;
|
|
190
|
+
}
|
|
191
|
+
const texts = [];
|
|
192
|
+
for (const form of lookupForms(key, keySeparator)) {
|
|
193
|
+
if (reach.exact.has(`${namespace}\0${form}`)) return true;
|
|
194
|
+
texts.push(form);
|
|
195
|
+
if (nsSeparator !== false) texts.push(`${namespace}${nsSeparator}${form}`);
|
|
196
|
+
}
|
|
197
|
+
if (texts.some((text) => reach.literals.has(text))) return true;
|
|
198
|
+
return reach.patterns.some((pattern) => texts.some((text) => pattern.test(text)));
|
|
199
|
+
}
|
|
200
|
+
function createTranslationDeadKeysRule(options = {}) {
|
|
201
|
+
const id = options.id ?? DEFAULT_ID;
|
|
202
|
+
const catalogs = options.catalogs ?? [];
|
|
203
|
+
const sourceGlobs = options.sourceGlobs ?? [];
|
|
204
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
205
|
+
const allow = allowPatterns(options.allow ?? []);
|
|
206
|
+
const ciCritical = options.ciCritical ?? true;
|
|
207
|
+
const defaultNamespace = options.defaultNamespace ?? TRANSLATION_DEFAULTS.defaultNamespace;
|
|
208
|
+
const nsSeparator = options.nsSeparator ?? TRANSLATION_DEFAULTS.nsSeparator;
|
|
209
|
+
const keySeparator = options.keySeparator ?? TRANSLATION_DEFAULTS.keySeparator;
|
|
210
|
+
return {
|
|
211
|
+
id,
|
|
212
|
+
category: "source-text",
|
|
213
|
+
ciCritical,
|
|
214
|
+
description: "Every translation catalog key must be reachable from the source: named by a translation call, or spelled by some string in the code.",
|
|
215
|
+
run(ctx) {
|
|
216
|
+
if (catalogs.length === 0 || sourceGlobs.length === 0) return [];
|
|
217
|
+
const violations = [];
|
|
218
|
+
const report = (file, message) => {
|
|
219
|
+
violations.push({ file, rule: id, message });
|
|
220
|
+
};
|
|
221
|
+
const namespaces = options.namespaces ?? deriveNamespaces(ctx, catalogs, defaultNamespace);
|
|
222
|
+
const keyed = [];
|
|
223
|
+
for (const namespace of namespaces) {
|
|
224
|
+
const loaded = catalogsForNamespace(namespace, catalogs, {
|
|
225
|
+
cwd: ctx.root,
|
|
226
|
+
defaultNamespace,
|
|
227
|
+
keySeparator
|
|
228
|
+
});
|
|
229
|
+
for (const reason of loaded.errors) report(reason.split(":")[0] ?? reason, `Catalog could not be loaded: ${reason}.`);
|
|
230
|
+
keyed.push({ namespace, catalogs: loaded.catalogs });
|
|
231
|
+
}
|
|
232
|
+
if (violations.length > 0) return violations;
|
|
233
|
+
const reach = { exact: /* @__PURE__ */ new Set(), prefixes: /* @__PURE__ */ new Map(), literals: /* @__PURE__ */ new Set(), patterns: [] };
|
|
234
|
+
const files = globFiles((pattern) => ctx.glob(pattern), sourceGlobs, skipDirs);
|
|
235
|
+
if (files.length === 0) {
|
|
236
|
+
report(sourceGlobs.join(", "), "No source file matches `sourceGlobs`, so every key would look dead. Fix the globs.");
|
|
237
|
+
return violations;
|
|
238
|
+
}
|
|
239
|
+
const unparsed = collectReach(ctx, files, options, reach);
|
|
240
|
+
if (unparsed.length > 0) {
|
|
241
|
+
for (const file of unparsed) {
|
|
242
|
+
report(file, "Could not analyse this file (a parse error, or not a JS/TS file), so the keys it reaches are unknown. Dead-key analysis is skipped until every source file is analysable.");
|
|
243
|
+
}
|
|
244
|
+
return violations;
|
|
245
|
+
}
|
|
246
|
+
for (const { namespace, catalogs: sources } of keyed) {
|
|
247
|
+
for (const catalog of sources) {
|
|
248
|
+
for (const key of [...catalog.leaves].sort()) {
|
|
249
|
+
if (isReached(namespace, key, reach, nsSeparator, keySeparator)) continue;
|
|
250
|
+
const qualified = `${namespace}:${key}`;
|
|
251
|
+
if (allow.some((pattern) => pattern.test(qualified))) continue;
|
|
252
|
+
report(
|
|
253
|
+
catalog.label.split("#")[0] ?? catalog.label,
|
|
254
|
+
`Translation key \`${qualified}\` (${catalog.label}) is named by no translation call and spelled by no string in the source. Delete it, or add it to \`allow\` if it is reached from outside the scanned files.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return violations;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export {
|
|
264
|
+
createTranslationDeadKeysRule,
|
|
265
|
+
deriveNamespaces
|
|
266
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,52 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
15
|
-
return lines.length;
|
|
16
|
-
}
|
|
17
|
-
function recursiveGlobs(roots, extensions) {
|
|
18
|
-
return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
|
|
19
|
-
}
|
|
20
|
-
var DEFAULT_WORKFLOW_GLOBS = [
|
|
21
|
-
".github/workflows/*.yml",
|
|
22
|
-
".github/workflows/*.yaml"
|
|
23
|
-
];
|
|
24
|
-
var DEFAULT_SKIP_DIRS = [
|
|
25
|
-
"node_modules",
|
|
26
|
-
".git",
|
|
27
|
-
"dist",
|
|
28
|
-
".turbo",
|
|
29
|
-
"coverage"
|
|
30
|
-
];
|
|
31
|
-
function globFiles(glob, globs, skipDirs = []) {
|
|
32
|
-
const skip = new Set(skipDirs);
|
|
33
|
-
const found = /* @__PURE__ */ new Set();
|
|
34
|
-
for (const pattern of globs) {
|
|
35
|
-
for (const rel of glob(pattern)) {
|
|
36
|
-
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return [...found].sort();
|
|
40
|
-
}
|
|
41
|
-
function stripYamlComment(line) {
|
|
42
|
-
return line.replace(/(^|\s)#.*$/u, "");
|
|
43
|
-
}
|
|
44
|
-
function unquote(value) {
|
|
45
|
-
return value.trim().replace(/^(['"])(.*)\1$/u, "$2");
|
|
46
|
-
}
|
|
47
|
-
function anywhereGlobs(baseNames) {
|
|
48
|
-
return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
|
|
49
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SKIP_DIRS,
|
|
3
|
+
DEFAULT_WORKFLOW_GLOBS,
|
|
4
|
+
anywhereGlobs,
|
|
5
|
+
baseName,
|
|
6
|
+
countLines,
|
|
7
|
+
dirOf,
|
|
8
|
+
escapeRegExp,
|
|
9
|
+
globFiles,
|
|
10
|
+
recursiveGlobs,
|
|
11
|
+
stripYamlComment,
|
|
12
|
+
unquote
|
|
13
|
+
} from "./chunk-Z7TXSZR4.js";
|
|
50
14
|
|
|
51
15
|
// src/rules/agents-doc-presence.ts
|
|
52
16
|
function createAgentsDocPresenceRule(options = {}) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# `translation-dead-keys`
|
|
2
|
+
|
|
3
|
+
> Every translation catalog key is reachable from the source: named by a translation call, or spelled
|
|
4
|
+
> by some string in the code.
|
|
5
|
+
|
|
6
|
+
Import it from the `i18n` entry point, which (unlike the main one) loads ESLint:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createTranslationDeadKeysRule } from '@noctcore/lint-meta-rules/i18n';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why
|
|
13
|
+
|
|
14
|
+
A catalog key nothing uses is still translated, reviewed and shipped in every language. Finding them is
|
|
15
|
+
a whole-program question, so it is not an ESLint rule: a per-file rule never sees every call site
|
|
16
|
+
(editor runs, `--cache`, lint-staged, sharded workers), and keys flow as data (key tables, key-building
|
|
17
|
+
helpers, server-sent codes) that no call-site analysis follows.
|
|
18
|
+
|
|
19
|
+
This rule resolves call sites with the same visitor and catalog loader as
|
|
20
|
+
`noctcore-contracts/translation-key-exists` (exported by `@noctcore/eslint-plugin-contracts`), so the
|
|
21
|
+
two checks never disagree on which key a call means. It then adds the data routes a per-file rule
|
|
22
|
+
cannot see.
|
|
23
|
+
|
|
24
|
+
## What counts as reached
|
|
25
|
+
|
|
26
|
+
A key is reached, and never reported, when ANY of these holds:
|
|
27
|
+
|
|
28
|
+
- a translation call resolves to it: `t('key')`, `t('ns:key')`, `t('key', { ns })`,
|
|
29
|
+
`useTranslation('ns', { keyPrefix })`, `<Trans i18nKey>`, a `TFunction<'ns'>` parameter, the
|
|
30
|
+
`fallbackNamespaces`;
|
|
31
|
+
- a template key's static head is a prefix of it: `` t(`status.${s}`) `` reaches every `status.*`;
|
|
32
|
+
- a `returnObjects` call names one of its ancestors;
|
|
33
|
+
- ANY string literal in the scanned source equals the key, `ns:key`, or its plural/context base
|
|
34
|
+
(`key` reaches `key_one`, `key_ordinal_few`, `key_male`), in any namespace;
|
|
35
|
+
- ANY template literal or `+` chain in the scanned source can produce it:
|
|
36
|
+
`` `nav.${id}.label` `` and `'errors.' + code` are patterns, not just call arguments;
|
|
37
|
+
- it matches an `allow` pattern.
|
|
38
|
+
|
|
39
|
+
And it reports **no dead key at all** when a scanned file cannot be analysed (a parse error), or when
|
|
40
|
+
`sourceGlobs` match nothing: in both cases the rule would otherwise call reached keys dead.
|
|
41
|
+
|
|
42
|
+
What is left was named by no call and spelled by no string. On a production app with 1,824 keys this
|
|
43
|
+
reported 55, and every one of them had zero references outside the catalogs.
|
|
44
|
+
|
|
45
|
+
## Blind spots
|
|
46
|
+
|
|
47
|
+
It is conservative, not complete. It reports a reached key as dead when the key arrives by a route it
|
|
48
|
+
cannot see:
|
|
49
|
+
|
|
50
|
+
- **Keys that never appear in the scanned source**: server-sent codes, a CMS, another app, JSON
|
|
51
|
+
config. List them in `allow`, or add the files that spell them to `sourceGlobs`.
|
|
52
|
+
- **A variable key under a `keyPrefix` binding**: `useTranslation('ns', { keyPrefix: 'form' })` then
|
|
53
|
+
`t(field)` with `field = 'name'` reaches `form.name`, but no string spells `form.name`. (A static key
|
|
54
|
+
under a `keyPrefix` is resolved correctly.)
|
|
55
|
+
- **Keys built by anything other than `+` or a template literal**: `[a, b].join('.')`,
|
|
56
|
+
`` `${a}` `` split across variables, `String.prototype.concat`.
|
|
57
|
+
|
|
58
|
+
It also misses some dead keys, by design: any string that happens to equal a key (in any namespace)
|
|
59
|
+
keeps it alive, and a broad pattern such as `` `${x}.title` `` keeps every `*.title` alive.
|
|
60
|
+
|
|
61
|
+
## Factory
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
createTranslationDeadKeysRule(options?: TranslationDeadKeysOptions): IMetaRule
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Inert until both `catalogs` and `sourceGlobs` are set; there are no built-in paths.
|
|
68
|
+
|
|
69
|
+
| Option | Type | Default | Meaning |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| `catalogs` | `CatalogSource[]` | `[]` | The catalogs to check, in `translation-key-exists`' shape (`{ file, namespace?, keyPath? }`, `{ns}` placeholders allowed). List ONE language: a key is dead or alive regardless of how many languages translate it. |
|
|
72
|
+
| `sourceGlobs` | `string[]` | `[]` | Every file that can reach a key: call sites AND key tables. Include tests if a key used only by a test should count as alive. |
|
|
73
|
+
| `namespaces` | `string[]` | derived | The namespaces to check. Derived from `catalogs`: a `{ns}` file segment is globbed, a trailing `{ns}` keyPath segment lists the object's keys. Required when a `{ns}` sits anywhere else. |
|
|
74
|
+
| `allow` | `string[]` | `[]` | `ns:key` patterns reached from outside the scanned source; `*` matches any run of characters. |
|
|
75
|
+
| `skipDirs` | `string[]` | `['node_modules', '.git', 'dist', '.turbo', 'coverage']` | Source paths with any of these segments are skipped. |
|
|
76
|
+
| `id` | `string` | `'translation-dead-keys'` | Rule id, for running more than one instance. |
|
|
77
|
+
| `ciCritical` | `boolean` | `true` | Whether a dead key fails CI. |
|
|
78
|
+
|
|
79
|
+
Every resolution option of `translation-key-exists` is accepted and means the same thing:
|
|
80
|
+
`defaultNamespace`, `fallbackNamespaces`, `hooks`, `instances`, `functions`, `typeNames`,
|
|
81
|
+
`transComponents`, `namespaceIdentifiers`, `nsSeparator`, `keySeparator`. Pass them the same values.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
createTranslationDeadKeysRule({
|
|
85
|
+
catalogs: [
|
|
86
|
+
{ file: 'apps/web/src/lib/i18n/locales/pl.json', keyPath: '{ns}' },
|
|
87
|
+
{ file: 'apps/web/src/features/{ns}/locales/pl.json' },
|
|
88
|
+
],
|
|
89
|
+
defaultNamespace: 'common',
|
|
90
|
+
namespaceIdentifiers: { HELP_NS: 'help' },
|
|
91
|
+
sourceGlobs: ['apps/web/src/**/*.ts', 'apps/web/src/**/*.tsx'],
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Requirements
|
|
96
|
+
|
|
97
|
+
The `i18n` entry needs the optional peers `eslint` (>= 9) and `@typescript-eslint/parser`. Scanned
|
|
98
|
+
files are parsed as TypeScript with JSX enabled, without type information.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/lint-meta-rules",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Portable, parameterized lint-meta rules — whole-repo / cross-file invariants ESLint cannot reach — for the @noctcore/harness lint-meta runner.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
"import": "./dist/index.js",
|
|
14
14
|
"require": "./dist/index.cjs"
|
|
15
15
|
},
|
|
16
|
+
"./i18n": {
|
|
17
|
+
"types": "./dist/i18n.d.ts",
|
|
18
|
+
"import": "./dist/i18n.js",
|
|
19
|
+
"require": "./dist/i18n.cjs"
|
|
20
|
+
},
|
|
16
21
|
"./package.json": "./package.json"
|
|
17
22
|
},
|
|
18
23
|
"files": [
|
|
@@ -41,15 +46,31 @@
|
|
|
41
46
|
"homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/lint-meta-rules",
|
|
42
47
|
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
43
48
|
"scripts": {
|
|
44
|
-
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
49
|
+
"build": "tsup src/index.ts src/i18n.ts --format esm,cjs --dts --clean",
|
|
45
50
|
"typecheck": "tsc --noEmit",
|
|
46
51
|
"test": "bun test"
|
|
47
52
|
},
|
|
48
53
|
"dependencies": {
|
|
49
|
-
"@noctcore/
|
|
54
|
+
"@noctcore/eslint-plugin-contracts": "^0.5.0",
|
|
55
|
+
"@noctcore/harness": "^0.3.0",
|
|
56
|
+
"@typescript-eslint/utils": "^8.61.1"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
60
|
+
"eslint": ">=9.0.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@typescript-eslint/parser": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"eslint": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
50
69
|
},
|
|
51
70
|
"devDependencies": {
|
|
52
71
|
"@types/node": "^22.0.0",
|
|
72
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
73
|
+
"eslint": "^10.7.0",
|
|
53
74
|
"tsup": "^8.5.1",
|
|
54
75
|
"typescript": "^5.6.0"
|
|
55
76
|
}
|