Skip to content

Commit

Permalink
Add absolute-version rule (#34)
Browse files Browse the repository at this point in the history
  • Loading branch information
ota-meshi authored Mar 16, 2022
1 parent 0889dfe commit 1d6f38b
Show file tree
Hide file tree
Showing 10 changed files with 851 additions and 12 deletions.
12 changes: 10 additions & 2 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,19 @@ module.exports = {
},
{
selector: "property",
format: ["camelCase", "UPPER_CASE", "PascalCase"],
format: null, // ["camelCase", "UPPER_CASE", "PascalCase"],
custom: {
regex: "[\\w _,]",
match: true,
},
},
{
selector: "method",
format: ["camelCase", "UPPER_CASE", "PascalCase"],
format: null, // ["camelCase", "UPPER_CASE", "PascalCase"],
custom: {
regex: "[\\w _,]",
match: true,
},
},
],
},
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ The rules with the following star :star: are included in the `plugin:node-depend

| Rule ID | Description | |
|:--------|:------------|:---|
| [node-dependencies/absolute-version](https://ota-meshi.github.io/eslint-plugin-node-dependencies/rules/absolute-version.html) | require or disallow absolute version of dependency. | |
| [node-dependencies/no-deprecated](https://ota-meshi.github.io/eslint-plugin-node-dependencies/rules/no-deprecated.html) | disallow having dependencies on deprecate packages. | |

### Deprecated
Expand Down
1 change: 1 addition & 0 deletions docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The rules with the following star :star: are included in the `plugin:node-depend

| Rule ID | Description | |
|:--------|:------------|:---|
| [node-dependencies/absolute-version](./absolute-version.md) | require or disallow absolute version of dependency. | |
| [node-dependencies/no-deprecated](./no-deprecated.md) | disallow having dependencies on deprecate packages. | |

### Deprecated
Expand Down
88 changes: 88 additions & 0 deletions docs/rules/absolute-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
pageClass: "rule-details"
sidebarDepth: 0
title: "node-dependencies/absolute-version"
description: "require or disallow absolute version of dependency."
---
# node-dependencies/absolute-version

> require or disallow absolute version of dependency.
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>

## :book: Rule Details

This rule enforces the use of absolute version of dependency.

```json5
{
"devDependencies": {
"semver": "^7.3.5", /* ✗ BAD */
"typescript": "4.6.2" /* ✓ GOOD */
}
}
```

## :wrench: Options

### String Option

```json5
{
"node-dependencies/absolute-version": ["error",
"always" // or "never"
]
}
```

- `"always"` ... Enforces to use the absolute version.
- `"never"` ... Enforces not to use the absolute version.

### Object Option

```json5
{
"node-dependencies/absolute-version": ["error", {
"dependencies": "ignore", // , "always" or "never"
"peerDependencies": "ignore", // , "always" or "never"
"optionalDependencies": "ignore", // , "always" or "never"
"devDependencies": "always" // , "never" or "ignore"
}]
}
```

- `dependencies` ... Configuration for `dependencies`.
- `peerDependencies` ... Configuration for `dependencies`.
- `optionalDependencies` ... Configuration for `dependencies`.
- `devDependencies` ... Configuration for `dependencies`.
- Value
- `"always"` ... Enforces to use the absolute version.
- `"never"` ... Enforces not to use the absolute version.
- `"ignore"` ... Ignored from the check.

By default, `always` applies only to `devDependencies`.

### Override Option for Each Package

```json5
{
"node-dependencies/absolute-version": ["error", {
// "dependencies": "ignore",
// "peerDependencies": "ignore",
// "optionalDependencies": "ignore",
// "devDependencies": "always",
"overridePackages": {
"foo": "always" // Always use an absolute version for the "foo" package.
}
}]
}
```

- `overridePackages` ... Configure an object with the package name as the key.
- Property Key ... Specify the package name, or the pattern such as `/^@babel\//`.
- Property Value ... Can use the Object Option or the String Option.

## :mag: Implementation

