diff --git a/index.js b/index.js index 404ed32..7a8dbc6 100644 --- a/index.js +++ b/index.js @@ -69,6 +69,10 @@ function getDependencies(config) { dependencies = precinct.paperwork(config.filename, precinctOptions); debug(`extracted ${dependencies.length} dependencies: `, dependencies); } catch(error) { + // A file that fails to parse yields no dependencies, but a missing "typescript" peer + // dependency is a setup problem: swallowing it would silently produce an empty tree + if (error.code === 'ERR_TYPESCRIPT_UNAVAILABLE') throw error; + debug(`error getting dependencies: ${error.message}`); debug(error.stack); return []; diff --git a/lib/config.js b/lib/config.js index 057ac45..029f170 100644 --- a/lib/config.js +++ b/lib/config.js @@ -7,6 +7,62 @@ const require = createRequire(import.meta.url); const debug = debuglog('tree'); +// Tagged so callers can tell an unusable peer dependency apart from a file that failed to parse +const typeScriptUnavailableCode = 'ERR_TYPESCRIPT_UNAVAILABLE'; + +/** + * Loads the optional `typescript` peer dependency. No version is inspected: the config-parsing + * API is looked for instead, so a peer dependency that no longer carries it reports itself here + * rather than as an undefined property in the middle of parsing. + * + * @return {Object} + */ +function loadTypeScript() { + let ts; + + try { + ts = require('typescript'); + } catch(error) { + debug(`could not load the typescript peer dependency: ${error.message}`); + throw typeScriptUnavailable(error); + } + + if (typeof ts.readJsonConfigFile !== 'function') { + throw typeScriptUnavailable(new TypeError('ts.readJsonConfigFile is not a function')); + } + + return ts; +} + +/** + * @param {Error} error - What using the peer dependency threw + * @return {Error} + */ +function typeScriptUnavailable(error) { + const version = installedTypeScriptVersion(); + + if (!version) { + const message = 'Parsing a tsConfig path requires the "typescript" peer dependency, which is not installed. ' + + 'Run `npm install typescript`.'; + return Object.assign(new Error(message), { code: typeScriptUnavailableCode }); + } + + const message = `The installed typescript@${version} does not provide the config-parsing API dependency-tree uses: ${error.message}`; + + return Object.assign(new Error(message, { cause: error }), { code: typeScriptUnavailableCode }); +} + +/** + * @return {string | undefined} + */ +function installedTypeScriptVersion() { + try { + return require('typescript').version; + } catch { + return undefined; + } +} + /** * @typedef {object} ConfigOptions * @property {string} [filename] - Entry module path @@ -53,7 +109,7 @@ export default class Config { if (typeof this.tsConfig === 'string') { // Pre-parse once so all recursive clones share the object form debug('preparsing the ts config into an object for performance'); - const ts = require('typescript'); + const ts = loadTypeScript(); const tsParsedConfig = ts.readJsonConfigFile(this.tsConfig, ts.sys.readFile); const obj = ts.parseJsonSourceFileConfigFileContent(tsParsedConfig, ts.sys, path.dirname(this.tsConfig)); this.tsConfigPath ||= this.tsConfig; diff --git a/package-lock.json b/package-lock.json index dd2c170..9f1b68d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,7 @@ "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", "filing-cabinet": "^6.0.0", - "precinct": "^13.0.1", - "typescript": "^6.0.3" + "precinct": "^13.0.1" }, "bin": { "dependency-tree": "bin/cli.js" @@ -26,6 +25,14 @@ }, "engines": { "node": ">=20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "typescript": "^5.4.4 || ^6.0.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@babel/code-frame": { @@ -8126,6 +8133,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 19db905..b302ea7 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,15 @@ "@discoveryjs/json-ext": "^1.1.0", "commander": "^14.0.3", "filing-cabinet": "^6.0.0", - "precinct": "^13.0.1", - "typescript": "^6.0.3" + "precinct": "^13.0.1" + }, + "peerDependencies": { + "typescript": "^5.4.4 || ^6.0.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "devDependencies": { "@vitest/coverage-v8": "^4.1.10", diff --git a/test/typescript-peer.test.js b/test/typescript-peer.test.js new file mode 100644 index 0000000..0b32a07 --- /dev/null +++ b/test/typescript-peer.test.js @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { + describe, + it, + expect, + vi +} from 'vitest'; +import precinct from 'precinct'; +import dependencyTree from '../index.js'; +import { fixtures, testDir } from './helpers.js'; + +const rootDir = path.dirname(testDir); +const directory = fixtures('ts'); +const filename = path.join(directory, 'a.ts'); +const tsConfigPath = fixtures('ts', '.tsconfig'); + +/** + * Builds a dependency tree in a child process, replacing `typescript` only after the library + * has been imported so unrelated eager imports are unaffected. The replacement either hides + * the module or carries a version but none of the config-parsing API. + * + * @param {{ hideTypeScript?: boolean, typeScriptVersion?: string }} options + * @returns {string} + */ +function buildTreeInChildProcess(options) { + const stub = options.hideTypeScript ? + `if (request === 'typescript') { + throw Object.assign(new Error("Cannot find module 'typescript'"), { code: 'MODULE_NOT_FOUND' }); + }` : + `if (request === 'typescript') return { version: '${options.typeScriptVersion}' };`; + + const source = ` + import Module from 'node:module'; + + const { default: dependencyTree } = await import('./index.js'); + + const originalLoad = Module._load; + Module._load = function(request, ...rest) { + ${stub} + + return originalLoad.call(this, request, ...rest); + }; + + try { + dependencyTree.toList({ + filename: ${JSON.stringify(filename)}, + directory: ${JSON.stringify(directory)}, + tsConfig: ${JSON.stringify(tsConfigPath)} + }); + console.log('no-error'); + } catch (error) { + console.log(error.code + ' | ' + error.message); + } + `; + + return execFileSync(process.execPath, ['--input-type=module', '--eval', source], { + cwd: rootDir, + encoding: 'utf8' + }).trim(); +} + +describe('typescript peer dependency', () => { + it('reports a missing typescript when parsing a tsConfig path', () => { + const output = buildTreeInChildProcess({ hideTypeScript: true }); + + expect(output).toContain('ERR_TYPESCRIPT_UNAVAILABLE'); + expect(output).toContain('"typescript" peer dependency'); + }); + + it('reports a typescript without the config-parsing API', () => { + const output = buildTreeInChildProcess({ typeScriptVersion: '7.0.2' }); + + expect(output).toContain('ERR_TYPESCRIPT_UNAVAILABLE'); + expect(output).toContain('typescript@7.0.2 does not provide the config-parsing API'); + expect(output).toContain('readJsonConfigFile'); + }); + + it('surfaces an unavailable typescript reported by precinct', () => { + const spy = vi.spyOn(precinct, 'paperwork').mockImplementation(() => { + throw Object.assign(new Error('detective-typescript requires the "typescript" peer dependency'), { + code: 'ERR_TYPESCRIPT_UNAVAILABLE' + }); + }); + + expect(() => dependencyTree.toList({ filename, directory })).toThrow(/"typescript" peer dependency/); + + spy.mockRestore(); + }); + + it('still swallows unrelated parse failures', () => { + const spy = vi.spyOn(precinct, 'paperwork').mockImplementation(() => { + throw new Error('unexpected token'); + }); + + expect(dependencyTree.toList({ filename, directory })).toStrictEqual([filename]); + + spy.mockRestore(); + }); +});