Skip to content
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

harden-exports rule for eslint plugin #2369

Merged
merged 3 commits into from
Jul 18, 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
107 changes: 107 additions & 0 deletions packages/eslint-plugin/lib/rules/harden-exports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @fileoverview Ensure each named export is followed by a call to `harden` function
*/

'use strict';

/**
* @import {Rule} from 'eslint';
* @import * as ESTree from 'estree';
*/

/**
* ESLint rule module for ensuring each named export is followed by a call to `harden` function.
* @type {Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure each named export is followed by a call to `harden` function',
category: 'Possible Errors',
recommended: false,
},
fixable: 'code',
schema: [],
},
/**
* Create function for the rule.
* @param {Rule.RuleContext} context - The rule context.
* @returns {Object} The visitor object.
*/
create(context) {
/** @type {Array<ESTree.ExportNamedDeclaration & Rule.NodeParentExtension>} */
let exportNodes = [];

return {
/** @param {ESTree.ExportNamedDeclaration & Rule.NodeParentExtension} node */
ExportNamedDeclaration(node) {
exportNodes.push(node);
},
'Program:exit'() {
const sourceCode = context.getSourceCode();

for (const exportNode of exportNodes) {
/** @type {string[]} */
let exportNames = [];
if (exportNode.declaration) {
// @ts-expect-error xxx typedef
if (exportNode.declaration.declarations) {
// @ts-expect-error xxx typedef
for (const declaration of exportNode.declaration.declarations) {
if (declaration.id.type === 'ObjectPattern') {
for (const prop of declaration.id.properties) {
exportNames.push(prop.key.name);
}
} else {
exportNames.push(declaration.id.name);
}
}
} else if (exportNode.declaration.type === 'FunctionDeclaration') {
// @ts-expect-error xxx typedef
exportNames.push(exportNode.declaration.id.name);
}
} else if (exportNode.specifiers) {
for (const spec of exportNode.specifiers) {
exportNames.push(spec.exported.name);
}
}

const missingHardenCalls = [];
for (const exportName of exportNames) {
const hasHardenCall = sourceCode.ast.body.some(statement => {
return (
statement.type === 'ExpressionStatement' &&
statement.expression.type === 'CallExpression' &&
// @ts-expect-error xxx typedef
statement.expression.callee.name === 'harden' &&
statement.expression.arguments.length === 1 &&
// @ts-expect-error xxx typedef
statement.expression.arguments[0].name === exportName
);
});

if (!hasHardenCall) {
missingHardenCalls.push(exportName);
}
}

if (missingHardenCalls.length > 0) {
const noun = missingHardenCalls.length === 1 ? 'export' : 'exports';
context.report({
node: exportNode,
message: `The named ${noun} '${missingHardenCalls.join(', ')}' should be followed by a call to 'harden'.`,
fix: function (fixer) {
const hardenCalls = missingHardenCalls
.map(name => `harden(${name});`)
.join('\n');
return fixer.insertTextAfter(exportNode, `\n${hardenCalls}`);
},
});
}
}
},
};
},
};
6 changes: 4 additions & 2 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"author": "Endo contributors",
"main": "./lib/index.js",
"scripts": {
"test": "exit 0",
"test": "mocha",
"test:xs": "exit 0",
"build": "exit 0",
"lint-fix": "exit 0",
Expand All @@ -24,7 +24,9 @@
"typescript-eslint": "^7.3.1"
},
"devDependencies": {
"eslint": "^8.57.0"
"@types/mocha": "^10",
"eslint": "^8.57.0",
"mocha": "^10.6.0"
},
"engines": {
"node": ">=0.10.0"
Expand Down
250 changes: 250 additions & 0 deletions packages/eslint-plugin/test/harden-exports.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
const { RuleTester } = require('eslint');
const rule = require('../lib/rules/harden-exports');

const jsValid = [
{
code: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);
`,
},
{
code: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);
`,
},
{
code: `
export function foo() {
console.log("foo");
}
harden(foo);
export const a = 1;
harden(a);
`,
},
{
code: `
export const a = 1;
harden(a);
export function bar() {
console.log("bar");
}
harden(bar);
`,
},
{
code: `
export const a = 1;
harden(a);
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
harden(getEnvironmentOption);
harden(getEnvironmentOptionsList);
harden(environmentOptionsListHas);
`,
},
];

const invalid = [
{
code: `
export const a = 'alreadyHardened';
export const b = 'toHarden';

harden(a);
`,
errors: [
{
message:
"The named export 'b' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 'alreadyHardened';
export const b = 'toHarden';
Comment on lines +81 to +82
Copy link
Member

Choose a reason for hiding this comment

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

In a handful of cases, it will be somewhat tedious to have to harden primitive literals. Are you up for a little complication?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm all ears. I'd like to land this though because it's useful as is

harden(b);

harden(a);
`,
},
{
code: `
export const a = 1;
`,
errors: [
{
message:
"The named export 'a' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 1;
harden(a);
`,
},
{
code: `
export function foo() {
console.log("foo");
}
`,
errors: [
{
message:
"The named export 'foo' should be followed by a call to 'harden'.",
},
],
output: `
export function foo() {
console.log("foo");
}
harden(foo);
`,
},
{
code: `
export function
multilineFunction() {
console.log("This is a multiline function.");
}
`,
errors: [
{
message:
"The named export 'multilineFunction' should be followed by a call to 'harden'.",
},
],
output: `
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const a = 1;
export const b = 2;

export const alreadyHardened = 3;
harden(alreadyHardened);

export function foo() {
console.log("foo");
}
export function
multilineFunction() {
console.log("This is a multiline function.");
}
`,
errors: [
{
message:
"The named export 'a' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'b' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'foo' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'multilineFunction' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);

export const alreadyHardened = 3;
harden(alreadyHardened);

export function foo() {
console.log("foo");
}
harden(foo);
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
`,
errors: [
{
message:
"The named exports 'getEnvironmentOption, getEnvironmentOptionsList, environmentOptionsListHas' should be followed by a call to 'harden'.",
},
],
output: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
harden(getEnvironmentOption);
harden(getEnvironmentOptionsList);
harden(environmentOptionsListHas);
`,
},
];

const jsTester = new RuleTester({
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
});
jsTester.run('harden JS exports', rule, {
valid: jsValid,
invalid,
});

const tsTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
});
tsTester.run('harden TS exports', rule, {
valid: [
...jsValid,
{
// harden() on only value exports
code: `
export type Foo = string;
export interface Bar {
baz: number;
}
`,
},
],
invalid,
});
Loading
Loading