@open-xchange/linter-presets 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8] - 2024-07-23
4
+
5
+ - fixed: [ESLint] type error in rule `env-project/no-invalid-modules`
6
+
7
+ ## [0.1.7] - 2024-07-23
8
+
9
+ - changed: [ESLint] reverted option `modules` for `env.project` (introduced in 0.1.6)
10
+ - changed: [ESLint] `env.project`: renamed option `packages` to `hierarchy`
11
+ - chore: [ESLint] split rule `env-project/no-invalid-modules` (added `env-project/no-invalid-hierarchy`)
12
+
3
13
  ## [0.1.6] - 2024-07-23
4
14
 
5
15
  - changed: [ESLint] moved rule options for `env.project` into own `modules` option
@@ -31,7 +41,7 @@
31
41
 
32
42
  ## [0.0.6] - 2024-07-10
33
43
 
34
- - fixed: [ESLint] type error in `env-project/no-invalid-modules`
44
+ - fixed: [ESLint] type error in rule `env-project/no-invalid-modules`
35
45
 
36
46
  ## [0.0.5] - 2024-07-10
37
47
 
@@ -1,14 +1,24 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
  import type { EnvBaseOptions } from "../shared/env-utils.js";
3
+ import type { SharedRuleSettings } from "../shared/rule-utils.js";
3
4
  import { type RuleNoInvalidModulesOptions } from "../rules/no-invalid-modules.js";
5
+ import { type RuleNoInvalidHierarchyOptions } from "../rules/no-invalid-hierarchy.js";
4
6
  /**
5
7
  * Configuration options for the environment preset "env.project".
6
8
  */
7
9
  export interface EnvProjectOptions extends EnvBaseOptions {
10
+ /**
11
+ * Maps all alias prefixes to actual paths in the project.
12
+ */
13
+ alias?: SharedRuleSettings["alias"];
8
14
  /**
9
15
  * Options to be passed to the rule "env-project/no-invalid-modules".
10
16
  */
11
- modules?: RuleNoInvalidModulesOptions;
17
+ external?: RuleNoInvalidModulesOptions["external"];
18
+ /**
19
+ * Options to be passed to the rule "env-project/no-invalid-hierarchy".
20
+ */
21
+ hierarchy?: RuleNoInvalidHierarchyOptions;
12
22
  }
