Skip to content

feat: support named exports in ESM/TS #449

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 73 additions & 46 deletions lib/utils.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you speak to the possibility of this causing additional false positives as I mentioned in #375 (comment)? Do you anticipate any? It may be worth testing on a few top ESLint plugins for a change like this. Is there anything else we should do to mitigate this from detecting named exports that aren't ESLint rules?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the eslint-remote-tester's result, no false positives were found. We can start with this implementation and improve it if we receive feedback. thoughts?

https://github.com/eslint-community/eslint-plugin-eslint-plugin/actions/runs/7980943358/job/21791683343?pr=449

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very primitive idea is allowing configuring the esquery selectors for the possible rules:

{
  settings: {
    // to allow "export const rule = ..."
    "eslint-plugin": {"rule-selectors": ["ExportNamedDeclaration[declaration.type="VariableDeclaration"][declaration.declarations.0.id.name="rule"]"]}
  }
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aladdin-add is eslint-remote-tester actually showing us new lint violations (which could be false positives), or just crashes? It says Results: No errors but I'd be kind of surprised if there really are no lint violations in any of those repositories. We may also want to add more ESM plugins to the list it tests against.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@43081j can you manually test this on a few larger/top ESM plugins? I don't think eslint-remote-tester, which runs during CI, detects false positives for us, only crashes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll have a look into it 👍

Original file line number Diff line number Diff line change
Expand Up @@ -118,55 +118,82 @@ function isTypeScriptRuleHelper(node) {
* Helper for `getRuleInfo`. Handles ESM and TypeScript rules.
*/
function getRuleExportsESM(ast, scopeManager) {
return ast.body
.filter((statement) =>
[
'ExportDefaultDeclaration', // export default rule;
'TSExportAssignment', // export = rule;
].includes(statement.type)
)
.map((statement) => statement.declaration || statement.expression)

.reduce((currentExports, node) => {
if (node.type === 'ObjectExpression') {
// Check `export default { create() {}, meta: {} }`
return collectInterestingProperties(
node.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(node)) {
// Check `export default function(context) { return { ... }; }`
return { create: node, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(node)) {
// Check `export default someTypeScriptHelper({ create() {}, meta: {} });
return collectInterestingProperties(
node.arguments[0].properties,
INTERESTING_RULE_KEYS
);
} else if (node.type === 'Identifier') {
// Rule could be stored in a variable before being exported.
const possibleRule = findVariableValue(node, scopeManager);
if (possibleRule) {
if (possibleRule.type === 'ObjectExpression') {
// Check `const possibleRule = { ... }; export default possibleRule;
return collectInterestingProperties(
possibleRule.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(possibleRule)) {
// Check `const possibleRule = function(context) { return { ... } }; export default possibleRule;`
return { create: possibleRule, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(possibleRule)) {
// Check `const possibleRule = someTypeScriptHelper({ ... }); export default possibleRule;
return collectInterestingProperties(
possibleRule.arguments[0].properties,
INTERESTING_RULE_KEYS
);
const possibleNodes = [];

for (const statement of ast.body) {
switch (statement.type) {
// export default rule;
case 'ExportDefaultDeclaration': {
possibleNodes.push(statement.declaration);
break;
}
// export = rule;
case 'TSExportAssignment': {
possibleNodes.push(statement.expression);
break;
}
// export const rule = { ... };
// or export {rule};
case 'ExportNamedDeclaration': {
for (const specifier of statement.specifiers) {
possibleNodes.push(specifier.local);
}
if (statement.declaration) {
if (statement.declaration.type === 'VariableDeclaration') {
for (const declarator of statement.declaration.declarations) {
if (declarator.init) {
possibleNodes.push(declarator.init);
}
}
} else {
possibleNodes.push(statement.declaration);
}
}
break;
}
return currentExports;
}, {});
}
}

return possibleNodes.reduce((currentExports, node) => {
if (node.type === 'ObjectExpression') {
// Check `export default { create() {}, meta: {} }`
return collectInterestingProperties(
node.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(node)) {
// Check `export default function(context) { return { ... }; }`
return { create: node, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(node)) {
// Check `export default someTypeScriptHelper({ create() {}, meta: {} });
return collectInterestingProperties(
node.arguments[0].properties,
INTERESTING_RULE_KEYS
);
} else if (node.type === 'Identifier') {
// Rule could be stored in a variable before being exported.
const possibleRule = findVariableValue(node, scopeManager);
if (possibleRule) {
if (possibleRule.type === 'ObjectExpression') {
// Check `const possibleRule = { ... }; export default possibleRule;
return collectInterestingProperties(
possibleRule.properties,
INTERESTING_RULE_KEYS
);
} else if (isFunctionRule(possibleRule)) {
// Check `const possibleRule = function(context) { return { ... } }; export default possibleRule;`
return { create: possibleRule, meta: null, isNewStyle: false };
} else if (isTypeScriptRuleHelper(possibleRule)) {
// Check `const possibleRule = someTypeScriptHelper({ ... }); export default possibleRule;
return collectInterestingProperties(
possibleRule.arguments[0].properties,
INTERESTING_RULE_KEYS
);
}
}
}
return currentExports;
}, {});
}

/**
Expand Down
53 changes: 49 additions & 4 deletions tests/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ describe('utils', () => {
'module.exports = createESLintRule({ create() {}, meta: {} });',
'module.exports = util.createRule({ create() {}, meta: {} });',
'module.exports = ESLintUtils.RuleCreator(docsUrl)({ create() {}, meta: {} });',

// Named export of a rule, only supported in ESM within this plugin
'module.exports.rule = { create: function() {} };',
'exports.rule = { create: function() {} };',
'const rule = { create: function() {} }; module.exports.rule = rule;',
'const rule = { create: function() {} }; exports.rule = rule;',
].forEach((noRuleCase) => {
it(`returns null for ${noRuleCase}`, () => {
const ast = espree.parse(noRuleCase, { ecmaVersion: 8, range: true });
Expand All @@ -65,15 +71,11 @@ describe('utils', () => {
describe('the file does not have a valid rule (ESM)', () => {
[
'',
'export const foo = { create() {} }',
'export default { foo: {} }',
'const foo = {}; export default foo',
'const foo = 123; export default foo',
'const foo = function(){}; export default foo',

// Exports function but not default export.
'export function foo (context) { return {}; }',

// Exports function but no object return inside function.
'export default function (context) { }',
'export default function (context) { return; }',
Expand Down Expand Up @@ -209,13 +211,46 @@ describe('utils', () => {
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// No helper, exported variable.
'export const rule = { create() {}, meta: {} };': {
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// no helper, variable with type.
'const rule: Rule.RuleModule = { create() {}, meta: {} }; export default rule;':
{
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// no helper, exported variable with type.
'export const rule: Rule.RuleModule = { create() {}, meta: {} };': {
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// no helper, exported reference with type.
'const rule: Rule.RuleModule = { create() {}, meta: {} }; export {rule};':
{
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// no helper, exported aliased reference with type.
'const foo: Rule.RuleModule = { create() {}, meta: {} }; export {foo as rule};':
{
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// no helper, exported variable with type in multiple declarations
'export const foo = 5, rule: Rule.RuleModule = { create() {}, meta: {} };':
{
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
// No helper, variable, `export =` syntax.
'const rule = { create() {}, meta: {} }; export = rule;': {
create: { type: 'FunctionExpression' },
Expand Down Expand Up @@ -474,6 +509,16 @@ describe('utils', () => {
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
'export const rule = { create() {}, meta: {} };': {
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},
'const rule = { create() {}, meta: {} }; export {rule};': {
create: { type: 'FunctionExpression' },
meta: { type: 'ObjectExpression' },
isNewStyle: true,
},

// ESM (function style)
'export default function (context) { return {}; }': {
Expand Down