diff --git a/docs/rules/no-sync.md b/docs/rules/no-sync.md index 38dea638..86114630 100644 --- a/docs/rules/no-sync.md +++ b/docs/rules/no-sync.md @@ -79,6 +79,13 @@ Examples of **correct** code for this rule with the `{ ignores: ['readFileSync'] fs.readFileSync(somePath); ``` +> [!WARNING] +> Advanced `ignores` options (object specifiers) require TypeScript and the [`ts-declaration-location`](https://www.npmjs.com/package/ts-declaration-location) package. This package is an **optional peer dependency** for the `n/no-sync` rule. If you want to use advanced TypeScript-based ignores, please install it in your project: +> +> ```sh +> npm install --save-dev ts-declaration-location +> ``` + ##### Advanced (TypeScript only) You can provide a list of specifiers to ignore. Specifiers are typed as follows: @@ -102,6 +109,9 @@ type Specifier = } ``` +> [!NOTE] +> To use advanced TypeScript-based ignores, you must have `ts-declaration-location` installed as a dependency in your project. + ###### From a file Examples of **correct** code for this rule with the ignore file specifier: diff --git a/lib/rules/no-sync.js b/lib/rules/no-sync.js index f267926a..f269374b 100644 --- a/lib/rules/no-sync.js +++ b/lib/rules/no-sync.js @@ -8,14 +8,6 @@ let typeMatchesSpecifier = /** @type {import('ts-declaration-location').default | undefined} */ (undefined) -try { - typeMatchesSpecifier = - /** @type {import('ts-declaration-location').default} */ ( - /** @type {unknown} */ (require("ts-declaration-location")) - ) - - // eslint-disable-next-line no-empty -- Deliberately left empty. -} catch {} const getTypeOfNode = require("../util/get-type-of-node") const getParserServices = require("../util/get-parser-services") const getFullTypeName = require("../util/get-full-type-name") @@ -124,6 +116,27 @@ module.exports = { const selector = options.allowAtRootLevel ? selectors.map(selector => `:function ${selector}`) : selectors + + const hasAdvancedIgnores = ignores.some( + ignore => typeof ignore !== "string" + ) + + // Only require `ts-declaration-location` if needed and not already required. + if (hasAdvancedIgnores) { + try { + typeMatchesSpecifier ||= + /** @type {import('ts-declaration-location').default} */ ( + /** @type {unknown} */ ( + require("ts-declaration-location") + ) + ) + } catch { + throw new Error( + 'ts-declaration-location not available. Rule "n/no-sync" is configured to use "ignores" option with a non-string value. This requires ts-declaration-location to be available.' + ) + } + } + return { /** * @param {import('estree').Identifier & {parent: import('estree').Node}} node @@ -160,12 +173,6 @@ module.exports = { ) } - if (typeMatchesSpecifier === undefined) { - throw new Error( - 'ts-declaration-location not available. Rule "n/no-sync" is configured to use "ignores" option with a non-string value. This requires ts-declaration-location to be available.' - ) - } - type = type === undefined ? getTypeOfNode(node, parserServices) @@ -177,6 +184,7 @@ module.exports = { : fullName if ( + typeMatchesSpecifier && typeMatchesSpecifier( parserServices.program, ignore, diff --git a/lib/util/get-full-type-name.js b/lib/util/get-full-type-name.js index 9e5ee7bf..40bbeb5c 100644 --- a/lib/util/get-full-type-name.js +++ b/lib/util/get-full-type-name.js @@ -2,7 +2,6 @@ const ts = (() => { try { - // eslint-disable-next-line n/no-unpublished-require return require("typescript") } catch { return null diff --git a/package.json b/package.json index e16364a6..218b28c3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,17 @@ "types/index.d.ts" ], "peerDependencies": { - "eslint": ">=8.23.0" + "eslint": ">=8.23.0", + "ts-declaration-location": "^1.0.6", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } }, "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", @@ -24,8 +34,7 @@ "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", - "semver": "^7.6.3", - "ts-declaration-location": "^1.0.6" + "semver": "^7.6.3" }, "devDependencies": { "@eslint/js": "^9.14.0", @@ -52,6 +61,8 @@ "punycode": "^2.3.1", "release-it": "^17.10.0", "rimraf": "^5.0.10", + "sinon": "^21.0.0", + "ts-declaration-location": "^1.0.6", "ts-ignore-import": "^4.0.1", "type-fest": "^4.26.1", "typescript": "~5.6" diff --git a/tests/lib/rules/no-sync.js b/tests/lib/rules/no-sync.js index 9fb8c33d..cb47de5f 100644 --- a/tests/lib/rules/no-sync.js +++ b/tests/lib/rules/no-sync.js @@ -5,7 +5,10 @@ "use strict" const { RuleTester, TsRuleTester } = require("#test-helpers") +const Module = require("node:module") +const assert = require("node:assert") const rule = require("../../../lib/rules/no-sync") +const sinon = require("sinon") new RuleTester().run("no-sync", rule, { valid: [ @@ -319,3 +322,162 @@ fooSync(); }, ], }) + +describe("no-sync rule with missing dependencies", () => { + const originalRequire = module.require + let mockRequire + let originalModules = {} + + /** + * Helper function to mock a module. + * - Assigns a module object to `require.cache` with the provided mock exports to prevent TypeScript errors when the module is required, as TypeScript expects the module to exist in the cache with an 'exports' property. + * + * @param {string} modulePath - The path to the module. + * @param {*} mockExports - The mock exports to use. + */ + function mockModule(modulePath, mockExports) { + const resolvedPath = require.resolve(modulePath) + + // Store original module if not already stored. + if (!originalModules[resolvedPath] && require.cache[resolvedPath]) { + originalModules[resolvedPath] = require.cache[resolvedPath] + } + + require.cache[resolvedPath] = { exports: mockExports } + } + + /** + * Helper to test rule behavior with missing dependencies. + * - Sets mocks for each dependency based on options. + * + * @param {object} options - Test options. + * @param {boolean} options.mockTsDeclarationLocation - Whether to mock `ts-declaration-location` as missing. + * @param {boolean} options.mockTypeScriptServices - Whether to mock TypeScript services as missing. + * @param {RegExp} options.expectedError - The expected error message pattern. + */ + function testWithMissingDependency(options) { + const mockModules = {} + + if (options.mockTsDeclarationLocation) { + mockModules["ts-declaration-location"] = { + throws: new Error( + "Cannot find module 'ts-declaration-location'" + ), + } + } + + // Stub for the require function. + mockRequire = sinon + .stub(Module.prototype, "require") + .callsFake(function (id) { + if (mockModules[id] && mockModules[id].throws) { + throw mockModules[id].throws + } + return originalRequire.apply(this, arguments) + }) + + if (options.mockTypeScriptServices) { + const mockGetParserServices = function () { + return null + } + + mockModule( + "../../../lib/util/get-parser-services", + mockGetParserServices + ) + } + + // Directly create and test the rule. + const rule = require("../../../lib/rules/no-sync") + + // Context with required fields to satisfy TypeScript parser requirements. + const context = { + options: [ + { + ignores: [ + { + from: "file", + }, + ], + }, + ], + } + + // Node that triggers the rule. + const node = { + name: "fooSync", + } + + // Test if the rule throws the expected error. + let errorThrown = false + try { + const ruleListener = rule.create(context) + const selectors = Object.keys(ruleListener) + + for (const selector of selectors) { + try { + if (typeof ruleListener[selector] === "function") { + ruleListener[selector](node) + } + } catch (e) { + if (e.message.match(options.expectedError)) { + errorThrown = true + break + } + } + } + } catch (e) { + if (e.message.match(options.expectedError)) { + errorThrown = true + } else { + throw e + } + } + + assert.ok( + errorThrown, + `Expected error matching ${options.expectedError} was not thrown` + ) + } + + beforeEach(() => { + delete require.cache[require.resolve("../../../lib/rules/no-sync")] + delete require.cache[ + require.resolve("../../../lib/util/get-parser-services") + ] + }) + + afterEach(() => { + if (mockRequire && typeof mockRequire.restore === "function") { + mockRequire.restore() + } + + sinon.restore() + module.require = originalRequire + }) + + it("should throw if `ts-declaration-location` is not installed", function () { + testWithMissingDependency({ + mockTsDeclarationLocation: true, + mockTypeScriptServices: false, + expectedError: /ts-declaration-location not available/, + }) + }) + + it("should throw if TypeScript parser services are not available", function () { + testWithMissingDependency({ + mockTsDeclarationLocation: false, + mockTypeScriptServices: true, + expectedError: /TypeScript parser services not available/, + }) + }) + + it("should throw if both `ts-declaration-location` and `typescript` are not available", function () { + testWithMissingDependency({ + mockTsDeclarationLocation: true, + mockTypeScriptServices: true, + expectedError: + /TypeScript parser services not available|ts-declaration-location not available/, + }) + }) +})