13
23
  /**
14
24
  * Adds custom linter rules for checking project setup and module hierarchy.
@@ -1,6 +1,7 @@
1
1
  import { concatConfigs, createConfig, customRules } from "../shared/env-utils.js";
2
2
  import noAmdModuleDirective from "../rules/no-amd-module-directive.js";
3
3
  import noInvalidModules from "../rules/no-invalid-modules.js";
4
+ import noInvalidHierarchy from "../rules/no-invalid-hierarchy.js";
4
5
  // exports ====================================================================
5
6
  /**
6
7
  * Adds custom linter rules for checking project setup and module hierarchy.
@@ -20,13 +21,20 @@ export default function project(envOptions) {
20
21
  rules: {
21
22
  "no-amd-module-directive": noAmdModuleDirective,
22
23
  "no-invalid-modules": noInvalidModules,
24
+ "no-invalid-hierarchy": noInvalidHierarchy,
23
25
  },
24
26
  },
25
27
  },
28
+ settings: {
29
+ "env-project": {
30
+ alias: envOptions.alias,
31
+ },
32
+ },
26
33
  }),
27
34
  // custom rules
28
35
  customRules(envOptions, {
29
36
  "env-project/no-amd-module-directive": "error",
30
- "env-project/no-invalid-modules": ["error", envOptions.modules ?? {}],
37
+ "env-project/no-invalid-modules": ["error", { external: envOptions.external ?? [] }],
38
+ "env-project/no-invalid-hierarchy": envOptions.hierarchy ? ["error", envOptions.hierarchy] : "off",
31
39
  }));
32
40
  }
@@ -0,0 +1,25 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ export interface RuleNoInvalidHierarchyPackage {
3
+ /**
4
+ * Glob patterns selecting all source files that are part of the package.
5
+ */
6
+ files: string[];
7
+ /**
8
+ * Specifies the names of all packages (keys of the `packages` dictionary)
9
+ * this package depends on.
10
+ */
11
+ extends?: string | string[];
12
+ /**
13
+ * Set to `true` to mark an optional package that may be missing in an
14
+ * installation. Such a package cannot be imported statically (with
15
+ * `import` statement) from a non-optional package, but can only be loaded
16
+ * dynamically at runtime (by calling `import()`). The rule will mark all
17
+ * static imports of optional code as an error. Default value is `false`.
18
+ */
19
+ optional?: boolean;
20
+ }
21
+ export type RuleNoInvalidHierarchyOptions = Record<string, RuleNoInvalidHierarchyPackage>;
22
+ type RuleOptions = [RuleNoInvalidHierarchyOptions];
23
+ type RuleMessageIds = "UNEXPECTED_OPTIONAL_STATIC" | "INVALID_PACKAGE_HIERARCHY";
24
+ declare const _default: ESLintUtils.RuleModule<RuleMessageIds, RuleOptions, ESLintUtils.RuleListener>;
25
+ export default _default;
@@ -0,0 +1,85 @@
1
+ import { AST_NODE_TYPES as NodeType, ESLintUtils } from "@typescript-eslint/utils";
2
+ import { Schema, makeArray, ProjectContext } from "../shared/rule-utils.js";
3
+ // exports ====================================================================
4
+ export default ESLintUtils.RuleCreator.withoutDocs({
5
+ meta: {
6
+ type: "problem",
7
+ schema: [
8
+ // single options object
9
+ Schema.dictionary(Schema.options({
10
+ files: Schema.maybeArray(Schema.stringNE()),
11
+ extends: Schema.maybeArray(Schema.stringNE()),
12
+ optional: Schema.boolean(),
13
+ }, ["files"])),
14
+ ],
15
+ messages: {
16
+ UNEXPECTED_OPTIONAL_STATIC: "Unexpected static import of optional package.",
17
+ INVALID_PACKAGE_HIERARCHY: "Low-level package cannot import modules from higher-level package.",
18
+ },
19
+ fixable: "code",
20
+ },
21
+ defaultOptions: [{}],
22
+ create(context, [options]) {
23
+ // create the project context with aliases, root path, own module name, etc.
24
+ const projectContext = new ProjectContext(context);
25
+ const hierarchyMap = new Map();
26
+ for (const [key, settings] of Object.entries(options)) {
27
+ hierarchyMap.set(key, {
28
+ ...settings,
29
+ extends: makeArray(settings.extends),
30
+ });
31
+ }
32
+ // collect all globs for package members
33
+ const packagesGlobs = Array.from(hierarchyMap.values(), settings => settings.files).flat();
34
+ return {
35
+ "ImportDeclaration, TSExternalModuleReference, ImportExpression, ExportAllDeclaration, ExportNamedDeclaration"(node) {
36
+ // import/export statement must contain string literal
37
+ const importWrapper = projectContext.resolveImportNode(node);
38
+ if (!importWrapper) {
39
+ return;
40
+ }
41
+ // check dependencies if the imported module is covered in the configuration
42
+ if (!importWrapper.matchModuleName(packagesGlobs)) {
43
+ return;
44
+ }
45
+ // check package dependencies
46
+ // whether an invalid static import has been found
47
+ let invalidStatic = false;
48
+ // recursively checks the imported module for dependency violations
49
+ const checkDependencies = (settings, root) => {
50
+ // only allow static imports from specified package, if it is not optional
51
+ if (importWrapper.matchModuleName(settings.files)) {
52
+ if (!root && (node.type !== NodeType.ImportExpression) && settings.optional) {
53
+ invalidStatic = true;
54
+ }
55
+ return true;
56
+ }
57
+ // do not traverse into dependencies of optional modules
58
+ if (!settings.extends.length || (!root && settings.optional)) {
59
+ return false;
60
+ }
61
+ // allow imports from any of the dependent packages
62
+ return settings.extends.some(key => {
63
+ const package2 = hierarchyMap.get(key);
64
+ return !!package2 && checkDependencies(package2, false);
65
+ });
66
+ };
67
+ // check dependencies of all configured packages (not for anonymous modules)
68
+ let invalidDeps = false;
69
+ for (const settings of hierarchyMap.values()) {
70
+ if (projectContext.matchModuleName(settings.files) && !checkDependencies(settings, true)) {
71
+ invalidDeps = true;
72
+ break;
73
+ }
74
+ }
75
+ // report package hierarchy errors
76
+ if (invalidStatic) {
77
+ context.report({ messageId: "UNEXPECTED_OPTIONAL_STATIC", node: importWrapper.sourceNode });
78
+ }
79
+ else if (invalidDeps) {
80
+ context.report({ messageId: "INVALID_PACKAGE_HIERARCHY", node: importWrapper.sourceNode });
81
+ }
82
+ },
83
+ };
84
+ },
85
+ });
@@ -1,38 +1,10 @@
1
1
  import { ESLintUtils } from "@typescript-eslint/utils";
2
- export interface RuleNoInvalidModulesPackage {
3
- /**
4
- * Glob patterns selecting all source files that are part of the package.
5
- */
6
- src: string | string[];
7
- /**
8
- * Specifies the names of all packages (keys of the `packages` dictionary)
9
- * this package depends on.
10
- */
11
- extends?: string | string[];
12
- /**
13
- * Set to `true` to mark an optional package that may be missing in an
14
- * installation. Such a package cannot be imported statically (with
15
- * `import` statement) from a non-optional package, but can only be loaded
16
- * dynamically at runtime (by calling `import()`). The rule will mark all
17
- * static imports of optional code as an error. Default value is `false`.
18
- */
19
- optional?: boolean;
20
- }
21
2
  export interface RuleNoInvalidModulesOptions {
22
- /**
23
- * Maps all alias prefixes to actual paths in the project.
24
- */
25
- alias?: Record<string, string>;
26
3
  /**
27
4
  * Specifies glob patterns for external modules.
28
5
  */
29
- external?: string | string[];
30
- /**
31
- * Allows to separate the source files into virtual packages.
32
- */
33
- packages?: Record<string, RuleNoInvalidModulesPackage>;
6
+ external?: string[];
34
7
  }