- [Rule source](https://github.com/ota-meshi/eslint-plugin-node-dependencies/blob/main/lib/rules/absolute-version.ts)
- [Test source](https://github.com/ota-meshi/eslint-plugin-node-dependencies/blob/main/tests/lib/rules/absolute-version.ts)
211 changes: 211 additions & 0 deletions lib/rules/absolute-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { getStaticJSONValue } from "jsonc-eslint-parser"
import type { JSONProperty } from "jsonc-eslint-parser/lib/parser/ast"
import type { Range } from "semver"
import { createRule, defineJsonVisitor } from "../utils"
import { getKeyFromJSONProperty } from "../utils/ast-utils"
import { toRegExp } from "../utils/regexp"
import { getSemverRange, isAnyComparator } from "../utils/semver"

const PREFERS = ["always" as const, "never" as const, "ignore" as const]
const SCHEMA_FOR_DEPS_PROPERTIES = {
dependencies: { enum: PREFERS },
peerDependencies: { enum: PREFERS },
optionalDependencies: { enum: PREFERS },
devDependencies: { enum: PREFERS },
}
type Prefer = typeof PREFERS[number]
type FullOption = {
dependencies: Prefer
peerDependencies: Prefer
optionalDependencies: Prefer
devDependencies: Prefer
}

const DEFAULT: FullOption = {
dependencies: "ignore",
peerDependencies: "ignore",
optionalDependencies: "ignore",
devDependencies: "always",
}

/**
* Convert from string option to object option
*/
function stringToOption(option: Prefer): FullOption {
return {
dependencies: option,
peerDependencies: option,
optionalDependencies: option,
devDependencies: option,
}
}

/**
* Convert from object option to object option
*/
function objectToOption(
option: Partial<FullOption>,
defaults: FullOption,
): FullOption {
return {
dependencies: option.dependencies || defaults.dependencies,
peerDependencies: option.peerDependencies || defaults.peerDependencies,
optionalDependencies:
option.optionalDependencies || defaults.optionalDependencies,
devDependencies: option.devDependencies || defaults.devDependencies,
}
}

/**
* Parse option
*/
function parseOption(
option:
| null
| Prefer
| (Partial<FullOption> & {
overridePackages?: Record<string, Prefer | Partial<FullOption>>
}),
): (packageName: string) => FullOption {
if (!option) {
return () => DEFAULT
}
if (typeof option === "string") {
const objectOption = stringToOption(option)
return () => objectOption
}
const baseOption = objectToOption(option, DEFAULT)
if (!option.overridePackages) {
return () => baseOption
}
const overridePackages = Object.entries(option.overridePackages).map(
([packageName, opt]) => {
const regexp = toRegExp(packageName)
return {
test: (s: string) => regexp.test(s),
...(typeof opt === "string"
? stringToOption(opt)
: objectToOption(opt, baseOption)),
}
},
)

return (name) => {
for (const overridePackage of overridePackages) {
if (overridePackage.test(name)) {
return overridePackage
}
}

return baseOption
}
}

export default createRule("absolute-version", {
meta: {
docs: {
description: "require or disallow absolute version of dependency.",
category: "Best Practices",
recommended: false,
},
schema: [
{
oneOf: [
{ enum: PREFERS.filter((p) => p !== "ignore") },
{
type: "object",
properties: {
...SCHEMA_FOR_DEPS_PROPERTIES,
overridePackages: {
type: "object",
patternProperties: {
"^(?:\\S+)$": {
oneOf: [
{ enum: PREFERS },
{
type: "object",
properties:
SCHEMA_FOR_DEPS_PROPERTIES,
additionalProperties: false,
},
],
},
},
minProperties: 1,
additionalProperties: false,
},
},
additionalProperties: false,
},
],
},
],
messages: {},
type: "suggestion",
},
create(context) {
const getOption = parseOption(context.options[0])

/** Define dependency visitor */
function defineVisitor(
visitName:
| "dependencies"
| "peerDependencies"
| "optionalDependencies"
| "devDependencies",
) {
return (node: JSONProperty) => {
const ver = getStaticJSONValue(node.value)
if (typeof ver !== "string" || ver == null) {
return
}
const name = String(getKeyFromJSONProperty(node))
const option = getOption(name)[visitName]

const semver = getSemverRange(ver)
if (semver == null) {
return
}
if (option === "always") {
if (isAbsoluteVersion(semver)) {
return
}
context.report({
loc: node.value.loc,
message: "Use the absolute version instead.",
})
} else if (option === "never") {
if (!isAbsoluteVersion(semver)) {
return
}
context.report({
loc: node.value.loc,
message: "Do not use the absolute version.",
})
}
}
}

return defineJsonVisitor({
dependencies: defineVisitor("dependencies"),
peerDependencies: defineVisitor("peerDependencies"),
optionalDependencies: defineVisitor("optionalDependencies"),
devDependencies: defineVisitor("devDependencies"),
})
},
})

/** Checks whether the given semver is absolute version or not */
function isAbsoluteVersion(semver: Range) {
for (const comparators of semver.set) {
for (const comparator of comparators) {
if (isAnyComparator(comparator)) {
return false
}
if (comparator.operator !== "=" && comparator.operator !== "") {
return false
}
}
}
return true
}
27 changes: 27 additions & 0 deletions lib/utils/regexp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Convert to regexp
*/
export function toRegExp(str: string): { test(s: string): boolean } {
const regexp = parseRegExp(str)
if (regexp) {
return regexp
}
return { test: (s) => s === str }
}

/**
* Parse regexp string
*/
function parseRegExp(str: string) {
if (!str.startsWith("/")) {
return null
}
const lastSlashIndex = str.lastIndexOf("/")
if (lastSlashIndex <= 1) {
return null
}
return new RegExp(
str.slice(1, lastSlashIndex),
str.slice(lastSlashIndex + 1),
)
}
2 changes: 2 additions & 0 deletions lib/utils/rules.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { RuleModule } from "../types"
import absoluteVersion from "../rules/absolute-version"
import compatEngines from "../rules/compat-engines"
import noDeprecated from "../rules/no-deprecated"
import validEngines from "../rules/valid-engines"
import validSemver from "../rules/valid-semver"

export const rules = [
absoluteVersion,
compatEngines,
noDeprecated,
validEngines,
Expand Down
Loading

0 comments on commit 1d6f38b

Please sign in to comment.