@trackunit/eslint-plugin-trackunit 0.6.98 → 0.6.100-alpha-770d9994af7.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 +7 -0
- package/package.json +2 -2
- package/src/lib/rules/no-dynamic-translation-key/no-dynamic-translation-key.js +26 -3
- package/src/lib/rules/no-unused-translation-key/expand-dynamic-key.d.ts +28 -0
- package/src/lib/rules/no-unused-translation-key/expand-dynamic-key.js +200 -0
- package/src/lib/rules/no-unused-translation-key/used-strings.d.ts +34 -3
- package/src/lib/rules/no-unused-translation-key/used-strings.js +66 -6
- package/src/lib/utils/program-utils.d.ts +28 -0
- package/src/lib/utils/program-utils.js +113 -0
- package/src/lib/utils/translation-key-types.d.ts +8 -0
- package/src/lib/utils/translation-key-types.js +26 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/eslint-plugin-trackunit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.100-alpha-770d9994af7.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
5
5
|
"repository": "https://github.com/Trackunit/manager",
|
|
6
6
|
"engines": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@nx/eslint-plugin": "23.1.0",
|
|
11
|
-
"@trackunit/css-classname-utils": "0.0.
|
|
11
|
+
"@trackunit/css-classname-utils": "0.0.6-alpha-770d9994af7.0",
|
|
12
12
|
"@typescript-eslint/eslint-plugin": "8.58.1",
|
|
13
13
|
"@typescript-eslint/utils": "8.58.1",
|
|
14
14
|
"eslint-config-prettier": "^10.1.8",
|
|
@@ -21,9 +21,30 @@ exports.noDynamicTranslationKey = void 0;
|
|
|
21
21
|
* t(categoryKey[category]);
|
|
22
22
|
*/
|
|
23
23
|
const utils_1 = require("@typescript-eslint/utils");
|
|
24
|
+
const translation_key_types_1 = require("../../utils/translation-key-types");
|
|
24
25
|
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/main/libs/eslint/plugin-trackunit/src/lib/rules/${name}/${name}.ts`);
|
|
25
26
|
/** A template literal that actually interpolates a value, e.g. `foo.${bar}` (not a plain `foo`). */
|
|
26
27
|
const isInterpolatedTemplate = (node) => node.type === utils_1.AST_NODE_TYPES.TemplateLiteral && node.expressions.length > 0;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a template literal's interpolations should be flagged: true unless every
|
|
30
|
+
* interpolated expression's TypeScript type is a finite string-literal union (in which
|
|
31
|
+
* case every possible resulting key is statically enumerable). If type resolution fails
|
|
32
|
+
* for any reason (e.g. a non-type-aware lint run), fails safe by flagging the template.
|
|
33
|
+
*/
|
|
34
|
+
const hasWidenedInterpolation = (context, templateLiteral) => {
|
|
35
|
+
try {
|
|
36
|
+
const services = utils_1.ESLintUtils.getParserServices(context);
|
|
37
|
+
const checker = services.program.getTypeChecker();
|
|
38
|
+
return templateLiteral.expressions.some(expr => {
|
|
39
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(expr);
|
|
40
|
+
const type = checker.getTypeAtLocation(tsNode);
|
|
41
|
+
return (0, translation_key_types_1.classifyInterpolationType)(type) === "widened";
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
27
48
|
/** A call to the translation function: either a bare `t(...)` or a member call like `i18n.t(...)`. */
|
|
28
49
|
const isTranslationCallee = (callee) => {
|
|
29
50
|
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
@@ -42,7 +63,7 @@ exports.noDynamicTranslationKey = createRule({
|
|
|
42
63
|
description: "Disallow building i18next translation keys dynamically with template strings; map values to explicit keys with a switch or Record instead.",
|
|
43
64
|
},
|
|
44
65
|
messages: {
|
|
45
|
-
dynamicKey: "Avoid building translation keys dynamically with template strings.
|
|
66
|
+
dynamicKey: "Avoid building translation keys dynamically with template strings unless every interpolated value has a finite string-literal union type (TypeScript can then enumerate every possible key). Otherwise, map the value to an explicit key using a switch statement or a Record so keys stay statically analysable and unused keys can be detected.",
|
|
46
67
|
},
|
|
47
68
|
schema: [],
|
|
48
69
|
},
|
|
@@ -54,7 +75,7 @@ exports.noDynamicTranslationKey = createRule({
|
|
|
54
75
|
return;
|
|
55
76
|
}
|
|
56
77
|
const [firstArg] = node.arguments;
|
|
57
|
-
if (firstArg && isInterpolatedTemplate(firstArg)) {
|
|
78
|
+
if (firstArg && isInterpolatedTemplate(firstArg) && hasWidenedInterpolation(context, firstArg)) {
|
|
58
79
|
context.report({ node: firstArg, messageId: "dynamicKey" });
|
|
59
80
|
}
|
|
60
81
|
},
|
|
@@ -63,7 +84,9 @@ exports.noDynamicTranslationKey = createRule({
|
|
|
63
84
|
return;
|
|
64
85
|
}
|
|
65
86
|
const { value } = node;
|
|
66
|
-
if (value?.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
87
|
+
if (value?.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
88
|
+
isInterpolatedTemplate(value.expression) &&
|
|
89
|
+
hasWidenedInterpolation(context, value.expression)) {
|
|
67
90
|
context.report({ node: value.expression, messageId: "dynamicKey" });
|
|
68
91
|
}
|
|
69
92
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* The result of expanding one direct `t(...)`/`i18nKey={...}` template-literal call:
|
|
4
|
+
* - `concrete` — every interpolation was a finite string-literal union, so every possible key is
|
|
5
|
+
* enumerated exactly.
|
|
6
|
+
* - `pattern` — at least one interpolation widened to `string`/`any`/`unknown`/an enum, so we fall
|
|
7
|
+
* back to today's conservative prefix/suffix wildcard.
|
|
8
|
+
*/
|
|
9
|
+
export type ExpandedDynamicKey = {
|
|
10
|
+
kind: "concrete";
|
|
11
|
+
keys: Array<string>;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "pattern";
|
|
14
|
+
prefix: string;
|
|
15
|
+
suffix: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Walks every real source file belonging to `projectDir` within `program`, expanding each direct
|
|
19
|
+
* `t(...)`/`i18nKey={...}` template-literal call into either its exact concrete keys or a
|
|
20
|
+
* conservative prefix/suffix pattern (see `ExpandedDynamicKey`).
|
|
21
|
+
*
|
|
22
|
+
* `program` may contain many more files than just this library's own — see `isScannableSourceFile` —
|
|
23
|
+
* so `projectDir` scopes the walk to the library actually being linted.
|
|
24
|
+
*
|
|
25
|
+
* Indirect/variable-built keys (`const key = \`a.${x}\`; t(key)`) are not matched — only template
|
|
26
|
+
* literals written directly inside the call/attribute are.
|
|
27
|
+
*/
|
|
28
|
+
export declare const expandDynamicKeysInProgram: (program: ts.Program, checker: ts.TypeChecker, projectDir: string) => Array<ExpandedDynamicKey>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.expandDynamicKeysInProgram = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* Type-aware expansion of dynamic (template-literal) translation keys.
|
|
7
|
+
*
|
|
8
|
+
* `used-strings.ts` detects dynamic keys via regex over raw source text, producing a
|
|
9
|
+
* `{ prefix, suffix }` wildcard pattern that conservatively treats every key matching that
|
|
10
|
+
* prefix/suffix as "used". This module replaces that conservative pattern with the exact set of
|
|
11
|
+
* concrete keys whenever every interpolation in a direct `t(...)`/`i18nKey={...}` template
|
|
12
|
+
* literal is a finite string-literal union — `` t(`cat.${category}`) `` with
|
|
13
|
+
* `category: "A" | "B"` becomes `["cat.A", "cat.B"]` instead of `{ prefix: "cat.", suffix: "" }`.
|
|
14
|
+
*
|
|
15
|
+
* This walks a real `ts.Program` (see `program-utils.ts`) directly, independent of ESLint's own
|
|
16
|
+
* per-file visitor, because it needs to examine every file in a library — not just the one file
|
|
17
|
+
* ESLint happens to be linting when it asks "is this key used?".
|
|
18
|
+
*
|
|
19
|
+
* Indirect/variable-built keys (`const key = \`a.${x}\`; t(key)`) are out of scope here and stay on
|
|
20
|
+
* the existing regex path in `used-strings.ts` — this only matches template literals written
|
|
21
|
+
* directly inside the call/attribute, mirroring the restriction `no-dynamic-translation-key`
|
|
22
|
+
* itself enforces.
|
|
23
|
+
*/
|
|
24
|
+
const path = tslib_1.__importStar(require("path"));
|
|
25
|
+
const ts = tslib_1.__importStar(require("typescript"));
|
|
26
|
+
const translation_key_types_1 = require("../../utils/translation-key-types");
|
|
27
|
+
const EXCLUDED_PATH_SEGMENTS = ["/node_modules/", "/dist/", "/generated/", "/coverage/", "/.git/"];
|
|
28
|
+
function isFiniteStringLiteralType(type) {
|
|
29
|
+
const flags = type.getFlags();
|
|
30
|
+
return (flags & ts.TypeFlags.StringLiteral) !== 0 && (flags & ts.TypeFlags.EnumLiteral) === 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Extracts the exact string values of a finite string-literal type or union thereof.
|
|
34
|
+
*
|
|
35
|
+
* A union may also carry an `undefined` member (see `classifyInterpolationType`, which still
|
|
36
|
+
* classifies such a union as `literalUnion` as long as every *other* member is a string literal).
|
|
37
|
+
* A real call site with that interpolation `undefined` produces the runtime string `"undefined"` --
|
|
38
|
+
* JS template literals stringify `undefined` to the text "undefined" -- so that literal string is
|
|
39
|
+
* included as one of the concrete values, alongside the real string-literal members.
|
|
40
|
+
*/
|
|
41
|
+
const getLiteralStringValues = (type) => {
|
|
42
|
+
if (type.isUnion()) {
|
|
43
|
+
const literalValues = type.types.filter(isFiniteStringLiteralType).map(member => member.value);
|
|
44
|
+
const hasUndefinedMember = type.types.some(member => (member.getFlags() & ts.TypeFlags.Undefined) !== 0);
|
|
45
|
+
return hasUndefinedMember ? [...literalValues, "undefined"] : literalValues;
|
|
46
|
+
}
|
|
47
|
+
return isFiniteStringLiteralType(type) ? [type.value] : [];
|
|
48
|
+
};
|
|
49
|
+
/** A call to the translation function: either a bare `t(...)` or a member call like `i18n.t(...)`. */
|
|
50
|
+
const isTranslationCalleeNode = (callee) => {
|
|
51
|
+
if (ts.isIdentifier(callee)) {
|
|
52
|
+
return callee.text === "t";
|
|
53
|
+
}
|
|
54
|
+
return ts.isPropertyAccessExpression(callee) && callee.name.text === "t";
|
|
55
|
+
};
|
|
56
|
+
/** Today's conservative prefix/suffix wildcard: the static text before the first, and after the last, interpolation. */
|
|
57
|
+
const buildPatternFallback = (template) => {
|
|
58
|
+
const spans = template.templateSpans;
|
|
59
|
+
const lastSpan = spans[spans.length - 1];
|
|
60
|
+
return {
|
|
61
|
+
kind: "pattern",
|
|
62
|
+
prefix: template.head.text,
|
|
63
|
+
suffix: lastSpan ? lastSpan.literal.text : "",
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Expands one direct template-literal call: concrete cross-product if every interpolation is a
|
|
68
|
+
* finite string-literal union, otherwise the prefix/suffix fallback. Fails safe to the fallback
|
|
69
|
+
* if type resolution throws for this call site, so one bad call can't crash the whole scan.
|
|
70
|
+
*/
|
|
71
|
+
const expandTemplate = (template, checker) => {
|
|
72
|
+
try {
|
|
73
|
+
const interpolations = [];
|
|
74
|
+
for (const span of template.templateSpans) {
|
|
75
|
+
const type = checker.getTypeAtLocation(span.expression);
|
|
76
|
+
if ((0, translation_key_types_1.classifyInterpolationType)(type) !== "literalUnion") {
|
|
77
|
+
return buildPatternFallback(template);
|
|
78
|
+
}
|
|
79
|
+
const values = getLiteralStringValues(type);
|
|
80
|
+
if (values.length === 0) {
|
|
81
|
+
return buildPatternFallback(template);
|
|
82
|
+
}
|
|
83
|
+
interpolations.push({ values, literalText: span.literal.text });
|
|
84
|
+
}
|
|
85
|
+
let keys = [template.head.text];
|
|
86
|
+
for (const { values, literalText } of interpolations) {
|
|
87
|
+
const next = [];
|
|
88
|
+
for (const key of keys) {
|
|
89
|
+
for (const value of values) {
|
|
90
|
+
next.push(key + value + literalText);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
keys = next;
|
|
94
|
+
}
|
|
95
|
+
return { kind: "concrete", keys };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return buildPatternFallback(template);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Unwraps `as`/`satisfies`/parenthesized wrappers to find a `TemplateExpression` underneath.
|
|
103
|
+
*
|
|
104
|
+
* `` t(`cat.${x}` as const) `` parses as an `AsExpression` whose `.expression` is the actual
|
|
105
|
+
* `TemplateExpression` — the wrapper is invisible to a direct `ts.isTemplateExpression(node)` check,
|
|
106
|
+
* even though the checker still reports a type for the inner expression regardless of the wrapper
|
|
107
|
+
* (see `expandTemplate`'s use of `checker.getTypeAtLocation(span.expression)`, which is unaffected by
|
|
108
|
+
* unwrapping here since spans live inside the returned node either way).
|
|
109
|
+
*/
|
|
110
|
+
const unwrapToTemplateExpression = (node) => {
|
|
111
|
+
let current = node;
|
|
112
|
+
while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) {
|
|
113
|
+
current = current.expression;
|
|
114
|
+
}
|
|
115
|
+
return ts.isTemplateExpression(current) ? current : null;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Whether a JSX attribute is `i18nKey={...}` with a template-literal expression inside the braces.
|
|
119
|
+
*/
|
|
120
|
+
const getI18nKeyTemplate = (node) => {
|
|
121
|
+
if (!ts.isIdentifier(node.name) || node.name.text !== "i18nKey") {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const { initializer } = node;
|
|
125
|
+
if (initializer && ts.isJsxExpression(initializer) && initializer.expression) {
|
|
126
|
+
return unwrapToTemplateExpression(initializer.expression);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
/** Finds every direct `t(`...${...}...`)`/`i18nKey={`...${...}...`}` template literal in a source file. */
|
|
131
|
+
const collectDirectTemplates = (sourceFile) => {
|
|
132
|
+
const templates = [];
|
|
133
|
+
const visit = (node) => {
|
|
134
|
+
if (ts.isCallExpression(node) && isTranslationCalleeNode(node.expression)) {
|
|
135
|
+
const [firstArg] = node.arguments;
|
|
136
|
+
const template = firstArg && unwrapToTemplateExpression(firstArg);
|
|
137
|
+
if (template) {
|
|
138
|
+
templates.push(template);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (ts.isJsxAttribute(node)) {
|
|
142
|
+
const template = getI18nKeyTemplate(node);
|
|
143
|
+
if (template) {
|
|
144
|
+
templates.push(template);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
ts.forEachChild(node, visit);
|
|
148
|
+
};
|
|
149
|
+
visit(sourceFile);
|
|
150
|
+
return templates;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Whether a source file belongs to the library's own real source — not a declaration file, a
|
|
154
|
+
* vendored/build artifact, or a file pulled into the `Program` only because the library imports it
|
|
155
|
+
* (`ts.createProgram` includes every file transitively reachable from `rootNames`, which in this
|
|
156
|
+
* monorepo's path-mapped setup means every other library the library under scan depends on).
|
|
157
|
+
* `projectDirPrefix` restricts the scan back to files actually inside the library being linted, so a
|
|
158
|
+
* dependency's own `t(...)` calls aren't misattributed as this library's usage (and aren't re-walked
|
|
159
|
+
* on every dependent library's lint pass).
|
|
160
|
+
*/
|
|
161
|
+
const isScannableSourceFile = (sourceFile, projectDirPrefix) => {
|
|
162
|
+
if (sourceFile.isDeclarationFile) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const fileName = sourceFile.fileName.replace(/\\/g, "/");
|
|
166
|
+
if (!/\.tsx?$/.test(fileName)) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (!fileName.startsWith(projectDirPrefix)) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return !EXCLUDED_PATH_SEGMENTS.some(segment => fileName.includes(segment));
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Walks every real source file belonging to `projectDir` within `program`, expanding each direct
|
|
176
|
+
* `t(...)`/`i18nKey={...}` template-literal call into either its exact concrete keys or a
|
|
177
|
+
* conservative prefix/suffix pattern (see `ExpandedDynamicKey`).
|
|
178
|
+
*
|
|
179
|
+
* `program` may contain many more files than just this library's own — see `isScannableSourceFile` —
|
|
180
|
+
* so `projectDir` scopes the walk to the library actually being linted.
|
|
181
|
+
*
|
|
182
|
+
* Indirect/variable-built keys (`const key = \`a.${x}\`; t(key)`) are not matched — only template
|
|
183
|
+
* literals written directly inside the call/attribute are.
|
|
184
|
+
*/
|
|
185
|
+
const expandDynamicKeysInProgram = (program, checker, projectDir) => {
|
|
186
|
+
const resolvedProjectDir = path.resolve(projectDir).replace(/\\/g, "/");
|
|
187
|
+
const projectDirPrefix = resolvedProjectDir.endsWith("/") ? resolvedProjectDir : `${resolvedProjectDir}/`;
|
|
188
|
+
const results = [];
|
|
189
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
190
|
+
if (!isScannableSourceFile(sourceFile, projectDirPrefix)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const template of collectDirectTemplates(sourceFile)) {
|
|
194
|
+
results.push(expandTemplate(template, checker));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
};
|
|
199
|
+
exports.expandDynamicKeysInProgram = expandDynamicKeysInProgram;
|
|
200
|
+
//# sourceMappingURL=expand-dynamic-key.js.map
|
|
@@ -18,11 +18,20 @@ export type DynamicPattern = {
|
|
|
18
18
|
* `` t(`pages.categories.${x}`) `` contributes `{ prefix: "pages.categories.", suffix: "" }` and
|
|
19
19
|
* `` t(`${root}.title`) `` contributes `{ prefix: "", suffix: ".title" }`. A key is treated as used
|
|
20
20
|
* when it both starts with the prefix and ends with the suffix of some pattern — the smallest set of
|
|
21
|
-
* keys the interpolation could possibly produce.
|
|
21
|
+
* keys the interpolation could possibly produce. Populated both by the regex scan below (for
|
|
22
|
+
* indirect/variable-built templates, and for direct templates when a type-aware `ts.Program`
|
|
23
|
+
* couldn't be built) and by `expandDynamicKeysInProgram`'s `"pattern"` results (for direct
|
|
24
|
+
* templates whose interpolation type widened past a finite literal union).
|
|
25
|
+
* - `concreteDynamicKeys` — exact keys a direct `` t(`...${x}...`) ``/`i18nKey={...}` template can
|
|
26
|
+
* produce, when every interpolation resolves to a finite string-literal union. Computed by
|
|
27
|
+
* `expandDynamicKeysInProgram` walking a real type-checked `ts.Program` (see `program-utils.ts`).
|
|
28
|
+
* Precise, unlike `dynamicPatterns`: a key under the same static prefix that isn't one of these
|
|
29
|
+
* exact keys is correctly reported as unused.
|
|
22
30
|
*/
|
|
23
31
|
export type UsedStrings = {
|
|
24
32
|
sourceText: string;
|
|
25
33
|
dynamicPatterns: Array<DynamicPattern>;
|
|
34
|
+
concreteDynamicKeys: Set<string>;
|
|
26
35
|
};
|
|
27
36
|
/**
|
|
28
37
|
* Build the used-strings summary from already-read source file contents.
|
|
@@ -30,14 +39,36 @@ export type UsedStrings = {
|
|
|
30
39
|
* Kept free of filesystem access so the ESLint rule and the
|
|
31
40
|
* `devtools/translations/remove-unused-translation-keys` script share one implementation of what
|
|
32
41
|
* "used" means, each doing its own IO.
|
|
42
|
+
*
|
|
43
|
+
* `skipTrustedTemplates` — when a type-aware `ts.Program` was available and already produced precise
|
|
44
|
+
* results for every direct `t(...)`/`i18nKey={...}` template (see `getUsedTranslationStrings`), the
|
|
45
|
+
* regex-based "trusted" detection is redundant and would only re-introduce the conservative
|
|
46
|
+
* prefix/suffix wildcard the Program-based scan was meant to replace. When `true`, the trusted regex
|
|
47
|
+
* pass is skipped, and any template body it would have matched is also excluded from the "untrusted"
|
|
48
|
+
* pass (which otherwise re-discovers the same body via `ANY_TEMPLATE_REGEX` and — for a namespaced,
|
|
49
|
+
* dotted key — records the same wildcard pattern anyway). Defaults to `false`, preserving today's
|
|
50
|
+
* behavior exactly for libraries with no resolvable tsconfig.
|
|
33
51
|
*/
|
|
34
|
-
export declare const buildUsedStrings: (contents: Array<string
|
|
52
|
+
export declare const buildUsedStrings: (contents: Array<string>, options?: {
|
|
53
|
+
skipTrustedTemplates?: boolean;
|
|
54
|
+
}) => UsedStrings;
|
|
35
55
|
/**
|
|
36
56
|
* Read a library's `.ts`/`.tsx` source, keeping the raw text plus any dynamic key patterns it builds.
|
|
57
|
+
*
|
|
58
|
+
* Also attempts a type-aware pass: `getLibraryProgram` builds (and caches) a `ts.Program` scoped to
|
|
59
|
+
* the library, and `expandDynamicKeysInProgram` walks it to resolve every direct
|
|
60
|
+
* `t(...)`/`i18nKey={...}` template literal into either its exact concrete keys (when every
|
|
61
|
+
* interpolation is a finite string-literal union) or today's conservative `{prefix, suffix}` pattern
|
|
62
|
+
* (when any interpolation widened). When that succeeds, it fully replaces the regex-based "trusted"
|
|
63
|
+
* template detection (see `buildUsedStrings`'s `skipTrustedTemplates`), since it covers the same call
|
|
64
|
+
* sites more precisely. When no tsconfig can be resolved, or anything throws, this step is skipped
|
|
65
|
+
* entirely and behavior falls back to the regex-only detection that ran before this rule was made
|
|
66
|
+
* type-aware.
|
|
37
67
|
*/
|
|
38
68
|
export declare const getUsedTranslationStrings: (projectDir: string) => UsedStrings;
|
|
39
69
|
/**
|
|
40
70
|
* Whether a translation key is referenced by the scanned source — quoted verbatim, through a dynamic
|
|
41
|
-
* template's static bookends,
|
|
71
|
+
* template's static bookends, as one of the exact keys a type-aware dynamic template can produce, or
|
|
72
|
+
* as the plural form of a referenced base key.
|
|
42
73
|
*/
|
|
43
74
|
export declare const isTranslationKeyUsed: (used: UsedStrings, key: string) => boolean;
|
|
@@ -4,6 +4,8 @@ exports.isTranslationKeyUsed = exports.getUsedTranslationStrings = exports.build
|
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const fs = tslib_1.__importStar(require("fs"));
|
|
6
6
|
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const program_utils_1 = require("../../utils/program-utils");
|
|
8
|
+
const expand_dynamic_key_1 = require("./expand-dynamic-key");
|
|
7
9
|
const EXCLUDED_DIRS = new Set(["node_modules", "dist", "generated", "coverage", ".git"]);
|
|
8
10
|
const SCANNED_EXTENSIONS = [".ts", ".tsx"];
|
|
9
11
|
/**
|
|
@@ -67,24 +69,53 @@ const collectSourceFiles = (dir, files = []) => {
|
|
|
67
69
|
* Kept free of filesystem access so the ESLint rule and the
|
|
68
70
|
* `devtools/translations/remove-unused-translation-keys` script share one implementation of what
|
|
69
71
|
* "used" means, each doing its own IO.
|
|
72
|
+
*
|
|
73
|
+
* `skipTrustedTemplates` — when a type-aware `ts.Program` was available and already produced precise
|
|
74
|
+
* results for every direct `t(...)`/`i18nKey={...}` template (see `getUsedTranslationStrings`), the
|
|
75
|
+
* regex-based "trusted" detection is redundant and would only re-introduce the conservative
|
|
76
|
+
* prefix/suffix wildcard the Program-based scan was meant to replace. When `true`, the trusted regex
|
|
77
|
+
* pass is skipped, and any template body it would have matched is also excluded from the "untrusted"
|
|
78
|
+
* pass (which otherwise re-discovers the same body via `ANY_TEMPLATE_REGEX` and — for a namespaced,
|
|
79
|
+
* dotted key — records the same wildcard pattern anyway). Defaults to `false`, preserving today's
|
|
80
|
+
* behavior exactly for libraries with no resolvable tsconfig.
|
|
70
81
|
*/
|
|
71
|
-
const buildUsedStrings = (contents) => {
|
|
82
|
+
const buildUsedStrings = (contents, options = {}) => {
|
|
83
|
+
const { skipTrustedTemplates = false } = options;
|
|
72
84
|
const patterns = new Map();
|
|
73
85
|
for (const content of contents) {
|
|
86
|
+
const trustedBodies = new Set();
|
|
74
87
|
let translationTemplate;
|
|
75
88
|
while ((translationTemplate = TRANSLATION_TEMPLATE_REGEX.exec(content)) !== null) {
|
|
76
|
-
|
|
89
|
+
const body = translationTemplate[1] ?? "";
|
|
90
|
+
trustedBodies.add(body);
|
|
91
|
+
if (!skipTrustedTemplates) {
|
|
92
|
+
recordTemplatePattern(body, true, patterns);
|
|
93
|
+
}
|
|
77
94
|
}
|
|
78
95
|
let anyTemplate;
|
|
79
96
|
while ((anyTemplate = ANY_TEMPLATE_REGEX.exec(content)) !== null) {
|
|
80
|
-
|
|
97
|
+
const body = anyTemplate[1] ?? "";
|
|
98
|
+
if (skipTrustedTemplates && trustedBodies.has(body)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
recordTemplatePattern(body, false, patterns);
|
|
81
102
|
}
|
|
82
103
|
}
|
|
83
|
-
return { sourceText: contents.join("\n"), dynamicPatterns: [...patterns.values()] };
|
|
104
|
+
return { sourceText: contents.join("\n"), dynamicPatterns: [...patterns.values()], concreteDynamicKeys: new Set() };
|
|
84
105
|
};
|
|
85
106
|
exports.buildUsedStrings = buildUsedStrings;
|
|
86
107
|
/**
|
|
87
108
|
* Read a library's `.ts`/`.tsx` source, keeping the raw text plus any dynamic key patterns it builds.
|
|
109
|
+
*
|
|
110
|
+
* Also attempts a type-aware pass: `getLibraryProgram` builds (and caches) a `ts.Program` scoped to
|
|
111
|
+
* the library, and `expandDynamicKeysInProgram` walks it to resolve every direct
|
|
112
|
+
* `t(...)`/`i18nKey={...}` template literal into either its exact concrete keys (when every
|
|
113
|
+
* interpolation is a finite string-literal union) or today's conservative `{prefix, suffix}` pattern
|
|
114
|
+
* (when any interpolation widened). When that succeeds, it fully replaces the regex-based "trusted"
|
|
115
|
+
* template detection (see `buildUsedStrings`'s `skipTrustedTemplates`), since it covers the same call
|
|
116
|
+
* sites more precisely. When no tsconfig can be resolved, or anything throws, this step is skipped
|
|
117
|
+
* entirely and behavior falls back to the regex-only detection that ran before this rule was made
|
|
118
|
+
* type-aware.
|
|
88
119
|
*/
|
|
89
120
|
const getUsedTranslationStrings = (projectDir) => {
|
|
90
121
|
const contents = [];
|
|
@@ -96,17 +127,46 @@ const getUsedTranslationStrings = (projectDir) => {
|
|
|
96
127
|
continue;
|
|
97
128
|
}
|
|
98
129
|
}
|
|
99
|
-
|
|
130
|
+
let expandedResults = null;
|
|
131
|
+
try {
|
|
132
|
+
const libraryProgram = (0, program_utils_1.getLibraryProgram)(projectDir);
|
|
133
|
+
if (libraryProgram) {
|
|
134
|
+
expandedResults = (0, expand_dynamic_key_1.expandDynamicKeysInProgram)(libraryProgram.program, libraryProgram.checker, projectDir);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
expandedResults = null;
|
|
139
|
+
}
|
|
140
|
+
const used = (0, exports.buildUsedStrings)(contents, { skipTrustedTemplates: expandedResults !== null });
|
|
141
|
+
if (expandedResults) {
|
|
142
|
+
const patternKeys = new Set(used.dynamicPatterns.map(({ prefix, suffix }) => JSON.stringify([prefix, suffix])));
|
|
143
|
+
for (const result of expandedResults) {
|
|
144
|
+
if (result.kind === "concrete") {
|
|
145
|
+
for (const key of result.keys) {
|
|
146
|
+
used.concreteDynamicKeys.add(key);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const patternKey = JSON.stringify([result.prefix, result.suffix]);
|
|
151
|
+
if (!patternKeys.has(patternKey)) {
|
|
152
|
+
patternKeys.add(patternKey);
|
|
153
|
+
used.dynamicPatterns.push({ prefix: result.prefix, suffix: result.suffix });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return used;
|
|
100
158
|
};
|
|
101
159
|
exports.getUsedTranslationStrings = getUsedTranslationStrings;
|
|
102
160
|
/** Whether the key appears in the source as a quoted string, in any of the three quote styles. */
|
|
103
161
|
const isQuotedInSource = (sourceText, key) => sourceText.includes(`"${key}"`) || sourceText.includes(`'${key}'`) || sourceText.includes(`\`${key}\``);
|
|
104
162
|
/**
|
|
105
163
|
* Whether a translation key is referenced by the scanned source — quoted verbatim, through a dynamic
|
|
106
|
-
* template's static bookends,
|
|
164
|
+
* template's static bookends, as one of the exact keys a type-aware dynamic template can produce, or
|
|
165
|
+
* as the plural form of a referenced base key.
|
|
107
166
|
*/
|
|
108
167
|
const isTranslationKeyUsed = (used, key) => {
|
|
109
168
|
const isReferenced = (candidate) => isQuotedInSource(used.sourceText, candidate) ||
|
|
169
|
+
used.concreteDynamicKeys.has(candidate) ||
|
|
110
170
|
used.dynamicPatterns.some(({ prefix, suffix }) => candidate.startsWith(prefix) && candidate.endsWith(suffix));
|
|
111
171
|
return isReferenced(key) || (PLURAL_SUFFIX_REGEX.test(key) && isReferenced(key.replace(PLURAL_SUFFIX_REGEX, "")));
|
|
112
172
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Utility functions for building a type-checking TypeScript `Program` scoped to a library.
|
|
4
|
+
*
|
|
5
|
+
* ## Main Functions
|
|
6
|
+
* - `getLibraryProgram()` - Build (and cache) a `ts.Program`/`ts.TypeChecker` pair for a library
|
|
7
|
+
* - `clearProgramCache()` - Clear the cache. Useful for testing.
|
|
8
|
+
*/
|
|
9
|
+
export type LibraryProgram = {
|
|
10
|
+
program: ts.Program;
|
|
11
|
+
checker: ts.TypeChecker;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Build (and cache) a `ts.Program` scoped to the nearest `tsconfig.json` found by walking up from
|
|
15
|
+
* `projectDir`. Falls back to sibling leaf tsconfigs (`tsconfig.lib.json`, etc.) when the discovered
|
|
16
|
+
* config is solution-style and covers no real source files.
|
|
17
|
+
*
|
|
18
|
+
* @param projectDir - Absolute path to a library's directory (or a directory inside it)
|
|
19
|
+
* @returns The `Program`/`TypeChecker` pair, or `null` if no usable tsconfig was found
|
|
20
|
+
* @example
|
|
21
|
+
* const result = getLibraryProgram("/workspace/libs/my-lib");
|
|
22
|
+
* // Returns: { program, checker }
|
|
23
|
+
*/
|
|
24
|
+
export declare const getLibraryProgram: (projectDir: string) => LibraryProgram | null;
|
|
25
|
+
/**
|
|
26
|
+
* Clear the Program cache. Useful for testing.
|
|
27
|
+
*/
|
|
28
|
+
export declare const clearProgramCache: () => void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clearProgramCache = exports.getLibraryProgram = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const ts = tslib_1.__importStar(require("typescript"));
|
|
7
|
+
const file_utils_1 = require("./file-utils");
|
|
8
|
+
/**
|
|
9
|
+
* Cache for library Programs to avoid re-parsing tsconfig and rebuilding the (expensive) `ts.Program`
|
|
10
|
+
* on every call. Key is the resolved `projectDir` passed in, value is the built program/checker pair.
|
|
11
|
+
*/
|
|
12
|
+
const programCache = new Map();
|
|
13
|
+
/**
|
|
14
|
+
* Nx-convention leaf tsconfigs to fall back to, in priority order, when the discovered `tsconfig.json`
|
|
15
|
+
* turns out to be "solution style" (no real source files of its own — just `references` to these).
|
|
16
|
+
*/
|
|
17
|
+
const LEAF_TSCONFIG_CANDIDATES = ["tsconfig.lib.json", "tsconfig.app.json", "tsconfig.spec.json"];
|
|
18
|
+
/** Parse a tsconfig file at `tsconfigPath`, resolving relative paths against its own directory. */
|
|
19
|
+
const parseTsconfigAt = (tsconfigPath) => {
|
|
20
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
21
|
+
if (configFile.error) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(tsconfigPath));
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a tsconfig that actually covers real source files, starting from `tsconfigPath`.
|
|
28
|
+
*
|
|
29
|
+
* Many Nx libraries use a "solution style" root `tsconfig.json` (`files: []` plus `references` to
|
|
30
|
+
* `tsconfig.lib.json`/`tsconfig.spec.json`/etc.) that parses to an empty `fileNames` on its own. When
|
|
31
|
+
* that happens, fall back to the sibling leaf configs that actually resolve source files.
|
|
32
|
+
*
|
|
33
|
+
* Every usable leaf config's `fileNames` are unioned (not just the first match): `tsconfig.lib.json`
|
|
34
|
+
* conventionally excludes spec/test/story/demo files, which live only in `tsconfig.spec.json`.
|
|
35
|
+
* Building the Program from a single leaf would silently drop those files from type-aware scanning —
|
|
36
|
+
* while the regex-based fallback elsewhere still sees them via a plain filesystem walk — leaving any
|
|
37
|
+
* dynamic key referenced only from such a file neither wildcarded nor concretely expanded (i.e.
|
|
38
|
+
* incorrectly reported as unused). Compiler `options` are taken from the first usable config; the
|
|
39
|
+
* leaf configs share the same `extends` chain so this only affects unrelated settings like `types`.
|
|
40
|
+
*/
|
|
41
|
+
const resolveUsableTsconfig = (tsconfigPath) => {
|
|
42
|
+
const parsed = parseTsconfigAt(tsconfigPath);
|
|
43
|
+
if (parsed && parsed.fileNames.some(fileName => !fileName.endsWith(".d.ts"))) {
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
const configDir = path.dirname(tsconfigPath);
|
|
47
|
+
const usableConfigs = [];
|
|
48
|
+
for (const candidate of LEAF_TSCONFIG_CANDIDATES) {
|
|
49
|
+
const candidatePath = path.join(configDir, candidate);
|
|
50
|
+
if (!ts.sys.fileExists(candidatePath)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const candidateParsed = parseTsconfigAt(candidatePath);
|
|
54
|
+
if (candidateParsed && candidateParsed.fileNames.some(fileName => !fileName.endsWith(".d.ts"))) {
|
|
55
|
+
usableConfigs.push(candidateParsed);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const [primaryConfig, ...restConfigs] = usableConfigs;
|
|
59
|
+
if (!primaryConfig) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const fileNames = new Set(primaryConfig.fileNames);
|
|
63
|
+
for (const config of restConfigs) {
|
|
64
|
+
for (const fileName of config.fileNames) {
|
|
65
|
+
fileNames.add(fileName);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ...primaryConfig, fileNames: [...fileNames] };
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Build (and cache) a `ts.Program` scoped to the nearest `tsconfig.json` found by walking up from
|
|
72
|
+
* `projectDir`. Falls back to sibling leaf tsconfigs (`tsconfig.lib.json`, etc.) when the discovered
|
|
73
|
+
* config is solution-style and covers no real source files.
|
|
74
|
+
*
|
|
75
|
+
* @param projectDir - Absolute path to a library's directory (or a directory inside it)
|
|
76
|
+
* @returns The `Program`/`TypeChecker` pair, or `null` if no usable tsconfig was found
|
|
77
|
+
* @example
|
|
78
|
+
* const result = getLibraryProgram("/workspace/libs/my-lib");
|
|
79
|
+
* // Returns: { program, checker }
|
|
80
|
+
*/
|
|
81
|
+
const getLibraryProgram = (projectDir) => {
|
|
82
|
+
const resolvedProjectDir = path.resolve(projectDir);
|
|
83
|
+
const cached = programCache.get(resolvedProjectDir);
|
|
84
|
+
if (cached) {
|
|
85
|
+
return cached;
|
|
86
|
+
}
|
|
87
|
+
// `findNearestFile` starts looking in the *parent* of the file path it's given, so probing with a
|
|
88
|
+
// synthetic file inside `resolvedProjectDir` makes it look in `resolvedProjectDir` itself first.
|
|
89
|
+
const tsconfigPath = (0, file_utils_1.findNearestFile)(path.join(resolvedProjectDir, "__probe__.ts"), "tsconfig.json");
|
|
90
|
+
if (!tsconfigPath) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const parsedConfig = resolveUsableTsconfig(tsconfigPath);
|
|
94
|
+
if (!parsedConfig) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const program = ts.createProgram({
|
|
98
|
+
rootNames: parsedConfig.fileNames,
|
|
99
|
+
options: parsedConfig.options,
|
|
100
|
+
});
|
|
101
|
+
const result = { program, checker: program.getTypeChecker() };
|
|
102
|
+
programCache.set(resolvedProjectDir, result);
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
exports.getLibraryProgram = getLibraryProgram;
|
|
106
|
+
/**
|
|
107
|
+
* Clear the Program cache. Useful for testing.
|
|
108
|
+
*/
|
|
109
|
+
const clearProgramCache = () => {
|
|
110
|
+
programCache.clear();
|
|
111
|
+
};
|
|
112
|
+
exports.clearProgramCache = clearProgramCache;
|
|
113
|
+
//# sourceMappingURL=program-utils.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Classifies a template-literal interpolation's TypeScript type as either a finite
|
|
4
|
+
* string-literal union (every possible value is statically known, e.g. `"a" | "b"`) or a
|
|
5
|
+
* widened type (plain `string`, `any`, `unknown`, or any enum) whose exact set of values
|
|
6
|
+
* cannot be enumerated at compile time.
|
|
7
|
+
*/
|
|
8
|
+
export declare function classifyInterpolationType(type: ts.Type): "literalUnion" | "widened";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyInterpolationType = classifyInterpolationType;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const ts = tslib_1.__importStar(require("typescript"));
|
|
6
|
+
function isFiniteStringLiteralType(type) {
|
|
7
|
+
const flags = type.getFlags();
|
|
8
|
+
return (flags & ts.TypeFlags.StringLiteral) !== 0 && (flags & ts.TypeFlags.EnumLiteral) === 0;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Classifies a template-literal interpolation's TypeScript type as either a finite
|
|
12
|
+
* string-literal union (every possible value is statically known, e.g. `"a" | "b"`) or a
|
|
13
|
+
* widened type (plain `string`, `any`, `unknown`, or any enum) whose exact set of values
|
|
14
|
+
* cannot be enumerated at compile time.
|
|
15
|
+
*/
|
|
16
|
+
function classifyInterpolationType(type) {
|
|
17
|
+
if (type.isUnion()) {
|
|
18
|
+
const nonUndefinedMembers = type.types.filter(member => (member.getFlags() & ts.TypeFlags.Undefined) === 0);
|
|
19
|
+
// `.some(isFiniteStringLiteralType)` was previously ANDed in here too, but it's implied by
|
|
20
|
+
// `length > 0 && every(...)`: if every member of a non-empty array passes, at least one does.
|
|
21
|
+
const isFiniteUnion = nonUndefinedMembers.length > 0 && nonUndefinedMembers.every(isFiniteStringLiteralType);
|
|
22
|
+
return isFiniteUnion ? "literalUnion" : "widened";
|
|
23
|
+
}
|
|
24
|
+
return isFiniteStringLiteralType(type) ? "literalUnion" : "widened";
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=translation-key-types.js.map
|