35
8
  type RuleOptions = [Required<RuleNoInvalidModulesOptions>];
36
- type RuleMessageIds = "SOURCE_FILE_NOT_FOUND" | "UNEXPECTED_OPTIONAL_STATIC" | "INVALID_PACKAGE_HIERARCHY";
37
- declare const _default: ESLintUtils.RuleModule<RuleMessageIds, RuleOptions, ESLintUtils.RuleListener>;
9
+ declare const _default: ESLintUtils.RuleModule<"SOURCE_FILE_NOT_FOUND", RuleOptions, ESLintUtils.RuleListener>;
38
10
  export default _default;
@@ -1,10 +1,5 @@
1
- import { createRequire } from "node:module";
2
- import { posix, dirname, extname } from "node:path";
3
- import { findUpSync } from "find-up";
4
1
  import { AST_NODE_TYPES as NodeType, ESLintUtils } from "@typescript-eslint/utils";
5
- import { Schema, makeArray, toPosixPath, isFile, matchModuleName, getModuleName } from "../shared/rule-utils.js";
6
- // constants ==================================================================
7
- const FILE_EXTENSIONS = ["js", "ts", "d.ts"];
2
+ import { Schema, ProjectContext } from "../shared/rule-utils.js";
8
3
  // exports ====================================================================
