Skip to content

Commit

Permalink
Feat: multiple sets of options (#41)
Browse files Browse the repository at this point in the history
* Schema: allowing multiple sets of options.

* Draft implementation.

* Ref: moving lookup.

* Ref: extracting splitPeers and correcting the type of Manifest.

* FIX: working for multiple options.

* removing non-null assertion (fixed).

* minor: todo.

* Extending Manifest type.

* More readable Manifest interface.

* REF: flattening the actual rule handler.

* REF: using R import from ramda.

* Fix extra options in Manifest.

* Ref: using R.map instead of const assertion.

* Ref: extracting typed const slices.

* REF: moving splitPeers to helpers, removing flip on R.path, ensuring boolean type.

* Testing splitPeers, using exact bool match.

* Minor: naming.

* Enh: create RegExp once.

* Ref: using R.any() instead of Array::some().

* Ref: extracting tester using R.invoker().

* Ref: extracting makeTester.

* v1.2.0-beta.1

* Ref: simpler isOptional, without R.path.

* v1.2.0-beta.2
  • Loading branch information
RobinTail authored Feb 20, 2025
1 parent f18380c commit c3a2e80
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 64 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "eslint-plugin-allowed-dependencies",
"description": "ESLint plugin Allowed Dependencies",
"version": "1.1.1",
"version": "1.2.0-beta.2",
"type": "module",
"module": "dist/index.js",
"main": "dist/index.cjs",
Expand Down
27 changes: 26 additions & 1 deletion src/helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { readerMock } from "../mocks/fs.ts";
import { getManifest, getName } from "./helpers.ts";
import { getManifest, getName, splitPeers } from "./helpers.ts";

describe("Helpers", () => {
describe("getName()", () => {
Expand All @@ -22,4 +22,29 @@ describe("Helpers", () => {
expect(readerMock).toHaveBeenCalledWith("some/dir/package.json", "utf8");
});
});

describe("splitPeers()", () => {
it("should divide peer dependencies into required and optional", () => {
expect(
splitPeers({
peerDependencies: {
one: "",
two: "",
three: "",
four: "",
five: "",
},
peerDependenciesMeta: {
one: { optional: false },
two: { optional: true },
three: { optional: undefined },
four: { optional: 123 as unknown as boolean },
},
}),
).toEqual({
optionalPeers: ["two"],
requiredPeers: ["one", "three", "four", "five"],
});
});
});
});
30 changes: 25 additions & 5 deletions src/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,33 @@
import { readFileSync } from "node:fs";
import { join as joinPath } from "node:path";
import { flow, join, split, startsWith, take } from "ramda";
import { join } from "node:path";
import * as R from "ramda";

/** is scoped import: starts with "at" */
const hasScope = startsWith("@");
const hasScope = R.startsWith("@");

/** gets the dependency name even when importing its internal path */
export const getName = (imp: string) =>
flow(imp, [split("/"), take(hasScope(imp) ? 2 : 1), join("/")]);
R.flow(imp, [R.split("/"), R.take(hasScope(imp) ? 2 : 1), R.join("/")]);

type Dependencies = Record<string, string>;
type PeerMeta = Record<string, { optional?: boolean }>;
export interface Manifest {
dependencies?: Dependencies;
devDependencies?: Dependencies;
peerDependencies?: Dependencies;
peerDependenciesMeta?: PeerMeta;
[K: string]: unknown;
}

export const getManifest = (path: string) =>
JSON.parse(readFileSync(joinPath(path, "package.json"), "utf8"));
JSON.parse(readFileSync(join(path, "package.json"), "utf8")) as Manifest;

export const splitPeers = (manifest: Manifest) => {
const isOptional = (name: string) =>
manifest.peerDependenciesMeta?.[name]?.optional === true;
const [optionalPeers, requiredPeers] = R.partition(
isOptional,
Object.keys(manifest.peerDependencies || {}),
);
return { requiredPeers, optionalPeers };
};
16 changes: 14 additions & 2 deletions src/rule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { RuleTester } from "@typescript-eslint/rule-tester";
import { readerMock } from "../mocks/fs.ts";
import { rule } from "./rule";

const makeBefore = (env: object) => () =>
readerMock.mockReturnValueOnce(JSON.stringify(env));
const makeBefore =
(...envs: object[]) =>
() => {
for (const env of envs) readerMock.mockReturnValueOnce(JSON.stringify(env));
};

RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
Expand Down Expand Up @@ -81,6 +84,15 @@ tester.run("dependencies", rule, {
code: `import type {} from "eslint"`,
before: makeBefore({ devDependencies: { eslint: "" } }),
},
{
name: "multiple options",
code: `import {} from "fancy-module"; import type {} from "eslint";`,
options: [{ production: true }, { development: "typeOnly" }],
before: makeBefore(
{ dependencies: { "fancy-module": "" } },
{ devDependencies: { eslint: "" } },
),
},
],
invalid: [
{
Expand Down
109 changes: 57 additions & 52 deletions src/rule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ESLintUtils } from "@typescript-eslint/utils";
import { path, flatten, flip, mapObjIndexed, partition, values } from "ramda";
import { getManifest, getName } from "./helpers.ts";
import * as R from "ramda";
import { getManifest, getName, splitPeers } from "./helpers.ts";
import { type Category, type Options, type Value, options } from "./schema.ts";

const messages = {
Expand All @@ -15,68 +15,73 @@ const defaults: Options = {
optionalPeers: "typeOnly",
};

export const rule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
messages,
type: "problem",
schema: [options],
},
defaultOptions: [defaults],
create: (
ctx,
[
{
packageDir = ctx.cwd,
typeOnly = [],
ignore = ["^\\.", "^node:"],
...rest
},
],
) => {
const manifest = getManifest(packageDir);
const isIgnored = (imp: string) =>
ignore.some((pattern) => new RegExp(pattern).test(imp));
const lookup = flip(path)(manifest);
const isOptional = (name: string) =>
lookup(["peerDependenciesMeta", name, "optional"]) as boolean;
const [optionalPeers, requiredPeers] = partition(
isOptional,
Object.keys(manifest.peerDependencies || {}),
);
const values: Value[] = [true, false, "typeOnly"];

const makeIterator =
(ctx: { cwd: string }) =>
({
packageDir = ctx.cwd,
typeOnly = [],
ignore = ["^\\.", "^node:"],
production = defaults.production,
development = defaults.development,
requiredPeers = defaults.requiredPeers,
optionalPeers = defaults.optionalPeers,
}: Options) => {
const manifest = getManifest(packageDir);
const sources: Record<Category, string[]> = {
production: Object.keys(manifest.dependencies || {}),
development: Object.keys(manifest.devDependencies || {}),
requiredPeers,
optionalPeers,
...splitPeers(manifest),
};

const controls = { production, development, requiredPeers, optionalPeers };
const take = (value: Value) =>
flatten(
values(mapObjIndexed((v, k) => (v === value ? sources[k] : []), rest)),
R.flatten(
R.values(
R.mapObjIndexed((v, k) => (v === value ? sources[k] : []), controls),
),
);

const [allowed, prohibited, limited] = [
true,
false,
"typeOnly" as const,
].map(take);
const [allowed, prohibited, limited] = R.map(take, values);
limited.push(...typeOnly);

return { allowed, prohibited, limited, ignore };
};

const makeTester = (ignored: string[]) => {
const patterns = R.map(R.constructN(1, RegExp), ignored);
const invoker = R.invoker(1, "test");
return (name: string) => R.any(invoker(name), patterns);
};

export const rule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
messages,
type: "problem",
schema: { type: "array", items: options },
},
defaultOptions: [...[defaults]],
create: (ctx) => {
const iterator = makeIterator(ctx);
const combined = R.map(iterator, ctx.options.length ? ctx.options : [{}]);

const [allowed, prohibited, limited, ignored] = R.map(
(group) => R.flatten(R.pluck(group, combined)),
["allowed", "prohibited", "limited", "ignore"] as const,
);

const isIgnored = makeTester(ignored);
return {
ImportDeclaration: ({ source, importKind }) => {
if (!isIgnored(source.value)) {
const name = getName(source.value);
if (!allowed.includes(name)) {
if (importKind !== "type" || prohibited.includes(name)) {
ctx.report({
node: source,
data: { name },
messageId: limited.includes(name) ? "typeOnly" : "prohibited",
});
}
}
}
if (isIgnored(source.value)) return;
const name = getName(source.value);
if (allowed.includes(name)) return;
if (importKind === "type" && !prohibited.includes(name)) return;
ctx.report({
node: source,
data: { name },
messageId: limited.includes(name) ? "typeOnly" : "prohibited",
});
},
};
},
Expand Down
6 changes: 3 additions & 3 deletions src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { JSONSchema } from "@typescript-eslint/utils";
import type { FromSchema } from "json-schema-to-ts";
import { fromPairs, xprod } from "ramda";
import * as R from "ramda";

const value = {
oneOf: [{ type: "boolean" }, { type: "string", enum: ["typeOnly"] }],
} as const satisfies JSONSchema.JSONSchema4;
export type Value = FromSchema<typeof value>;

const categories = fromPairs(
xprod(
const categories = R.fromPairs(
R.xprod(
["production", "optionalPeers", "requiredPeers", "development"] as const,
[value],
),
Expand Down

0 comments on commit c3a2e80

Please sign in to comment.