Skip to content

Commit

Permalink
feat(eslint-plugin-esm)!: support allowDevDependencies option
Browse files Browse the repository at this point in the history
  • Loading branch information
zanminkian committed Sep 26, 2024
1 parent 4f5a75d commit 28edf1c
Show file tree
Hide file tree
Showing 9 changed files with 130 additions and 64 deletions.
6 changes: 6 additions & 0 deletions .changeset/lemon-vans-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"eslint-plugin-esm": minor
"@git-validator/eslint-config": patch
---

feat(eslint-plugin-esm)!: support `allowDevDependencies` option
11 changes: 11 additions & 0 deletions packages/eslint-config/src/config/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,5 +469,16 @@ export function javascript() {
"import/no-default-export": "off",
},
},
{
// https://github.com/motemen/minimatch-cheat-sheet
name: "git-validator/javascript/test",
files: [
"**/__tests__/**/*.{js,cjs,mjs,jsx}",
"**/*.{test,spec}.{js,cjs,mjs,jsx}",
],
rules: {
"esm/no-phantom-dep-imports": ["error", { allowDevDependencies: true }],
},
},
] as const;
}
1 change: 1 addition & 0 deletions packages/eslint-config/src/config/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export function typescript(project?: string) {
rules: {
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/unbound-method": "off",
"esm/no-phantom-dep-imports": ["error", { allowDevDependencies: true }],
},
},
] as const;
Expand Down
7 changes: 6 additions & 1 deletion packages/eslint-config/test/no-duplicated.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ await describe("no duplicated", async () => {
);

Object.keys(javascript()[0].rules)
.filter((rule) => !["import/no-default-export"].includes(rule))
.filter(
(rule) =>
!["import/no-default-export", "esm/no-phantom-dep-imports"].includes(
rule,
),
)
.forEach((rule) => {
assert.strictEqual(count(configContent, rule), 1);
});
Expand Down
13 changes: 9 additions & 4 deletions packages/eslint-plugin-esm/doc/rules/no-phantom-dep-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ Disallow importing from a module which the nearest `package.json` doesn't includ
### Fail

```ts
import type foo from 'foo' // options: [{"allowDevDependencies":true}]
import type foo from 'foo' // options: [{"allowDevDependencies":false}]
import {type Foo} from 'foo'
import foo from 'foo' // filename: /foo/src/rules/no-phantom-dep-imports.spec.ts
import eslint from 'eslint' // filename: /foo/foo.js
import foo from 'foo'
import {type Foo} from 'eslint'
import {Foo} from 'eslint'
import eslint from 'eslint'
```

### Pass
Expand All @@ -20,7 +24,8 @@ import foo from '/foo'
import foo from './foo'
import foo from '../foo'
import foo from 'node:foo'
import type {Foo} from 'foo'
import eslint from 'eslint' // filename: /foo/src/rules/no-phantom-dep-imports.spec.ts
import type Foo from 'estree'
import type {Foo} from 'eslint'
import eslint from 'eslint' // options: [{"allowDevDependencies":true}]
```
<!-- prettier-ignore-end -->
7 changes: 4 additions & 3 deletions packages/eslint-plugin-esm/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,28 @@ import type {
ImportDeclaration,
ImportExpression,
} from "estree";
import type { JSONSchema4 } from "json-schema";

export const DEFAULT_MESSAGE_ID = "default";

export function createRule({
name,
message,
// schema,
schema,
fixable,
type = "suggestion",
create,
}: {
name: string;
message: string;
// schema?: JSONSchema4[];
schema?: JSONSchema4[];
fixable?: Rule.RuleMetaData["fixable"];
type?: Rule.RuleMetaData["type"];
create: (context: Rule.RuleContext) => Rule.RuleListener;
}): { name: string; rule: Rule.RuleModule } {
const rule: Rule.RuleModule = {
meta: {
// ...(schema && { schema }),
...(schema && { schema }),
...(fixable && { fixable }),
messages: {
[DEFAULT_MESSAGE_ID]: message,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { test } from "../test.spec.js";
import { noPhantomDepImports } from "./no-phantom-dep-imports.js";

Expand All @@ -9,20 +6,30 @@ const valid = [
{ code: "import foo from './foo'" },
{ code: "import foo from '../foo'" },
{ code: "import foo from 'node:foo'" },
{ code: "import type {Foo} from 'foo'" },

{ code: "import type Foo from 'estree'" },
{ code: "import type {Foo} from 'eslint'" },
{
code: "import eslint from 'eslint'",
filename: fileURLToPath(import.meta.url),
options: [{ allowDevDependencies: true }],
},
];

const invalid = [
{ code: "import {type Foo} from 'foo'" },
{ code: "import foo from 'foo'", filename: fileURLToPath(import.meta.url) },
{
code: "import eslint from 'eslint'",
filename: path.join(process.cwd(), "foo.js"),
code: "import type foo from 'foo'",
options: [{ allowDevDependencies: true }],
},
{
code: "import type foo from 'foo'",
options: [{ allowDevDependencies: false }],
},
{ code: "import {type Foo} from 'foo'" },
{ code: "import foo from 'foo'" },

{ code: "import {type Foo} from 'eslint'" },
{ code: "import {Foo} from 'eslint'" },
{ code: "import eslint from 'eslint'" },
];

test({ valid, invalid, ...noPhantomDepImports });
99 changes: 58 additions & 41 deletions packages/eslint-plugin-esm/src/rules/no-phantom-dep-imports.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import {
create,
createRule,
getRuleName,
getSourceType,
type ImportationNode,
} from "../common.js";
import { create, createRule, getRuleName, getSourceType } from "../common.js";

function isObject(value: unknown) {
return value !== null && typeof value === "object";
Expand Down Expand Up @@ -52,39 +46,62 @@ export const noPhantomDepImports = createRule({
name: getRuleName(import.meta.url),
message:
"Disallow importing from a module which the nearest `package.json` doesn't include it.",
create: (context) => create(context, check),
});
schema: [
{
type: "object",
properties: {
allowDevDependencies: { type: "boolean" },
},
additionalProperties: false,
},
],
create: (context) =>
create(context, (filename, source, node) => {
const {
allowDevDependencies = false,
}: { allowDevDependencies: boolean } = context.options[0] ?? {};

function check(filename: string, source: string, node: ImportationNode) {
// ignore `import type {foo} from 'foo'`
if ("importKind" in node && node.importKind === "type") {
return false;
}
// ignore `import {foo} from './'`
if (getSourceType(source) !== "module") {
return false;
}
const pkgJson = getPkgJson(path.dirname(filename));
// cannot find package.json file
if (!pkgJson) {
return true;
}
const dep =
"dependencies" in pkgJson.content && isObject(pkgJson.content.dependencies)
? pkgJson.content.dependencies
: {};
const devDep =
"devDependencies" in pkgJson.content &&
isObject(pkgJson.content.devDependencies)
? pkgJson.content.devDependencies
: {};
// ignore `import {foo} from './'`
if (getSourceType(source) !== "module") {
return false;
}
const pkgJson = getPkgJson(path.dirname(filename));
// cannot find package.json file
if (!pkgJson) {
return true;
}
const dep =
"dependencies" in pkgJson.content &&
isObject(pkgJson.content.dependencies)
? pkgJson.content.dependencies
: {};
const devDep =
"devDependencies" in pkgJson.content &&
isObject(pkgJson.content.devDependencies)
? pkgJson.content.devDependencies
: {};

const moduleName = source
.split("/")
.slice(0, source.startsWith("@") ? 2 : 1)
.join("/");
if (["test", "spec"].includes(filename.split(".").at(-2) ?? "")) {
return !(moduleName in dep || moduleName in devDep);
}
return !(moduleName in dep);
}
const moduleName = source
.split("/")
.slice(0, source.startsWith("@") ? 2 : 1)
.join("/");

if ("importKind" in node && node.importKind === "type") {
return moduleName.startsWith("@") && moduleName.includes("/")
? !(
moduleName in dep ||
moduleName in devDep ||
`@types/${moduleName.slice(1).replace("/", "_")}` in devDep
)
: !(
moduleName in dep ||
moduleName in devDep ||
`@types/${moduleName}` in devDep
);
} else {
return allowDevDependencies
? !(moduleName in dep || moduleName in devDep)
: !(moduleName in dep);
}
}),
});
25 changes: 19 additions & 6 deletions packages/eslint-plugin-esm/src/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { fileURLToPath } from "node:url";
import { RuleTester, type Rule } from "eslint";
import { outdent } from "outdent";

export type TestCase = string | { code: string; filename?: string };
export type TestCase =
| string
| { code: string; filename?: string; options?: unknown };

const tester = new RuleTester({
parser: createRequire(import.meta.url).resolve("@typescript-eslint/parser"),
Expand All @@ -34,6 +36,9 @@ export async function test({
tester.run(name, rule, {
valid: [testCase],
invalid: [],
...(typeof testCase === "object" && testCase.options
? { options: testCase.options }
: {}),
});
});
}),
Expand All @@ -48,6 +53,9 @@ export async function test({
tester.run(name, rule, {
valid: [],
invalid: [{ code, errors, filename }],
...(typeof testCase === "object" && testCase.options
? { options: testCase.options }
: {}),
});
});
}),
Expand All @@ -73,11 +81,16 @@ async function genDoc({
.map((testCase) =>
typeof testCase === "string" ? { code: testCase } : testCase,
)
.map((testCase) =>
testCase.filename
? `${testCase.code} // filename: ${testCase.filename}`
: testCase.code,
)
.map((testCase) => {
if (!testCase.filename && !testCase.options) {
return testCase.code;
}
const filename = testCase.filename && `filename: ${testCase.filename}`;
const options =
testCase.options && `options: ${JSON.stringify(testCase.options)}`;
const comment = [filename, options].filter((i) => !!i).join(", ");
return `${testCase.code} // ${comment}`;
})
.join("\n");
const mdContent = outdent`
<!-- prettier-ignore-start -->
Expand Down

0 comments on commit 28edf1c

Please sign in to comment.