9
4
  export default ESLintUtils.RuleCreator.withoutDocs({
10
5
  meta: {
@@ -12,152 +7,48 @@ export default ESLintUtils.RuleCreator.withoutDocs({
12
7
  schema: [
13
8
  // single options object
14
9
  Schema.options({
15
- alias: Schema.dictionary(Schema.string()), // alias paths may be empty
16
- external: Schema.maybeArray(Schema.stringNE()),
17
- packages: Schema.dictionary(Schema.options({
18
- src: Schema.maybeArray(Schema.stringNE()),
19
- extends: Schema.maybeArray(Schema.stringNE()),
20
- optional: Schema.boolean(),
21
- }, ["src"])),
10
+ external: Schema.array(Schema.stringNE()),
22
11
  }),
23
12
  ],
24
13
  messages: {
25
14
  SOURCE_FILE_NOT_FOUND: "Source file for '{{moduleName}}' not found.",
26
- UNEXPECTED_OPTIONAL_STATIC: "Unexpected static import of optional package.",
27
- INVALID_PACKAGE_HIERARCHY: "Low-level package cannot import modules from higher-level package.",
28
15
  },
29
16
  fixable: "code",
30
17
  },
31
18
  defaultOptions: [{
32
- alias: {},
33
19
  external: [],
34
- packages: {},
35
20
  }],
36
21
  create(context, [options]) {
37
- // convert "alias" option to map
38
- const aliasMap = new Map(Object.entries(options.alias));
39
- const packagesMap = new Map();
40
- for (const [key, settings] of Object.entries(options.packages)) {
41
- packagesMap.set(key, {
42
- ...settings,
43
- src: makeArray(settings.src),
44
- extends: makeArray(settings.extends),
45
- });
46
- }
47
- // collect all globs for package members
48
- const packagesGlobs = Array.from(packagesMap.values(), settings => settings.src).flat();
49
- // resolve file name
50
- const configPath = findUpSync("eslint.config.js");
51
- const rootDir = configPath && toPosixPath(dirname(configPath));
52
- const fileName = toPosixPath(context.filename);
53
- if (!rootDir || !fileName.startsWith(rootDir + "/")) {
54
- throw new Error("invalid root directory");
55
- }
56
- // path of current module (slice rootDir with "/" from start, and extension with "." from end)
57
- const fileExt = extname(fileName);
58
- const selfModulePath = fileName.slice(rootDir.length + 1, -fileExt.length);
59
- if (!selfModulePath) {
60
- throw new Error("invalid own module path");
61
- }
62
- // replace alias path with alias key
63
- let selfModuleName = selfModulePath;
64
- for (const [aliasKey, aliasPath] of aliasMap) {
65
- if (selfModulePath.startsWith(aliasPath + "/")) {
66
- selfModuleName = aliasKey + "/" + selfModulePath.slice(aliasPath.length + 1);
67
- break;
68
- }
69
- }
70
- // returns an existing alias key used by the passed module name
71
- const resolveAlias = (moduleName) => {
72
- const [key, ...rest] = moduleName.split("/");
73
- const aliasPath = (key && rest[0]) ? aliasMap.get(key) : undefined;
74
- const aliasKey = aliasPath ? key : "";
75
- const modulePath = aliasPath ? posix.join(aliasPath, ...rest) : moduleName;
76
- return [aliasKey, modulePath];
77
- };
78
- // returns whether a source file exists for the specified module
79
- const fileExists = (resolvedName) => {
80
- // check modules with explicit extension
81
- const resolvedPath = posix.join(rootDir, resolvedName);
82
- if (extname(resolvedName)) {
83
- return isFile(resolvedPath);
84
- }
85
- // search for a file with a known extension
86
- return FILE_EXTENSIONS.some(ext => isFile(resolvedPath + "." + ext));
87
- };
88
- // returns whether the passed module name is an installed NPM package
89
- const requireModule = createRequire(configPath);
90
- const packageExists = (moduleName) => {
91
- try {
92
- requireModule.resolve(moduleName);
93
- return true;
94
- }
95
- catch {
96
- return false;
97
- }
98
- };
22
+ // create the project context with aliases, root path, own module name, etc.
23
+ const projectContext = new ProjectContext(context);
99
24
  return {
100
25
  "ImportDeclaration, TSExternalModuleReference, ImportExpression, ExportAllDeclaration, ExportNamedDeclaration"(node) {
101
26
  // import/export statement must contain string literal
102
- const { sourceNode, moduleName } = getModuleName(node);
103
- if (!sourceNode || !moduleName) {
27
+ const importWrapper = projectContext.resolveImportNode(node);
28
+ if (!importWrapper) {
104
29
  return;
105
30
  }
106
31
  // skip glob patterns in TypeScript type imports
107
- if ((node.type === NodeType.ImportDeclaration) && (node.importKind === "type") && moduleName.includes("*")) {
32
+ if ((node.type === NodeType.ImportDeclaration) && (node.importKind === "type") && importWrapper.moduleName.includes("*")) {
108
33
  return;
109
34
  }
110
- // extract alias key, replace with alias path
111
- const [aliasKey, modulePath] = resolveAlias(moduleName);
112
- // whether the import is a known external module
113
- const isExternal = matchModuleName(moduleName, options.external);
114
- // whether the import is an installed NPM package
115
- const isPackage = !isExternal && !aliasKey && packageExists(moduleName);
116
- // check existence of source file
117
- if (!isExternal && !isPackage && !fileExists(modulePath)) {
118
- context.report({ messageId: "SOURCE_FILE_NOT_FOUND", node: sourceNode, data: { moduleName } });
35
+ // accept known external module
36
+ if (importWrapper.matchModuleName(options.external)) {
119
37
  return;
120
38
  }
121
- // check dependencies if the imported module is covered in the configuration
122
- if (!matchModuleName(moduleName, packagesGlobs)) {
39
+ // extract alias key, replace with alias path
40
+ const [aliasKey, modulePath] = projectContext.resolveAlias(importWrapper.moduleName);
41
+ // accept installed NPM package
42
+ if (!aliasKey && projectContext.packageExists(modulePath)) {
123
43
  return;
124
44
  }
125
- // check package dependencies
126
- // whether an invalid static import has been found
127
- let invalidStatic = false;
128
- // recursively checks the imported module for dependency violations
129
- const checkDependencies = (settings, root) => {
130
- // only allow static imports from specified package, if it is not optional
131
- if (matchModuleName(moduleName, settings.src)) {
132
- if (!root && (node.type !== NodeType.ImportExpression) && settings.optional) {
133
- invalidStatic = true;
134
- }
135
- return true;
136
- }
137
- // do not traverse into dependencies of optional modules
138
- if (!settings.extends.length || (!root && settings.optional)) {
139
- return false;
140
- }
141
- // allow imports from any of the dependent packages
142
- return settings.extends.some(key => {
143
- const package2 = packagesMap.get(key);
144
- return !!package2 && checkDependencies(package2, false);
45
+ // check existence of source file
46
+ if (!projectContext.fileExists(modulePath)) {
47
+ context.report({
48
+ messageId: "SOURCE_FILE_NOT_FOUND",
49
+ node: importWrapper.sourceNode,
50
+ data: { moduleName: importWrapper.moduleName },
145
51
  });
146
- };
147
- // check dependencies of all configured packages (not for anonymous modules)
148
- let invalidDeps = false;
149
- for (const settings of packagesMap.values()) {
150
- if (matchModuleName(selfModuleName, settings.src) && !checkDependencies(settings, true)) {
151
- invalidDeps = true;
152
- break;
153
- }
154
- }
155
- // report package hierarchy errors
156
- if (invalidStatic) {
157
- context.report({ messageId: "UNEXPECTED_OPTIONAL_STATIC", node: sourceNode });
158
- }
159
- else if (invalidDeps) {
160
- context.report({ messageId: "INVALID_PACKAGE_HIERARCHY", node: sourceNode });
161
52
  }
162
53
  },
163
54
  };
@@ -1,10 +1,15 @@
1
- import pm from "picomatch";
2
- import type { JSONSchema, TSESTree } from "@typescript-eslint/utils";
3
- export type ImportExportNode = TSESTree.ImportDeclaration | TSESTree.ImportExpression | TSESTree.ExportAllDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.TSExternalModuleReference;
4
- export interface ModuleNameResult {
5
- sourceNode: TSESTree.StringLiteral | undefined;
6
- moduleName: string;
1
+ import { type Glob } from "picomatch";
2
+ import type { JSONSchema, TSESTree, TSESLint } from "@typescript-eslint/utils";
3
+ /**
4
+ * Shared settings used by multiple rules.
5
+ */
6
+ export interface SharedRuleSettings {
7
+ /**
8
+ * Maps all alias prefixes to actual paths in the project.
9
+ */
10
+ alias?: Record<string, string>;
7
11
  }
12
+ export type ImportExportNode = TSESTree.ImportDeclaration | TSESTree.ImportExpression | TSESTree.ExportAllDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.TSExternalModuleReference;
8
13
  /**
9
14
  * Helper functions to build a JSON schema for custom ESLint rules.
10
15
  */
@@ -49,28 +54,88 @@ export declare function toPosixPath(path: string): string;
49
54
  * existing directories).
50
55
  */
51
56
  export declare function isFile(path: string): boolean;
57
+ export declare class ImportNodeWrapper {
58
+ /** The string literal node containing the name of the imported module. */
59
+ readonly sourceNode: TSESTree.StringLiteral;
60
+ /** The original name of the imported module (with alias key). */
61
+ readonly moduleName: string;
62
+ constructor(node: TSESTree.StringLiteral);
63
+ /**
64
+ * Returns whether the name of the imported module matches the specified
65
+ * glob patterns.
66
+ *
67
+ * @param patterns
68
+ * The glob patterns to be matched against the module name.
69
+ *
70
+ * @returns
71
+ * Whether the name of the wrapped module matches at least one glob
72
+ * pattern.
73
+ */
74
+ matchModuleName(patterns: Glob): boolean;
75
+ }
52
76
  /**
53
- * Returns whether a module name matches the specified glob patterns.
54
- *
55
- * @param moduleName
56
- * The module name to be checked.
57
- *
58
- * @param patterns
59
- * The glob patterns to be matched against the module name.
60
- *
61
- * @returns
62
- * Whether the module name matches at least one glob pattern.
63
- */
64
- export declare const matchModuleName: typeof pm.isMatch;
65
- /**
66
- * Extracts the source node and module name of an import/export statement node,
67
- * or a dynamic import expression node.
77
+ * A custom context helper for the linter rules of the preset environment
78
+ * "env.project".
68
79
  *
69
- * @param node
70
- * The import/export statement node, or dynamic import expression node.
71
- *
72
- * @returns
73
- * The string literal node containing the module name, and the resulting
74
- * extracted module name.
80
+ * @param context
81
+ * The rule context of the module currently linted.
75
82
  */
76
- export declare function getModuleName(node: ImportExportNode): ModuleNameResult;
83
+ export declare class ProjectContext {
84
+ #private;
85
+ constructor(context: Readonly<TSESLint.RuleContext<any, any>>);
86
+ /**
87
+ * Extracts the source node and module name of an import/export statement
88
+ * node, or a dynamic import expression node.
89
+ *
90
+ * @param node
91
+ * The import/export statement node, or dynamic import expression node.
92
+ *
93
+ * @returns
94
+ * The string literal node containing the module name, and the resulting
95
+ * extracted module name.
96
+ */
97
+ resolveImportNode(node: ImportExportNode): ImportNodeWrapper | undefined;
98
+ /**
99
+ * Extracts an existing alias key, and resolves the module path of a module
100
+ * name.
101
+ *
102
+ * @param moduleName
103
+ * The module name to extract an alias key from.
104
+ *
105
+ * @returns
106
+ * A pair containing the alias key (empty string if no alias found), and
107
+ * the resolved module path (passed module name if no alias found).
108
+ */
109
+ resolveAlias(moduleName: string): [string, string];
110
+ /**
111
+ * Returns whether the name of the module currently linted matches the
112
+ * specified glob patterns.
113
+ *
114
+ * @param patterns
115
+ * The glob patterns to be matched against the current module name.
116
+ *
117
+ * @returns
118
+ * Whether the current module name matches at least one glob pattern.
119
+ */
120
+ matchModuleName(patterns: Glob): boolean;
121
+ /**
122
+ * Returns whether a source file exists for the specified module.
123
+ *
124
+ * @param modulePath
125
+ * The resolved module path (alias key replaced with path).
126
+ *
127
+ * @returns
128
+ * Whether a source file exists for the specified module.
129
+ */
130
+ fileExists(modulePath: string): boolean;
131
+ /**
132
+ * Returns whether the passed module name is an installed NPM package.
133
+ *
134
+ * @param moduleName
135
+ * The module name to be checked.
136
+ *
137
+ * @returns
138
+ * Whether the passed module name is an installed NPM package.
139
+ */
140
+ packageExists(moduleName: string): boolean;
141
+ }
@@ -1,7 +1,11 @@
1
- import { posix, sep } from "node:path";
1
+ import { posix, sep, dirname, extname } from "node:path";
2
2
  import { lstatSync } from "node:fs";
3
+ import { createRequire } from "node:module";
3
4
  import pm from "picomatch";
5
+ import { findUpSync } from "find-up";
4
6
  import { AST_NODE_TYPES as NodeType } from "@typescript-eslint/utils";
7
+ // constants ==================================================================
8
+ const FILE_EXTENSIONS = ["js", "ts", "d.ts"];
5
9
  // Schema =====================================================================
6
10
  /**
7
11
  * Helper functions to build a JSON schema for custom ESLint rules.
@@ -73,37 +77,167 @@ export function isFile(path) {
73
77
  return false;
74
78
  }
75
79
  }
80
+ // class ImportNodeWrapper ====================================================
81
+ export class ImportNodeWrapper {
82
+ /** The string literal node containing the name of the imported module. */
83
+ sourceNode;
84
+ /** The original name of the imported module (with alias key). */
85
+ moduleName;
86
+ // constructor ------------------------------------------------------------
87
+ constructor(node) {
88
+ this.sourceNode = node;
89
+ // strip URL query strings from module name
90
+ this.moduleName = node.value.replace(/\?.*$/, "");
91
+ }
92
+ // public methods ---------------------------------------------------------
93
+ /**
94
+ * Returns whether the name of the imported module matches the specified
95
+ * glob patterns.
96
+ *
97
+ * @param patterns
98
+ * The glob patterns to be matched against the module name.
99
+ *
100
+ * @returns
101
+ * Whether the name of the wrapped module matches at least one glob
102
+ * pattern.
103
+ */
104
+ matchModuleName(patterns) {
105
+ return pm.isMatch(this.moduleName, patterns);
106
+ }
107
+ }
108
+ // class ProjectContext =======================================================
76
109
  /**
77
- * Returns whether a module name matches the specified glob patterns.
78
- *
79
- * @param moduleName
80
- * The module name to be checked.
81
- *
82
- * @param patterns
83
- * The glob patterns to be matched against the module name.
110
+ * A custom context helper for the linter rules of the preset environment
111
+ * "env.project".
84
112
  *
85
- * @returns
86
- * Whether the module name matches at least one glob pattern.
113
+ * @param context
114
+ * The rule context of the module currently linted.
87
115
  */
88
- export const matchModuleName = pm.isMatch;
89
- /**
90
- * Extracts the source node and module name of an import/export statement node,
91
- * or a dynamic import expression node.
92
- *
93
- * @param node
94
- * The import/export statement node, or dynamic import expression node.
95
- *
96
- * @returns
97
- * The string literal node containing the module name, and the resulting
98
- * extracted module name.
99
- */
100
- export function getModuleName(node) {
101
- // TSExternalModuleReference: module name in property "expression", otherwise "source"
102
- const sourceNodeRaw = (node.type === NodeType.TSExternalModuleReference) ? node.expression : node.source;
103
- // module name is expected to be a string literal
104
- const isStringLiteral = (sourceNodeRaw?.type === NodeType.Literal) && (typeof sourceNodeRaw.value === "string");
105
- const sourceNode = isStringLiteral ? sourceNodeRaw : undefined;
106
- // strip URL query strings from module name
107
- const moduleName = sourceNode ? sourceNode.value.replace(/\?.*$/, "") : "";
108
- return { sourceNode, moduleName };
116
+ export class ProjectContext {
117
+ /** Maps alias keys to alias paths. */
118
+ #aliasMap;
119
+ /** Root directory containing the linter configuration file. */
120
+ #rootDir;
121
+ /** Absolute path to the linter configuration file. */
122
+ #configPath;
123
+ /** The name of the linted module (with alias prefix if available). */
124
+ #moduleName;
125
+ /** Resolver for NPM packages. */
126
+ #requireModule;
127
+ // constructor ------------------------------------------------------------
128
+ constructor(context) {
129
+ // convert "alias" option to map
130
+ const sharedSettings = context.settings["env-project"];
131
+ this.#aliasMap = new Map(Object.entries(sharedSettings?.alias ?? {}));
132
+ // resolve root directory
133
+ const configPath = findUpSync("eslint.config.js");
134
+ const rootDir = configPath && toPosixPath(dirname(configPath));
135
+ const fileName = toPosixPath(context.filename);
136
+ if (!rootDir || !fileName.startsWith(rootDir + "/")) {
137
+ throw new Error("invalid root directory");
138
+ }
139
+ this.#rootDir = rootDir;
140
+ this.#configPath = configPath;
141
+ // path of current module (slice rootDir with "/" from start, and extension with "." from end)
142
+ const fileExt = extname(fileName);
143
+ const selfModulePath = fileName.slice(rootDir.length + 1, -fileExt.length);
144
+ if (!selfModulePath) {
145
+ throw new Error("invalid own module path");
146
+ }
147
+ // replace alias path with alias key
148
+ this.#moduleName = selfModulePath;
149
+ for (const [aliasKey, aliasPath] of this.#aliasMap) {
150
+ if (selfModulePath.startsWith(aliasPath + "/")) {
151
+ this.#moduleName = aliasKey + "/" + selfModulePath.slice(aliasPath.length + 1);
152
+ break;
153
+ }
154
+ }
155
+ }
156
+ // public methods ---------------------------------------------------------
157
+ /**
158
+ * Extracts the source node and module name of an import/export statement
159
+ * node, or a dynamic import expression node.
160
+ *
161
+ * @param node
162
+ * The import/export statement node, or dynamic import expression node.
163
+ *
164
+ * @returns
165
+ * The string literal node containing the module name, and the resulting
166
+ * extracted module name.
167
+ */
168
+ resolveImportNode(node) {
169
+ // TSExternalModuleReference: module name in property "expression", otherwise "source"
170
+ const sourceNodeRaw = (node.type === NodeType.TSExternalModuleReference) ? node.expression : node.source;
171
+ // module name is expected to be a string literal
172
+ const isStringLiteral = (sourceNodeRaw?.type === NodeType.Literal) && (typeof sourceNodeRaw.value === "string");
173
+ return isStringLiteral ? new ImportNodeWrapper(sourceNodeRaw) : undefined;
174
+ }
175
+ /**
176
+ * Extracts an existing alias key, and resolves the module path of a module
177
+ * name.
178
+ *
179
+ * @param moduleName
180
+ * The module name to extract an alias key from.
181
+ *
182
+ * @returns
183
+ * A pair containing the alias key (empty string if no alias found), and
184
+ * the resolved module path (passed module name if no alias found).
185
+ */
186
+ resolveAlias(moduleName) {
187
+ const [key, ...rest] = moduleName.split("/");
188
+ const aliasPath = (key && rest[0]) ? this.#aliasMap.get(key) : undefined;
189
+ const aliasKey = aliasPath ? key : "";
190
+ const modulePath = aliasPath ? posix.join(aliasPath, ...rest) : moduleName;
191
+ return [aliasKey, modulePath];
192
+ }
193
+ /**
194
+ * Returns whether the name of the module currently linted matches the
195
+ * specified glob patterns.
196
+ *
197
+ * @param patterns
198
+ * The glob patterns to be matched against the current module name.
199
+ *
200
+ * @returns
201
+ * Whether the current module name matches at least one glob pattern.
202
+ */
203
+ matchModuleName(patterns) {
204
+ return pm.isMatch(this.#moduleName, patterns);
205
+ }
206
+ /**
207
+ * Returns whether a source file exists for the specified module.
208
+ *
209
+ * @param modulePath
210
+ * The resolved module path (alias key replaced with path).
211
+ *
212
+ * @returns
213
+ * Whether a source file exists for the specified module.
214
+ */
215
+ fileExists(modulePath) {
216
+ // check modules with explicit extension
217
+ const resolvedPath = posix.join(this.#rootDir, modulePath);
218
+ if (extname(modulePath)) {
219
+ return isFile(resolvedPath);
220
+ }
221
+ // search for a file with a known extension
222
+ return FILE_EXTENSIONS.some(ext => isFile(resolvedPath + "." + ext));
223
+ }
224
+ /**
225
+ * Returns whether the passed module name is an installed NPM package.
226
+ *
227
+ * @param moduleName
228
+ * The module name to be checked.
229
+ *
230
+ * @returns
231
+ * Whether the passed module name is an installed NPM package.
232
+ */
233
+ packageExists(moduleName) {
234
+ this.#requireModule ??= createRequire(this.#configPath);
235
+ try {
236
+ this.#requireModule.resolve(moduleName);
237
+ return true;
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
109
243
  }
@@ -20,10 +20,9 @@ function project(options: EnvProjectOptions): Linter.FlatConfig[]
20
20
  | - | - | - | - |
21
21
  | `files` | `string[]` | _required_ | Glob patterns for source files to be included. |
22
22
  | `ignores` | `string[]` | `[]` | Glob patterns for source files matching `files` to be ignored. |
23
- | `modules` | `RuleNoInvalidModulesOptions` | `{}` | Configuration for the rule "env-project/no-invalid-modules": |
24
- | `modules.alias` | `Record<string, string>` | `{}` | Maps all alias prefixes to actual paths in the project (see below). |
25
- | `modules.external` | `string \| string[]` | `[]` | Specifies glob patterns for external modules (see below). |
26
- | `modules.packages` | `Record<string, EnvProjectPackage>` | `{}` | Allows to separate the source files into virtual packages (see below). |
23
+ | `alias` | `Record<string, string>` | `{}` | Maps all alias prefixes to actual paths in the project (see below). |
24
+ | `external` | `string \| string[]` | `[]` | Specifies glob patterns for external modules (see below). |
25
+ | `hierarchy` | `Record<string, RuleNoInvalidHierarchyPackage>` | `{}` | Allows to separate the source files into virtual packages (see below). |
27
26
  | `rules` | `Linter.RulesRecord` | `{}` | Additional linter rules to be added to the configuration. |
28
27
 
29
28
  ### Option `alias`
@@ -43,10 +42,8 @@ export default [
43
42
  ...eslint.configure({ /* ... */ }),
44
43
  ...eslint.env.project({
45
44
  files: ["src/**/*.{js,ts}"],
46
- modules: {
47
- alias: {
48
- "@": "src",
49
- },
45
+ alias: {
46
+ "@": "src",
50
47
  },
51
48
  }),
52
49
  ]
@@ -57,7 +54,7 @@ export default [
57
54
 
58
55
  ### Option `external`
59
56
 
60
- - Type: `string | string[]`
57
+ - Type: `string[]`
61
58
  - Default: `[]`
62
59
 
63
60
  Specifies glob patterns for modules that can be imported although there is no module file available, e.g. due to build tool configuration.
@@ -72,10 +69,8 @@ export default [
72
69
  ...eslint.configure({ /* ... */ }),
73
70
  ...eslint.env.project({
74
71
  files: ["src/**/*.{js,ts}"],
75
- modules: {
76
- alias: { "@": "src" },
77
- external: ["virtual:*", "@/special/lib/**/*.js"],
78
- },
72
+ alias: { "@": "src" },
73
+ external: ["virtual:*", "@/special/lib/**/*.js"],
79
74
  }),
80
75
  ]
81
76
  ```
@@ -83,19 +78,19 @@ export default [
83
78
  - All imports starting with `"virtual:"` will not be checked.
84
79
  - All imports of `.js` files deeply inside `src/special/lib/` will not be checked.
85
80
 
86
- ### Option `packages`
81
+ ### Option `hierarchy`
87
82
 
88
- - Type: `Record<string, EnvProjectPackage>`
83
+ - Type: `Record<string, RuleNoInvalidHierarchyPackage>`
89
84
  - Default: `{}`
90
85
 
91
86
  Allows to separate the source files into several virtual packages. This option is completely independent from any actual packaging performed in later steps of the build process. Its solely purpose is to impose dependencies between the source files to prevent importing files between specific packages.
92
87
 
93
- Each key in the `packages` record is the arbitrary unique name of a package. The record values must be objects satisfying the interface `EnvProjectPackage`:
88
+ Each key in the `hierarchy` record is the arbitrary unique name of a package. The record values must be objects satisfying the interface `RuleNoInvalidHierarchyPackage`:
94
89
 
95
90
  | Name | Type | Default | Description |
96
91
  | - | - | - | - |
97
- | `src` | `string\|string[]` | _required_ | Glob patterns selecting all source files that are part of the package. |
98
- | `extends` | `string\|string[]` | `[]` | Specifies the names of all packages (dictionary keys of the option `packages`) this package depends on. |
92
+ | `files` | `string[]` | _required_ | Glob patterns selecting all source files that are part of the package. |
93
+ | `extends` | `string\|string[]` | `[]` | Specifies the names of all packages (dictionary keys of the option `hierarchy`) this package depends on. |
99
94
  | `optional` | `boolean` | `false` | Set to `true` to mark an optional package that may be missing in an installation. Such a package cannot be imported statically (with `import` statement) from a non-optional package, but can only be loaded dynamically at runtime (by calling `import()`). The rule will mark all static imports of optional code as an error. |
100
95
 
101
96
  Modules that are part of such a package may import any modules from the same package, or from packages it depends on (also recursively from their dependent packages).
@@ -110,21 +105,19 @@ export default [
110
105
  ...eslint.configure({ /* ... */ }),
111
106
  ...eslint.env.project({
112
107
  files: ["src/**/*.{js,ts}"],
113
- modules: {
114
- alias: { "@": "src" },
115
- packages: {
116
- base: {
117
- src: ["@/base/**/*", "@/tools/**/*"],
118
- },
119
- debug: {
120
- src: "@/debug/**/*",
121
- extends: "base",
122
- optional: true,
123
- },
124
- special: {
125
- src: "@/my/special/**/*",
126
- extends: ["base", "debug"],
127
- },
108
+ alias: { "@": "src" },
109
+ hierarchy: {
110
+ base: {
111
+ files: ["@/base/**/*", "@/tools/**/*"],
112
+ },
113
+ debug: {
114
+ files: ["@/debug/**/*"],
115
+ extends: "base",
116
+ optional: true,
117
+ },
118
+ special: {
119
+ files: ["@/my/special/**/*"],
120
+ extends: ["base", "debug"],
128
121
  },
129
122
  },
130
123
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@open-xchange/linter-presets",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "description": "Configuration presets for ESLint and StyleLint",
6
6
  "repository": {
7
7
  "url": "https://gitlab.open-xchange.com/fspd/npm-packages/linter-presets"
@@ -2,7 +2,7 @@
2
2
  // eslint-disable-next-line env-project/no-amd-module-directive
3
3
  /// <amd-module name="file1"/>
4
4
 
5
- // valid imports
5
+ // valid static imports
6
6
  import "find-up";
7
7
  import "$/external/module";
8
8
  import "@/project1/generated";
@@ -11,16 +11,19 @@ import "@/project1/generated";
11
11
  import "invalid-package";
12
12
  // eslint-disable-next-line env-project/no-invalid-modules
13
13
  import "@/project1/invalid";
14
- // eslint-disable-next-line env-project/no-invalid-modules
14
+
15
+ // eslint-disable-next-line env-project/no-invalid-hierarchy
15
16
  import "@/project2/file2";
16
- // eslint-disable-next-line env-project/no-invalid-modules
17
+ // eslint-disable-next-line env-project/no-invalid-hierarchy
17
18
  import "@/project3/file3";
18
19
 
20
+ // valid dynamic imports
19
21
  await import("find-up");
20
22
  await import("@/project1/generated");
21
23
  await import("@/project3/file3");
22
24
 
23
25
  // eslint-disable-next-line env-project/no-invalid-modules
24
26
  await import("invalid-package");
25
- // eslint-disable-next-line env-project/no-invalid-modules
27
+
28
+ // eslint-disable-next-line env-project/no-invalid-hierarchy
26
29
  await import("@/project2/file2");