Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
58 changes: 57 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
101 changes: 101 additions & 0 deletions test/typescript-peer.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});