Skip to content

Commit f0a6fac

Browse files
committed
ci: require nls for quickpick valdiation and descriptions
1 parent 543a726 commit f0a6fac

15 files changed

Lines changed: 430 additions & 10 deletions

File tree

eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export default [
144144
'local/no-unused-i18n-messages': 'error',
145145
'local/no-vscode-message-literals': 'error',
146146
'local/no-vscode-progress-title-literals': 'error',
147+
'local/no-vscode-quickpick-description-literals': 'error',
148+
'local/no-vscode-validateinput-literals': 'error',
147149
'workspaces/no-relative-imports': 'error',
148150
'unicorn/consistent-date-clone': 'error',
149151
'unicorn/consistent-empty-array-spread': 'error',

packages/eslint-local-rules/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import { noExplicitEffectReturnType } from './noExplicitEffectReturnType';
1515
import { noUnusedI18nMessages } from './noUnusedI18nMessages';
1616
import { noVscodeMessageLiterals } from './noVscodeMessageLiterals';
1717
import { noVscodeProgressTitleLiterals } from './noVscodeProgressTitleLiterals';
18+
import { noVscodeQuickpickDescriptionLiterals } from './noVscodeQuickpickDescriptionLiterals';
1819
import { noVscodeUri } from './noVscodeUri';
20+
import { noVscodeValidateinputLiterals } from './noVscodeValidateinputLiterals';
1921
import { packageJsonCommandRefs } from './packageJsonCommandRefs';
2022
import { packageJsonExtensionIcon } from './packageJsonExtensionIcon';
2123
import { packageJsonI18nDescriptions } from './packageJsonI18nDescriptions';
@@ -35,8 +37,10 @@ const plugin = {
3537
'no-explicit-effect-return-type': noExplicitEffectReturnType,
3638
'no-unused-i18n-messages': noUnusedI18nMessages,
3739
'no-vscode-message-literals': noVscodeMessageLiterals,
38-
'no-vscode-uri': noVscodeUri,
3940
'no-vscode-progress-title-literals': noVscodeProgressTitleLiterals,
41+
'no-vscode-quickpick-description-literals': noVscodeQuickpickDescriptionLiterals,
42+
'no-vscode-uri': noVscodeUri,
43+
'no-vscode-validateinput-literals': noVscodeValidateinputLiterals,
4044
'package-json-i18n-descriptions': packageJsonI18nDescriptions,
4145
'package-json-extension-icon': packageJsonExtensionIcon,
4246
'package-json-icon-paths': packageJsonIconPaths,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
9+
import { RuleCreator } from '@typescript-eslint/utils/eslint-utils';
10+
11+
/** Check if an expression is a call to nls.localize() */
12+
const isNlsLocalizeCall = (expr: TSESTree.Expression): boolean =>
13+
expr.type === AST_NODE_TYPES.CallExpression &&
14+
expr.callee.type === AST_NODE_TYPES.MemberExpression &&
15+
expr.callee.object.type === AST_NODE_TYPES.Identifier &&
16+
expr.callee.object.name === 'nls' &&
17+
expr.callee.property.type === AST_NODE_TYPES.Identifier &&
18+
expr.callee.property.name === 'localize';
19+
20+
/** Check if an expression is a string literal or template literal without nls.localize() */
21+
const isStringLiteralOrTemplateWithoutNls = (expr: TSESTree.Expression): boolean => {
22+
if (expr.type === AST_NODE_TYPES.Literal && typeof expr.value === 'string') {
23+
return true;
24+
}
25+
if (expr.type === AST_NODE_TYPES.TemplateLiteral) {
26+
return !expr.expressions.some(isNlsLocalizeCall);
27+
}
28+
return false;
29+
};
30+
31+
/** Check if this is vscode.window.showQuickPick or window.showQuickPick */
32+
const isShowQuickPickCall = (node: TSESTree.CallExpression): boolean => {
33+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false;
34+
const callee = node.callee;
35+
if (callee.property.type !== AST_NODE_TYPES.Identifier || callee.property.name !== 'showQuickPick') {
36+
return false;
37+
}
38+
if (callee.object.type === AST_NODE_TYPES.MemberExpression) {
39+
return (
40+
callee.object.object.type === AST_NODE_TYPES.Identifier &&
41+
callee.object.object.name === 'vscode' &&
42+
callee.object.property.type === AST_NODE_TYPES.Identifier &&
43+
callee.object.property.name === 'window'
44+
);
45+
}
46+
if (callee.object.type === AST_NODE_TYPES.Identifier) {
47+
return callee.object.name === 'window';
48+
}
49+
return false;
50+
};
51+
52+
/** Find the description property in a Quick Pick item object */
53+
const findDescriptionProperty = (obj: TSESTree.ObjectExpression): TSESTree.Property | undefined => {
54+
const p = obj.properties.find(
55+
prop =>
56+
prop.type === AST_NODE_TYPES.Property &&
57+
prop.key.type === AST_NODE_TYPES.Identifier &&
58+
prop.key.name === 'description'
59+
);
60+
return p?.type === AST_NODE_TYPES.Property ? p : undefined;
61+
};
62+
63+
export const noVscodeQuickpickDescriptionLiterals = RuleCreator.withoutDocs({
64+
meta: {
65+
type: 'problem',
66+
docs: {
67+
description:
68+
'Disallow string literals in showQuickPick item descriptions - use nls.localize() instead'
69+
},
70+
schema: [],
71+
messages: {
72+
noLiteral:
73+
"showQuickPick item description must use nls.localize('message_key'), not a string literal. Add the message to i18n.ts and use nls.localize() to reference it."
74+
}
75+
},
76+
defaultOptions: [],
77+
create: context => ({
78+
CallExpression: (node: TSESTree.CallExpression): void => {
79+
if (!isShowQuickPickCall(node)) return;
80+
81+
const itemsArg = node.arguments[0];
82+
if (itemsArg?.type !== AST_NODE_TYPES.ArrayExpression) return;
83+
84+
for (const element of itemsArg.elements) {
85+
if (element?.type !== AST_NODE_TYPES.ObjectExpression) continue;
86+
const descProp = findDescriptionProperty(element);
87+
if (!descProp?.value) continue;
88+
89+
const value = descProp.value;
90+
if (value.type === AST_NODE_TYPES.AssignmentPattern) continue;
91+
92+
const expr = value as TSESTree.Expression;
93+
if (isStringLiteralOrTemplateWithoutNls(expr)) {
94+
context.report({ node: value, messageId: 'noLiteral' });
95+
}
96+
}
97+
}
98+
})
99+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
9+
import { RuleCreator } from '@typescript-eslint/utils/eslint-utils';
10+
11+
/** Check if an expression is a call to nls.localize() */
12+
const isNlsLocalizeCall = (expr: TSESTree.Expression): boolean =>
13+
expr.type === AST_NODE_TYPES.CallExpression &&
14+
expr.callee.type === AST_NODE_TYPES.MemberExpression &&
15+
expr.callee.object.type === AST_NODE_TYPES.Identifier &&
16+
expr.callee.object.name === 'nls' &&
17+
expr.callee.property.type === AST_NODE_TYPES.Identifier &&
18+
expr.callee.property.name === 'localize';
19+
20+
/** Collect all string literal nodes in an expression that would be returned as error message */
21+
const collectStringLiteralNodes = (
22+
expr: TSESTree.Expression | null | undefined,
23+
out: TSESTree.Node[]
24+
): void => {
25+
if (!expr) return;
26+
if (isNlsLocalizeCall(expr)) return;
27+
if (expr.type === AST_NODE_TYPES.Literal && typeof expr.value === 'string') {
28+
out.push(expr);
29+
return;
30+
}
31+
if (expr.type === AST_NODE_TYPES.TemplateLiteral && !expr.expressions.some(isNlsLocalizeCall)) {
32+
out.push(expr);
33+
return;
34+
}
35+
if (expr.type === AST_NODE_TYPES.ConditionalExpression) {
36+
collectStringLiteralNodes(expr.consequent, out);
37+
collectStringLiteralNodes(expr.alternate, out);
38+
return;
39+
}
40+
if (expr.type === AST_NODE_TYPES.LogicalExpression) {
41+
collectStringLiteralNodes(expr.left, out);
42+
collectStringLiteralNodes(expr.right, out);
43+
}
44+
};
45+
46+
/** Check if this is vscode.window.showInputBox or window.showInputBox */
47+
const isShowInputBoxCall = (node: TSESTree.CallExpression): boolean => {
48+
if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false;
49+
const callee = node.callee;
50+
if (callee.property.type !== AST_NODE_TYPES.Identifier || callee.property.name !== 'showInputBox') {
51+
return false;
52+
}
53+
if (callee.object.type === AST_NODE_TYPES.MemberExpression) {
54+
return (
55+
callee.object.object.type === AST_NODE_TYPES.Identifier &&
56+
callee.object.object.name === 'vscode' &&
57+
callee.object.property.type === AST_NODE_TYPES.Identifier &&
58+
callee.object.property.name === 'window'
59+
);
60+
}
61+
if (callee.object.type === AST_NODE_TYPES.Identifier) {
62+
return callee.object.name === 'window';
63+
}
64+
return false;
65+
};
66+
67+
/** Find the validateInput property in showInputBox options */
68+
const findValidateInputProperty = (obj: TSESTree.ObjectExpression): TSESTree.Property | undefined => {
69+
const p = obj.properties.find(
70+
prop =>
71+
prop.type === AST_NODE_TYPES.Property &&
72+
prop.key.type === AST_NODE_TYPES.Identifier &&
73+
prop.key.name === 'validateInput'
74+
);
75+
return p?.type === AST_NODE_TYPES.Property ? p : undefined;
76+
};
77+
78+
/** Recursively collect all ReturnStatement nodes from a block or statement */
79+
const collectReturnStatements = (
80+
node: TSESTree.Node,
81+
out: TSESTree.ReturnStatement[]
82+
): void => {
83+
if (node.type === AST_NODE_TYPES.ReturnStatement) {
84+
out.push(node);
85+
return;
86+
}
87+
if (node.type === AST_NODE_TYPES.BlockStatement) {
88+
for (const stmt of node.body) {
89+
collectReturnStatements(stmt, out);
90+
}
91+
return;
92+
}
93+
if (node.type === AST_NODE_TYPES.IfStatement) {
94+
collectReturnStatements(node.consequent, out);
95+
if (node.alternate) collectReturnStatements(node.alternate, out);
96+
return;
97+
}
98+
if (node.type === AST_NODE_TYPES.SwitchCase) {
99+
for (const stmt of node.consequent) {
100+
collectReturnStatements(stmt, out);
101+
}
102+
}
103+
};
104+
105+
/** Traverse function body for ReturnStatement nodes and collect string literals */
106+
const checkValidateInputFunction = (
107+
fn: TSESTree.ArrowFunctionExpression | TSESTree.FunctionExpression,
108+
context: { report: (opts: { node: TSESTree.Node; messageId: 'noLiteral' }) => void }
109+
): void => {
110+
const body = fn.body;
111+
const returnStmts: TSESTree.ReturnStatement[] = [];
112+
if (body.type === AST_NODE_TYPES.BlockStatement) {
113+
for (const stmt of body.body) {
114+
collectReturnStatements(stmt, returnStmts);
115+
}
116+
} else {
117+
returnStmts.push({ type: AST_NODE_TYPES.ReturnStatement, argument: body } as TSESTree.ReturnStatement);
118+
}
119+
120+
for (const stmt of returnStmts) {
121+
if (!stmt.argument) continue;
122+
const literals: TSESTree.Node[] = [];
123+
collectStringLiteralNodes(stmt.argument as TSESTree.Expression, literals);
124+
for (const node of literals) {
125+
context.report({ node, messageId: 'noLiteral' });
126+
}
127+
}
128+
};
129+
130+
export const noVscodeValidateinputLiterals = RuleCreator.withoutDocs({
131+
meta: {
132+
type: 'problem',
133+
docs: {
134+
description:
135+
'Disallow string literals in showInputBox validateInput - use nls.localize() for error messages'
136+
},
137+
schema: [],
138+
messages: {
139+
noLiteral:
140+
"showInputBox validateInput must use nls.localize('message_key') for error messages, not string literals. Add the message to i18n.ts and use nls.localize() to reference it."
141+
}
142+
},
143+
defaultOptions: [],
144+
create: context => ({
145+
CallExpression: (node: TSESTree.CallExpression): void => {
146+
if (!isShowInputBoxCall(node)) return;
147+
148+
const optionsArg = node.arguments[0];
149+
if (optionsArg?.type !== AST_NODE_TYPES.ObjectExpression) return;
150+
151+
const validateInputProp = findValidateInputProperty(optionsArg);
152+
if (!validateInputProp?.value) return;
153+
154+
const value = validateInputProp.value;
155+
if (value.type === AST_NODE_TYPES.Identifier) return; // External function - skip
156+
157+
if (
158+
value.type === AST_NODE_TYPES.ArrowFunctionExpression ||
159+
value.type === AST_NODE_TYPES.FunctionExpression
160+
) {
161+
checkValidateInputFunction(value, context);
162+
}
163+
}
164+
})
165+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import { RuleTester } from '@typescript-eslint/rule-tester';
9+
import { noVscodeQuickpickDescriptionLiterals } from '../src/noVscodeQuickpickDescriptionLiterals';
10+
11+
const ruleTester = new RuleTester();
12+
13+
ruleTester.run('no-vscode-quickpick-description-literals', noVscodeQuickpickDescriptionLiterals, {
14+
valid: [
15+
{
16+
code: `vscode.window.showQuickPick([
17+
{ label: 'ApexUnitTest', description: nls.localize('apex_unit_test_template_description') },
18+
{ label: 'BasicUnitTest', description: nls.localize('basic_unit_test_template_description') }
19+
], { placeHolder: nls.localize('prompt') });`,
20+
options: []
21+
},
22+
{
23+
code: `vscode.window.showQuickPick([
24+
{ label: 'JavaScript', value: 'default' },
25+
{ label: 'TypeScript', value: 'typeScript' }
26+
]);`,
27+
options: []
28+
},
29+
{
30+
code: `vscode.window.showQuickPick(items, { placeHolder: nls.localize('prompt') });`,
31+
options: []
32+
}
33+
],
34+
invalid: [
35+
{
36+
code: `vscode.window.showQuickPick([
37+
{ label: defaultUri.fsPath, description: '(default)', uri: defaultUri }
38+
]);`,
39+
errors: [{ messageId: 'noLiteral' }]
40+
},
41+
{
42+
code: `vscode.window.showQuickPick([
43+
{ label: 'A', description: 'Hardcoded description' },
44+
{ label: 'B', description: nls.localize('key') }
45+
]);`,
46+
errors: [{ messageId: 'noLiteral' }]
47+
},
48+
{
49+
code: `vscode.window.showQuickPick([
50+
{ label: 'A', description: 'First' },
51+
{ label: 'B', description: 'Second' }
52+
]);`,
53+
errors: [{ messageId: 'noLiteral' }, { messageId: 'noLiteral' }]
54+
}
55+
]
56+
});

0 commit comments

Comments
 (0)