diff --git a/docs/rule-jsdoc.md b/docs/rule-jsdoc.md index 17b3e2d..78b1499 100644 --- a/docs/rule-jsdoc.md +++ b/docs/rule-jsdoc.md @@ -14,7 +14,8 @@ As a bonus feature, importing exports annotated with `@private` is always forbid "indexLoophole": true, "filenameLoophole": false, "defaultImportability": "public", // "public" | "package" | "private" - "treatSelfReferenceAs": "external" // "internal" | "external" + "treatSelfReferenceAs": "external", // "internal" | "external" + "excludeSourcePatterns": ["generated/**/*"] // Array of glob patterns for source paths to exclude }], } ``` @@ -49,6 +50,7 @@ type JSDocRuleOptions = { filenameLoophole: boolean; defaultImportability: "public" | "pacakge" | "private"; treatSelfReferenceAs: "internal" | "external"; + excludeSourcePatterns?: string[]; }; ``` @@ -186,3 +188,56 @@ In the above example, `my-package/foo` is a self reference that connects to `src When `treatSelfReferenceAs: external`, this import is always allowed even though `something` is a package-private export because it is treated like an import from an external package. When `treatSelfReferenceAs: internal`, this import is disallowed because import from `my-package/foo` is treated like an import from `src/somewhere/foo.ts`. + +### `excludeSourcePatterns` + +_Default value: `[]`_ + +An array of glob patterns for source paths to exclude from the importability check. The patterns are resolved relative to the project root. When importing from a module that matches one of these patterns, the import-access/jsdoc rule will not apply any restrictions, regardless of the JSDoc annotations or defaultImportability setting. + +This is particularly useful for handling imports from auto-generated files that don't have proper JSDoc annotations. + +The patterns use the [minimatch](https://github.com/isaacs/minimatch) library's glob syntax. + +**Examples:** + +```ts +// Example: Match by file path +// excludeSourcePatterns: ["src/types/**/*.d.ts"] +import { typeDefinition } from "../types/api"; // Allowed if the implementation is in a .d.ts file in the src/types directory +``` + +Additional examples: +```js +// Match all files in a particular directory +"src/generated/**" + +// Match multiple extensions +"**/*.{generated,auto}.{ts,js}" + +// Match files with specific naming patterns +"**/[a-z]*.auto.ts" + +// Match specific type definition files +"src/**/*.d.ts" +``` + + +**Next.js specific configuration:** + +When working with Next.js projects, it's recommended to exclude the `.next` directory which contains auto-generated files: + +```js +// In your .eslintrc.js +{ + "rules": { + "import-access/jsdoc": ["error", { + // ... other options ... + "excludeSourcePatterns": [".next/**"] + }] + } +} +``` + +This will ensure that any imports from auto-generated files in the `.next` directory are not subject to import-access restrictions. + diff --git a/package-lock.json b/package-lock.json index 208c13c..b9e96f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "dependencies": { "@typescript-eslint/utils": "^8.4.0", + "minimatch": "^10.0.1", "tsutils": "^3.21.0" }, "devDependencies": { @@ -3038,6 +3039,21 @@ } } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", @@ -6544,14 +6560,15 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index dc67be4..822b65d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "license": "MIT", "dependencies": { "@typescript-eslint/utils": "^8.4.0", + "minimatch": "^10.0.1", "tsutils": "^3.21.0" }, "devDependencies": { diff --git a/src/__tests__/exclude-patterns.ts b/src/__tests__/exclude-patterns.ts new file mode 100644 index 0000000..823b182 --- /dev/null +++ b/src/__tests__/exclude-patterns.ts @@ -0,0 +1,45 @@ +import { getESLintTester } from "./fixtures/eslint"; + +const tester = getESLintTester(); + +it("Importing from generated package is disallowed by default", async () => { + const result = await tester.lintFile( + "src/exclude-patterns/generated-type-user.ts", + { + jsdoc: { + defaultImportability: "package", + }, + }, + ); + expect(result).toMatchInlineSnapshot(` +Array [ + Object { + "column": 10, + "endColumn": 19, + "endLine": 1, + "line": 1, + "message": "Cannot import a package-private export 'someValue'", + "messageId": "package", + "nodeType": "ImportSpecifier", + "ruleId": "import-access/jsdoc", + "severity": 2, + }, +] +`); +}); + +it("Importing from generated package is allowed with excludeSourcePatterns targeting the file path (relative path)", async () => { + const result = await tester.lintFile( + "src/exclude-patterns/generated-type-user.ts", + { + jsdoc: { + defaultImportability: "package", + excludeSourcePatterns: [ + // exclude the types file + "src/__tests__/fixtures/project/src/exclude-patterns/types/**", + ], + }, + }, + ); + expect(result).toMatchInlineSnapshot(`Array []`); +}); diff --git a/src/__tests__/fixtures/project/src/exclude-patterns/generated-type-user.ts b/src/__tests__/fixtures/project/src/exclude-patterns/generated-type-user.ts new file mode 100644 index 0000000..dfba777 --- /dev/null +++ b/src/__tests__/fixtures/project/src/exclude-patterns/generated-type-user.ts @@ -0,0 +1,3 @@ +import { someValue } from "generated-package"; + +console.log(someValue); diff --git a/src/__tests__/fixtures/project/src/exclude-patterns/types/types.d.ts b/src/__tests__/fixtures/project/src/exclude-patterns/types/types.d.ts new file mode 100644 index 0000000..a759c07 --- /dev/null +++ b/src/__tests__/fixtures/project/src/exclude-patterns/types/types.d.ts @@ -0,0 +1,3 @@ +declare module "generated-package" { + export const someValue: string; +} diff --git a/src/core/checkSymbolmportability.ts b/src/core/checkSymbolmportability.ts index 0e94c3c..61f51fc 100644 --- a/src/core/checkSymbolmportability.ts +++ b/src/core/checkSymbolmportability.ts @@ -1,3 +1,5 @@ +import { minimatch } from "minimatch"; +import path from "path"; import { Program, Symbol } from "typescript"; import { assertNever } from "../utils/assertNever"; import { concatArrays } from "../utils/concatArrays"; @@ -29,6 +31,25 @@ export function checkSymbolImportability( return; } + // Get the actual file name of the exported declaration + const exporterFilename = decl.getSourceFile().fileName; + + // Check if moduleSpecifier or exporter file path matches any of the excludeSourcePatterns + if (packageOptions.excludeSourcePatterns?.length) { + for (const pattern of packageOptions.excludeSourcePatterns) { + // Check actual file path + // Get relative path from the project root + const projectPath = program.getCurrentDirectory(); + const relativePath = path.relative(projectPath, exporterFilename); + + // Check if the file path matches the pattern + if (minimatch(relativePath, pattern, { dot: true })) { + // Skip importability check for this source + return; + } + } + } + // If declaration is from external module, treat as importable if (program.isSourceFileFromExternalLibrary(decl.getSourceFile())) { return; diff --git a/src/rules/jsdoc.ts b/src/rules/jsdoc.ts index 6955f57..69286a2 100644 --- a/src/rules/jsdoc.ts +++ b/src/rules/jsdoc.ts @@ -30,6 +30,11 @@ export type JSDocRuleOptions = { * the importability check. */ treatSelfReferenceAs: "internal" | "external"; + /** + * Array of glob patterns for source paths to exclude from the importability check. + * Useful for excluding generated files or auto-generated type definitions. + */ + excludeSourcePatterns?: string[]; }; const jsdocRule: Omit< @@ -70,6 +75,12 @@ const jsdocRule: Omit< type: "string", enum: ["external", "internal"], }, + excludeSourcePatterns: { + type: "array", + items: { + type: "string", + }, + }, }, additionalProperties: false, }, @@ -81,6 +92,7 @@ const jsdocRule: Omit< filenameLoophole: false, defaultImportability: "public", treatSelfReferenceAs: "external", + excludeSourcePatterns: [], }, ], create(context) { @@ -94,6 +106,7 @@ const jsdocRule: Omit< filenameLoophole, defaultImportability, treatSelfReferenceAs, + excludeSourcePatterns, } = jsDocRuleDefaultOptions(options[0]); const packageOptions: PackageOptions = { @@ -101,6 +114,7 @@ const jsdocRule: Omit< filenameLoophole, defaultImportability, treatSelfReferenceAs, + excludeSourcePatterns, }; return { @@ -249,12 +263,14 @@ export function jsDocRuleDefaultOptions( filenameLoophole = false, defaultImportability = "public", treatSelfReferenceAs = "external", + excludeSourcePatterns = [], } = options || {}; return { indexLoophole, filenameLoophole, defaultImportability, treatSelfReferenceAs, + excludeSourcePatterns, }; } diff --git a/src/utils/isInPackage.ts b/src/utils/isInPackage.ts index 92def1c..2597432 100644 --- a/src/utils/isInPackage.ts +++ b/src/utils/isInPackage.ts @@ -5,6 +5,7 @@ export type PackageOptions = { readonly filenameLoophole: boolean; readonly defaultImportability: "public" | "package" | "private"; readonly treatSelfReferenceAs: "internal" | "external"; + readonly excludeSourcePatterns?: readonly string[]; }; // ../ or ../../ or ...