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

Support custom dependency nodes, such as exports #317

Merged
merged 8 commits into from
Dec 1, 2023
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [unreleased]
### Added

- feat(#213): Add `dependency-nodes` setting to allow analyzing dependencies from additional nodes, such as exports or dynamic imports.

### Changed
### Fixed
### Removed

### BREAKING CHANGES

- Fixed the error position in multiline imports. See ["how to migrate from v3 to v4" guide](./docs/guides/how-to-migrate-from-v3-to-v4.md).

## [3.4.1] - 2023-11-01

### Changed
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Activate the plugin and one of the canned configs in your `.eslintrc.(yml|json|j
}
```

## Migrating from v3.x

New v4.0.0 release has introduced breaking changes. If you were using v3.x, you should [read the "how to migrate from v3 to v4" guide](./docs/guides/how-to-migrate-from-v3-to-v4.md).

## Migrating from v1.x

New v2.0.0 release has introduced many breaking changes. If you were using v1.x, you should [read the "how to migrate from v1 to v2" guide](./docs/guides/how-to-migrate-from-v1-to-v2.md).
Expand Down Expand Up @@ -257,7 +261,51 @@ You can also provide an absolute path in the environment variable, but it may be

</details>

### __`boundaries/dependency-nodes`__

This setting allows to modify built-in default dependency nodes. By default, the plugin will analyze only the `import` statements. All the rules defined for the plugin will be applicable to the nodes defined in this setting.

The setting should be an array of the following strings:

* `'import'`: analyze `import` statements.
* `'export'`: analyze `export` statements.
* `'dynamic-import'`: analyze [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) statements.

If you want to define custom dependency nodes, such as `jest.mock(...)`, use [additional-dependency-nodes](#boundariesadditional-dependency-nodes) setting.

For example, if you want to analyze only the `import` and `dynamic-import` statements you should use the following value:

```jsonc
"boundaries/dependency-nodes": ["import", "dynamic-import"],
```

### __`boundaries/additional-dependency-nodes`__

This setting allows to define custom dependency nodes to analyze. All the rules defined for the plugin will be applicable to the nodes defined in this setting.

The setting should be an array of objects with the following structure:

* __`selector`__: The [esquery selector](https://github.com/estools/esquery) for the `Literal` node in which dependency source are defined. For example, to analyze `jest.mock(...)` calls you could use this selector: `CallExpression[callee.object.name=jest][callee.property.name=mock] > Literal:first-child`.
* __`kind`__: The kind of dependency, possible values are: `"value"` or `"type"`. It is available only when using TypeScript.

Example of usage:

```jsonc
{
"boundaries/additional-dependency-nodes": [
// jest.requireActual('source')
{
"selector": "CallExpression[callee.object.name=jest][callee.property.name=requireActual] > Literal",
"kind": "value",
},
// jest.mock('source', ...)
{
"selector": "CallExpression[callee.object.name=jest][callee.property.name=mock] > Literal:first-child",
"kind": "value",
},
],
}
```

### Predefined configurations

Expand Down
51 changes: 51 additions & 0 deletions docs/guides/how-to-migrate-from-v3-to-v4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# How to migrate from v3.x to v4.x

## Table of Contents

- [Breaking changes](#breaking-changes)
- [How to migrate](#how-to-migrate)

## Breaking changes

There is only one breaking change in the v4.0.0 release. We've fixed the bug that caused ESLint to incorrectly mark the error position for multiline imports.

Previous behavior:

```js
import {
// ----^ (start of the error)
ComponentA
} from './components/component-a';
// -----------------------------^ (end of the error)
```

Fixed behavior:

```js
import {
ComponentA
} from './components/component-a';
// ----^ (start) ---------------^ (end)
```

## How to migrate

You need to adjust your `eslint-disable-next-line` directives to match the new position.

For example, this directive:

```js
// eslint-disable-next-line
import {
ComponentA
} from './components/component-a';
```

Should be moved here:

```js
import {
ComponentA
// eslint-disable-next-line
} from './components/component-a';
```
26 changes: 26 additions & 0 deletions src/constants/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ module.exports = {
IGNORE: `${PLUGIN_NAME}/ignore`,
INCLUDE: `${PLUGIN_NAME}/include`,
ROOT_PATH: `${PLUGIN_NAME}/root-path`,
DEPENDENCY_NODES: `${PLUGIN_NAME}/dependency-nodes`,
ADDITIONAL_DEPENDENCY_NODES: `${PLUGIN_NAME}/additional-dependency-nodes`,

// env vars
DEBUG: `${PLUGIN_ENV_VARS_PREFIX}_DEBUG`,
Expand All @@ -36,4 +38,28 @@ module.exports = {

// elements settings properties,
VALID_MODES: ["folder", "file", "full"],

VALID_DEPENDENCY_NODE_KINDS: ["value", "type"],
DEFAULT_DEPENDENCY_NODES: {
import: [
// import x from 'source'
{ selector: "ImportDeclaration:not([importKind=type]) > Literal", kind: "value" },
// import type x from 'source'
{ selector: "ImportDeclaration[importKind=type] > Literal", kind: "type" },
],
javierbrea marked this conversation as resolved.
Show resolved Hide resolved
"dynamic-import": [
// import('source')
{ selector: "ImportExpression > Literal", kind: "value" },
],
export: [
// export * from 'source';
{ selector: "ExportAllDeclaration:not([exportKind=type]) > Literal", kind: "value" },
// export type * from 'source';
{ selector: "ExportAllDeclaration[exportKind=type] > Literal", kind: "type" },
// export { x } from 'source';
{ selector: "ExportNamedDeclaration:not([exportKind=type]) > Literal", kind: "value" },
// export type { x } from 'source';
{ selector: "ExportNamedDeclaration[exportKind=type] > Literal", kind: "type" },
],
},
};
18 changes: 0 additions & 18 deletions src/helpers/rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,6 @@ function meta({ description, schema = [], ruleName }) {
};
}

function dependencyLocation(node, context) {
const columnStart = context.getSourceCode().getText(node).indexOf(node.source.value) - 1;
const columnEnd = columnStart + node.source.value.length + 2;
return {
loc: {
start: {
line: node.loc.start.line,
column: columnStart,
},
end: {
line: node.loc.end.line,
column: columnEnd,
},
},
};
}

function micromatchPatternReplacingObjectsValues(pattern, object) {
let patternToReplace = pattern;
// Backward compatibility
Expand Down Expand Up @@ -237,7 +220,6 @@ function elementRulesAllowDependency({

module.exports = {
meta,
dependencyLocation,
isObjectMatch,
isMatchElementKey,
isMatchElementType,
Expand Down
10 changes: 10 additions & 0 deletions src/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ function isArray(object) {
return Array.isArray(object);
}

function isObject(object) {
return typeof object === "object" && object !== null && !isArray(object);
}

function getArrayOrNull(value) {
return isArray(value) ? value : null;
}

function replaceObjectValueInTemplate(template, key, value, namespace) {
const keyToReplace = namespace ? `${namespace}.${key}` : key;
const regexp = new RegExp(`\\$\\{${keyToReplace}\\}`, "g");
Expand All @@ -27,5 +35,7 @@ function replaceObjectValuesInTemplates(strings, object, namespace) {
module.exports = {
isString,
isArray,
isObject,
getArrayOrNull,
replaceObjectValuesInTemplates,
};
67 changes: 65 additions & 2 deletions src/helpers/validations.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
const micromatch = require("micromatch");

const { TYPES, ALIAS, ELEMENTS, VALID_MODES } = require("../constants/settings");
const {
TYPES,
ALIAS,
ELEMENTS,
VALID_MODES,
DEPENDENCY_NODES,
ADDITIONAL_DEPENDENCY_NODES,
VALID_DEPENDENCY_NODE_KINDS,
DEFAULT_DEPENDENCY_NODES,
} = require("../constants/settings");

const { getElementsTypeNames, isLegacyType } = require("./settings");
const { rulesMainKey } = require("./rules");
const { warnOnce } = require("./debug");
const { isArray, isString } = require("./utils");
const { isArray, isString, isObject } = require("./utils");

const invalidMatchers = [];

Expand Down Expand Up @@ -147,6 +156,58 @@ function validateElements(elements) {
});
}

function validateDependencyNodes(dependencyNodes) {
if (!dependencyNodes) {
return;
}

const defaultNodesNames = Object.keys(DEFAULT_DEPENDENCY_NODES);
const invalidFormatMessage = [
`Please provide a valid value in ${DEPENDENCY_NODES} setting.`,
`The value should be an array of the following strings:`,
` "${defaultNodesNames.join('", "')}".`,
].join(" ");

if (!isArray(dependencyNodes)) {
warnOnce(invalidFormatMessage);
return;
}

dependencyNodes.forEach((dependencyNode) => {
if (!isString(dependencyNode) || !defaultNodesNames.includes(dependencyNode)) {
warnOnce(invalidFormatMessage);
}
});
}

function validateAdditionalDependencyNodes(additionalDependencyNodes) {
if (!additionalDependencyNodes) {
return;
}

const invalidFormatMessage = [
`Please provide a valid value in ${ADDITIONAL_DEPENDENCY_NODES} setting.`,
"The value should be an array composed of the following objects:",
'{ selector: "<esquery selector>", kind: "value" | "type" }.',
].join(" ");

if (!isArray(additionalDependencyNodes)) {
warnOnce(invalidFormatMessage);
return;
}

additionalDependencyNodes.forEach((dependencyNode) => {
const isValidObject =
isObject(dependencyNode) &&
isString(dependencyNode.selector) &&
(!dependencyNode.kind || VALID_DEPENDENCY_NODE_KINDS.includes(dependencyNode.kind));

if (!isValidObject) {
warnOnce(invalidFormatMessage);
}
});
}

function deprecateAlias(aliases) {
if (aliases) {
warnOnce(
Expand All @@ -165,6 +226,8 @@ function validateSettings(settings) {
deprecateTypes(settings[TYPES]);
deprecateAlias(settings[ALIAS]);
validateElements(settings[ELEMENTS] || settings[TYPES]);
validateDependencyNodes(settings[DEPENDENCY_NODES]);
validateAdditionalDependencyNodes(settings[ADDITIONAL_DEPENDENCY_NODES]);
}

function validateRules(settings, rules = [], options = {}) {
Expand Down
31 changes: 26 additions & 5 deletions src/rules-factories/dependency-rule.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
const {
DEPENDENCY_NODES,
DEFAULT_DEPENDENCY_NODES,
ADDITIONAL_DEPENDENCY_NODES,
} = require("../constants/settings");
const { getArrayOrNull } = require("../helpers/utils");
const { fileInfo } = require("../core/elementsInfo");
const { dependencyInfo } = require("../core/dependencyInfo");

Expand All @@ -19,13 +25,28 @@ module.exports = function (ruleMeta, rule, ruleOptions = {}) {
validateRules(context.settings, options.rules, ruleOptions.validateRules);
}

return {
ImportDeclaration: (node) => {
const dependency = dependencyInfo(node.source.value, node.importKind, context);
const dependencyNodesSetting = getArrayOrNull(context.settings[DEPENDENCY_NODES]);
const additionalDependencyNodesSetting = getArrayOrNull(
context.settings[ADDITIONAL_DEPENDENCY_NODES],
);
const dependencyNodes = (dependencyNodesSetting || ["import"])
.map((dependencyNode) => DEFAULT_DEPENDENCY_NODES[dependencyNode])
.flat()
.filter(Boolean);
const additionalDependencyNodes = additionalDependencyNodesSetting || [];

rule({ file, dependency, options, node, context });
return [...dependencyNodes, ...additionalDependencyNodes].reduce(
(visitors, { selector, kind }) => {
visitors[selector] = (node) => {
const dependency = dependencyInfo(node.value, kind, context);

rule({ file, dependency, options, node, context });
};

return visitors;
},
};
{},
);
},
};
};
7 changes: 1 addition & 6 deletions src/rules/element-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ const { RULE_ELEMENT_TYPES } = require("../constants/settings");
const dependencyRule = require("../rules-factories/dependency-rule");

const { rulesOptionsSchema } = require("../helpers/validations");
const {
dependencyLocation,
isMatchElementType,
elementRulesAllowDependency,
} = require("../helpers/rules");
const { isMatchElementType, elementRulesAllowDependency } = require("../helpers/rules");
const {
customErrorMessage,
ruleElementMessage,
Expand Down Expand Up @@ -59,7 +55,6 @@ module.exports = dependencyRule(
context.report({
message: errorMessage(ruleData, file, dependency),
node: node,
...dependencyLocation(node, context),
});
}
}
Expand Down
2 changes: 0 additions & 2 deletions src/rules/entry-point.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const dependencyRule = require("../rules-factories/dependency-rule");

const { rulesOptionsSchema } = require("../helpers/validations");
const {
dependencyLocation,
isMatchElementKey,
elementRulesAllowDependency,
isMatchImportKind,
Expand Down Expand Up @@ -73,7 +72,6 @@ module.exports = dependencyRule(
context.report({
message: errorMessage(ruleData, file, dependency),
node: node,
...dependencyLocation(node, context),
});
}
}
Expand Down
Loading