@discord/eslint-plugin-discord-intl 0.6.1 → 0.6.2

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/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  module.exports = {
2
2
  rules: {
3
- 'trimmed-whitespace': require('./rules/trimmable-whitespace'),
3
+ 'trimmed-whitespace': require('./rules/trimmed-whitespace'),
4
4
  'use-static-access': require('./rules/use-static-access'),
5
+ 'no-opaque-messages-objects': require('./rules/no-opaque-messages-objects'),
5
6
  },
6
7
  configs: {
7
8
  recommended: {
@@ -9,6 +10,7 @@ module.exports = {
9
10
  rules: {
10
11
  '@discord/discord-intl/trimmed-whitespace': 'error',
11
12
  '@discord/discord-intl/use-static-access': 'error',
13
+ '@discord/discord-intl/no-opaque-messages-objects': 'error',
12
14
  },
13
15
  },
14
16
  },
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Returns true if the current execution is using a TypeScript parser, using a best-effort guess
3
+ * based on the given context.
4
+ * @param {import('eslint').Rule.RuleContext} context
5
+ * @returns {boolean}
6
+ */
7
+ function isTypeScript(context) {
8
+ return context.parserPath?.includes('@typescript-eslint') ?? false;
9
+ }
10
+
11
+ module.exports = { isTypeScript };
package/lib/traverse.js CHANGED
@@ -1,18 +1,80 @@
1
1
  const { isMessageDefinitionsFile } = require('@discord/intl-loader-core');
2
2
 
3
+ /** @typedef {import('eslint').Rule.NodeParentExtension} NodeParentExtension */
4
+ /** @typedef {import('eslint').SourceCode} SourceCode */
5
+ /** @typedef {import('eslint').Scope.ScopeManager} ScopeManager */
6
+ /** @typedef {import('eslint').Scope.Reference} Reference */
3
7
  /** @typedef {import('eslint').Rule.RuleListener} RuleListener */
4
8
  /** @typedef {import('eslint').Rule.RuleContext} RuleContext */
9
+ /** @typedef {import('estree').Node} Node */
5
10
  /** @typedef {import('estree').MemberExpression} MemberExpression */
11
+ /** @typedef {import('estree').ImportDeclaration} ImportDeclaration */
12
+ /** @typedef {import('estree').ImportSpecifier} ImportSpecifier */
13
+ /** @typedef {import('estree').ImportDefaultSpecifier} ImportDefaultSpecifier */
14
+ /** @typedef {import('estree').ImportNamespaceSpecifier} ImportNamespaceSpecifier */
6
15
  /** @typedef {import('estree').BaseModuleSpecifier} BaseModuleSpecifier */
7
16
  /** @typedef {import('estree').ObjectExpression} ObjectExpression */
8
17
  /** @typedef {import('estree').SimpleLiteral} SimpleLiteral */
9
18
  /** @typedef {import('estree').TemplateLiteral} TemplateLiteral */
10
19
  /** @typedef {import('estree').Property} Property */
20
+ /** @typedef {import('estree').Identifier} Identifier */
11
21
 
12
22
  /**
13
- * @typedef {(node: MemberExpression, importer: BaseModuleSpecifier) => void} MessageAccessCallback
23
+ * @typedef DiscordIntlPluginConfig
24
+ * @property {Record<string, string[]>} [extraImports]
25
+ */
26
+
27
+ /**
28
+ * @param {DiscordIntlPluginConfig | undefined} config
29
+ * @param {ImportDeclaration} path
30
+ * @returns {Array<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier>}
31
+ */
32
+ function getImportedMessagesObjectSpecifiers(config, path) {
33
+ const importSource = /** @type {string} */ (path.source.value);
34
+ // TODO: Make `isMessageDefinitionsFile` understand this properly.
35
+ const isDefinition = isMessageDefinitionsFile(importSource) || importSource.endsWith('.messages');
36
+ const extraImportSpecifiers = config?.extraImports?.[importSource] ?? [];
37
+ // This transformer only handles usages of intl messages, so only
38
+ // imports of definitions files and configured extra specifiers need to
39
+ // be handled.
40
+ if (!isDefinition && extraImportSpecifiers.length === 0) {
41
+ return [];
42
+ }
43
+
44
+ return path.specifiers.filter(
45
+ (specifier) =>
46
+ (isDefinition && specifier.type === 'ImportDefaultSpecifier') ||
47
+ extraImportSpecifiers.includes(specifier.local.name),
48
+ );
49
+ }
50
+
51
+ /**
52
+ *
53
+ * @param {RuleContext} context
54
+ * @param {Node} node
55
+ * @returns {Reference[]}
56
+ */
57
+ function getBindingReferences(context, node) {
58
+ return (
59
+ context.sourceCode
60
+ .getDeclaredVariables(node)
61
+ // This should only ever yield 1 variable, so flatMap is unnecessary, but it is safer to
62
+ // use in case this changes in the future.
63
+ .flatMap((variable) => /** @type {Reference[]} */ (variable.references))
64
+ );
65
+ }
66
+
67
+ /**
68
+ * @typedef {(
69
+ * node: MemberExpression & NodeParentExtension,
70
+ * importer: BaseModuleSpecifier & NodeParentExtension
71
+ * ) => void} MessageAccessCallback
72
+ * @typedef {(
73
+ * reference: Identifier & NodeParentExtension,
74
+ * importer: BaseModuleSpecifier & NodeParentExtension
75
+ * ) => void} MessagesReferenceCallback
14
76
  * @typedef {(
15
- * property: Property,
77
+ * property: Property & NodeParentExtension,
16
78
  * value: SimpleLiteral | TemplateLiteral,
17
79
  * definition: ObjectExpression | undefined,
18
80
  * ) => void} MessageDefinitionCallback
@@ -30,44 +92,26 @@ const { isMessageDefinitionsFile } = require('@discord/intl-loader-core');
30
92
  */
31
93
  function traverseMessageAccesses(context, callback) {
32
94
  const config = context.settings['@discord/discord-intl'];
33
- const source = context.sourceCode;
34
95
 
35
96
  return /** @type {RuleListener} */ ({
36
97
  ImportDeclaration(path) {
37
- const importSource = /** @type {string} */ (path.source.value);
38
- // TODO: Make `isMessageDefinitionsFile` understand this properly.
39
- const isDefinition =
40
- isMessageDefinitionsFile(importSource) || importSource.endsWith('.messages');
41
- const extraImportSpecifiers = config.extraImports?.[importSource] ?? [];
42
- // This transformer only handles usages of intl messages, so only
43
- // imports of definitions files and configured extra specifiers need to
44
- // be handled.
45
- if (!isDefinition && extraImportSpecifiers.length === 0) {
46
- return;
47
- }
48
-
49
- const specifiers = path.specifiers.filter(
50
- (specifier) =>
51
- (isDefinition && specifier.type === 'ImportDefaultSpecifier') ||
52
- extraImportSpecifiers.includes(specifier.local.name),
53
- );
98
+ const specifiers = getImportedMessagesObjectSpecifiers(config, path);
99
+ if (specifiers.length === 0) return;
54
100
 
55
101
  for (const specifier of specifiers) {
56
- const bindingReferences = source
57
- .getDeclaredVariables(specifier)
58
- // This should only ever yield 1 variable, so flatMap is unnecessary, but it is safer to
59
- // use in case this changes in the future.
60
- .flatMap((variable) => variable.references);
102
+ const bindingReferences = getBindingReferences(context, specifier);
61
103
 
62
104
  for (const reference of bindingReferences) {
63
- // @ts-expect-error `identifier` is actually `Identifier & NodeParentExtension`.
64
- const parent = reference.identifier.parent;
105
+ const parent = /** @type {MemberExpression & NodeParentExtension} */ (
106
+ // @ts-expect-error `identifier` is actually `Identifier & NodeParentExtension`.
107
+ reference.identifier.parent
108
+ );
65
109
 
66
110
  // We only care about member expressions, since a direct reference to the
67
111
  // message source doesn't necessarily make it a message access.
68
112
  if (parent.type !== 'MemberExpression') continue;
69
113
 
70
- callback(/** @type {MemberExpression} */ parent, specifier);
114
+ callback(parent, /** @type {BaseModuleSpecifier & NodeParentExtension} */ (specifier));
71
115
  }
72
116
  }
73
117
  },
@@ -113,13 +157,12 @@ function traverseMessageDefinitions(context, callback) {
113
157
  case 'Literal':
114
158
  case 'TemplateLiteral':
115
159
  callback(
116
- property,
160
+ /** @type {Property & NodeParentExtension} */ (property),
117
161
  /** @type {SimpleLiteral | TemplateLiteral} */ (property.value),
118
162
  undefined,
119
163
  );
120
164
  break;
121
165
  case 'ObjectExpression': {
122
- console.log('got expression');
123
166
  const messageProperty = property.value.properties.find((prop) => {
124
167
  return prop.type === 'Property' && 'name' in prop.key && prop.key.name === 'message';
125
168
  });
@@ -132,7 +175,7 @@ function traverseMessageDefinitions(context, callback) {
132
175
  }
133
176
 
134
177
  callback(
135
- property,
178
+ /** @type {Property & NodeParentExtension} */ (property),
136
179
  /** @type {SimpleLiteral} */ (messageProperty.value),
137
180
  property.value,
138
181
  );
@@ -143,4 +186,37 @@ function traverseMessageDefinitions(context, callback) {
143
186
  });
144
187
  }
145
188
 
146
- module.exports = { traverseMessageAccesses, traverseMessageDefinitions };
189
+ /**
190
+ * Visit all `Identifier`s that act as accesses to a complete intl messages object. This includes
191
+ * all references, including message accesses, object passing, and includes the original import
192
+ * for completeness.
193
+ *
194
+ * @param {RuleContext} context The rule context from ESLint
195
+ * @param {MessagesReferenceCallback} callback Function to call for each instance of a message access
196
+ * @returns {RuleListener} A visitor object for the Babel transform.
197
+ */
198
+ function traverseMessageObjectReferences(context, callback) {
199
+ const config = context.settings['@discord/discord-intl'];
200
+
201
+ return /** @type {RuleListener} */ ({
202
+ ImportDeclaration(path) {
203
+ const specifiers = getImportedMessagesObjectSpecifiers(config, path);
204
+ for (const specifier of specifiers) {
205
+ const bindingReferences = getBindingReferences(context, specifier);
206
+
207
+ for (const reference of bindingReferences) {
208
+ callback(
209
+ /** @type {Identifier & NodeParentExtension} */ (reference.identifier),
210
+ /** @type {BaseModuleSpecifier & NodeParentExtension} */ (specifier),
211
+ );
212
+ }
213
+ }
214
+ },
215
+ });
216
+ }
217
+
218
+ module.exports = {
219
+ traverseMessageAccesses,
220
+ traverseMessageDefinitions,
221
+ traverseMessageObjectReferences,
222
+ };
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@discord/eslint-plugin-discord-intl",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "description": "ESLint plugin for validating and linting messages and translations using @discord/intl",
7
7
  "main": "index.js",
8
8
  "dependencies": {
9
- "@discord/intl-loader-core": "0.6.1"
9
+ "@typescript-eslint/scope-manager": "^8.8.1",
10
+ "@discord/intl-loader-core": "0.6.2"
10
11
  },
11
12
  "devDependencies": {
12
13
  "@types/eslint": "^8.56.12",
13
14
  "@types/estree": "^1.0.6",
15
+ "@typescript-eslint/parser": "^8.8.1",
14
16
  "eslint": "8.57.1"
15
17
  }
16
18
  }
@@ -0,0 +1,48 @@
1
+ const { traverseMessageObjectReferences } = require('../lib/traverse');
2
+ const { isTypeScript } = require('../lib/is-typescript');
3
+
4
+ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ({
5
+ meta: {
6
+ docs: {
7
+ description:
8
+ 'Disallow using whole messages objects as singular values, through passing as arguments to functions, taking the type of the object, and more.',
9
+ category: 'Best Practices',
10
+ },
11
+ messages: {
12
+ noObjectArgument:
13
+ 'Avoid passing message objects around as parameters. Use messages individually',
14
+ noTypeof:
15
+ 'Avoid requesting the type of an entire messages object. Use messages individually.',
16
+ },
17
+ },
18
+ create(context) {
19
+ return traverseMessageObjectReferences(context, (reference) => {
20
+ const parent = reference.parent;
21
+ if (parent.type === 'CallExpression') {
22
+ context.report({
23
+ node: reference,
24
+ messageId: 'noObjectArgument',
25
+ });
26
+ return;
27
+ }
28
+
29
+ if (parent.type === 'UnaryExpression' && parent.operator === 'typeof') {
30
+ context.report({
31
+ node: parent,
32
+ messageId: 'noTypeof',
33
+ });
34
+ return;
35
+ }
36
+
37
+ if (isTypeScript(context)) {
38
+ // @ts-expect-error TSNodes
39
+ if (parent.type === 'TSTypeQuery') {
40
+ context.report({
41
+ node: parent,
42
+ messageId: 'noTypeof',
43
+ });
44
+ }
45
+ }
46
+ });
47
+ },
48
+ });
@@ -0,0 +1,92 @@
1
+ // enforce-foo-bar.test.js
2
+ const { RuleTester } = require('eslint');
3
+ const noOpaqueMessagesObjects = require('./no-opaque-messages-objects');
4
+
5
+ const typescriptParser = require.resolve('@typescript-eslint/parser');
6
+ const ruleTester = new RuleTester({
7
+ // Must use at least ecmaVersion 2015 because
8
+ // that's when `const` variables were introduced.
9
+ parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
10
+ });
11
+
12
+ ruleTester.run('no-opaque-messages-objects', noOpaqueMessagesObjects, {
13
+ valid: [
14
+ {
15
+ name: 'normal message formatting',
16
+ code: `
17
+ import messages from 'Feature.messages';
18
+ intl.format(messages.SOME_MESSAGE);
19
+ otherFunction(messages.OTHER_MESSAGE);
20
+ `,
21
+ },
22
+ {
23
+ name: 'intermediate object passing',
24
+ code: `
25
+ import messages from 'Feature.messages';
26
+ otherFunction({
27
+ FIRST: messages.FIRST,
28
+ OTHER_MESSAGE: messages.OTHER_MESSAGE,
29
+ });
30
+ `,
31
+ },
32
+ {
33
+ name: 'typeof individual message',
34
+ parser: typescriptParser,
35
+ code: `
36
+ import messages from 'Feature.messages';
37
+ typeof messages.FOO;
38
+ function foo(message: typeof messages.FOO) {}
39
+ `,
40
+ },
41
+ ],
42
+ invalid: [
43
+ {
44
+ name: 'passing whole object',
45
+ code: `
46
+ import messages from 'Feature.messages';
47
+ otherFunction(messages);
48
+ `,
49
+ errors: 1,
50
+ },
51
+ {
52
+ name: 'passing whole object',
53
+ settings: {
54
+ '@discord/discord-intl': {
55
+ extraImports: { '@app/intl': ['t'] },
56
+ },
57
+ },
58
+ code: `
59
+ import {t} from '@app/intl';
60
+ otherFunction(t);
61
+ `,
62
+ errors: 1,
63
+ },
64
+ {
65
+ name: 'passing whole object',
66
+ code: `
67
+ import messages from 'Feature.messages';
68
+ otherFunction(messages);
69
+ `,
70
+ errors: 1,
71
+ },
72
+ {
73
+ name: 'typeof messages value',
74
+ code: `
75
+ import messages from 'Feature.messages';
76
+ typeof messages;
77
+ `,
78
+ errors: 1,
79
+ },
80
+ {
81
+ name: 'typeof messages as parameter',
82
+ parser: typescriptParser,
83
+ code: `
84
+ import messages from 'Feature.messages';
85
+ function foo(strings: typeof messages) {}
86
+ `,
87
+ errors: 1,
88
+ },
89
+ ],
90
+ });
91
+
92
+ console.log('All tests passed!');
@@ -1,4 +1,3 @@
1
- const util = require('node:util');
2
1
  const { traverseMessageDefinitions } = require('../lib/traverse');
3
2
 
4
3
  /**
@@ -35,8 +34,6 @@ module.exports = /** @type {import('eslint').Rule.RuleModule} */ ({
35
34
  const rawNoQuotes = sourceText.slice(1, -1);
36
35
 
37
36
  const trimmed = trimLeadingWhitespace(trimTrailingWhitespace(rawNoQuotes));
38
- util.inspect(rawNoQuotes);
39
- util.inspect(trimmed);
40
37
  if (trimmed === rawNoQuotes) return;
41
38
 
42
39
  context.report({
@@ -1,6 +1,6 @@
1
1
  // enforce-foo-bar.test.js
2
2
  const { RuleTester } = require('eslint');
3
- const trimmableWhitespace = require('./trimmable-whitespace');
3
+ const trimmedWhitespace = require('./trimmed-whitespace');
4
4
 
5
5
  const ruleTester = new RuleTester({
6
6
  // Must use at least ecmaVersion 2015 because
@@ -19,22 +19,22 @@ function defineMessages(messages) {
19
19
  `;
20
20
  }
21
21
 
22
- ruleTester.run('trimmable-whitespace', trimmableWhitespace, {
22
+ ruleTester.run('trimmed-whitespace', trimmedWhitespace, {
23
23
  valid: [
24
24
  {
25
25
  name: 'normal strings',
26
- code: defineMessages("{ A: 'no trimmable whitespace' }"),
26
+ code: defineMessages("{ A: 'no trimmed whitespace' }"),
27
27
  },
28
28
  {
29
29
  name: 'templates',
30
30
  code: defineMessages(
31
- '{ A: `no trimmable whitespace`, QUASI: `${ space }`, MULTILINE: `hi\n yes` }',
31
+ '{ A: `no trimmed whitespace`, QUASI: `${ space }`, MULTILINE: `hi\n yes` }',
32
32
  ),
33
33
  },
34
34
  {
35
35
  name: 'multi-line',
36
36
  code: defineMessages(
37
- `{ A: \`no trimmable
37
+ `{ A: \`no trimmed
38
38
  whitespace\`}`,
39
39
  ),
40
40
  },
@@ -107,14 +107,14 @@ ruleTester.run('trimmable-whitespace', trimmableWhitespace, {
107
107
  },
108
108
  {
109
109
  name: 'template quasis',
110
- code: defineMessages('{ A: `no trimmable whitespace`, QUASI: ` ${ space } ` }'),
111
- output: defineMessages('{ A: `no trimmable whitespace`, QUASI: `${ space }` }'),
110
+ code: defineMessages('{ A: `no trimmed whitespace`, QUASI: ` ${ space } ` }'),
111
+ output: defineMessages('{ A: `no trimmed whitespace`, QUASI: `${ space }` }'),
112
112
  errors: 1,
113
113
  },
114
114
  {
115
115
  name: 'multiline templates',
116
- code: defineMessages('{ A: `no trimmable whitespace`, QUASI: `\n\t${ space }\n ` }'),
117
- output: defineMessages('{ A: `no trimmable whitespace`, QUASI: `${ space }` }'),
116
+ code: defineMessages('{ A: `no trimmed whitespace`, QUASI: `\n\t${ space }\n ` }'),
117
+ output: defineMessages('{ A: `no trimmed whitespace`, QUASI: `${ space }` }'),
118
118
  errors: 1,
119
119
  },
120
120
  {