Skip to content

Commit

Permalink
Support package.json subpath imports
Browse files Browse the repository at this point in the history
If you specify an "imports" field in package.json it is used to resolved imports starting with "#" in the code.

For more details, see https://nodejs.org/api/packages.html#subpath-imports
  • Loading branch information
dobesv committed Sep 14, 2023
1 parent 23b78f0 commit af34e35
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 15 deletions.
131 changes: 116 additions & 15 deletions src/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,90 @@ function detect(detectors, node, deps) {
.value();
}

// Apply imports map from package.json to a discovered dependency. If the
// dependency starts with '#' and we have a matching entry in "imports" in
// the package.json we'll substitute the possible mapped imports in place of
// that dependency.
//
// Conditions can be well-known ones implemented by node, TypeScript, or webpack like
// "import", "browser", "types", or "require". They can also be custom ones as configurable
// in webpack configuration or using the enhanced-resolve package.
//
// See also:
// - https://nodejs.org/api/packages.html#subpath-imports
// - https://www.typescriptlang.org/docs/handbook/esm-node.html
// - https://webpack.js.org/configuration/resolve/#resolveconditionnames
function applyImportsMap(importsMap, dep) {
const resolvedDeps = [];

function accumulateDeps(v, wildcardMatch) {
if (v) {
if (typeof v === 'string') {
resolvedDeps.push(
wildcardMatch && v.includes('*')
? v.replaceAll('*', wildcardMatch)
: v,
);
} else if (typeof v === 'object') {
Object.values(v).forEach((vv) => accumulateDeps(vv, wildcardMatch));
}
}
}

// Match input against the path pattern; if it matches, and there was a wildcard in the patten,
// return the part of the input that matched the wildcard. If there was no wildcard, return
// an empty string.
function matchPathPattern(pattern, input) {
if (pattern.includes('*')) {
const escapedPattern = pattern.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const regexPattern = escapedPattern.replace(/\\\*/g, '.*');
debug('depcheck:applyImportsMap:matchPathPattern:regexPattern')(
pattern,
input,
regexPattern,
);
const regex = new RegExp(`^${regexPattern}$`);
const match = input.match(regex);
if (match) {
return match[0];
}
} else if (pattern === input) {
return '';
}
return null;
}

if (dep.startsWith('#')) {
Object.entries(importsMap).forEach((m) => {
const match = matchPathPattern(m[0], dep);
if (match !== null) {
accumulateDeps(m[1], match);
}
});
if (resolvedDeps.length) {
debug('depcheck:applyImportsMap:resolved')(dep, resolvedDeps);
return resolvedDeps;
}
debug('depcheck:applyImportsMap:unresolved')(dep);
}
return [dep];
}

function discoverPropertyDep(rootDir, deps, property, depName) {
const { metadata } = loadModuleData(depName, rootDir);
if (!metadata) return [];
const propertyDeps = Object.keys(metadata[property] || {});
return lodash.intersection(deps, propertyDeps);
}

async function getDependencies(dir, filename, deps, parser, detectors) {
async function getDependencies({
deps,
dir,
filename,
detectors,
importsMap,
parser,
}) {
const result = await parser(filename, deps, dir);

// when parser returns string array, skip detector step and treat them as dependencies.
Expand All @@ -58,6 +134,7 @@ async function getDependencies(dir, filename, deps, parser, detectors) {
.map((node) => detect(detectors, node, deps))
.flatten()
.uniq()
.flatMap(lodash.partial(applyImportsMap, importsMap))
.map(requirePackageName)
.thru((_dependencies) =>
parser === availableParsers.typescript
Expand Down Expand Up @@ -94,7 +171,7 @@ async function getDependencies(dir, filename, deps, parser, detectors) {
.value();
}

function checkFile(dir, filename, deps, parsers, detectors) {
function checkFile({ deps, detectors, dir, filename, importsMap, parsers }) {
debug('depcheck:checkFile')(filename);

const targets = lodash(parsers)
Expand All @@ -105,7 +182,14 @@ function checkFile(dir, filename, deps, parsers, detectors) {
.value();

return targets.map((parser) =>
getDependencies(dir, filename, deps, parser, detectors).then(
getDependencies({
deps,
detectors,
dir,
filename,
importsMap,
parser,
}).then(
(using) => {
if (using.length) {
debug('depcheck:checkFile:using')(filename, parser, using);
Expand All @@ -128,7 +212,15 @@ function checkFile(dir, filename, deps, parsers, detectors) {
);
}

function checkDirectory(dir, rootDir, ignorer, deps, parsers, detectors) {
function checkDirectory({
dir,
rootDir,
ignorer,
importsMap,
deps,
parsers,
detectors,
}) {
debug('depcheck:checkDirectory')(dir);

return new Promise((resolve) => {
Expand All @@ -142,7 +234,14 @@ function checkDirectory(dir, rootDir, ignorer, deps, parsers, detectors) {

finder.on('data', (entry) => {
promises.push(
...checkFile(rootDir, entry.fullPath, deps, parsers, detectors),
...checkFile({
deps,
detectors,
dir: rootDir,
filename: entry.fullPath,
importsMap,
parsers,
}),
);
});

Expand Down Expand Up @@ -182,14 +281,14 @@ function checkDirectory(dir, rootDir, ignorer, deps, parsers, detectors) {
});
}

function buildResult(
function buildResult({
result,
deps,
devDeps,
peerDeps,
optionalDeps,
skipMissing,
) {
}) {
const usingDepsLookup = lodash(result.using)
// { f1:[d1,d2,d3], f2:[d2,d3,d4] }
.toPairs()
Expand Down Expand Up @@ -233,26 +332,28 @@ function buildResult(
};
}

export default function check({
export default ({
rootDir,
ignorer,
importsMap,
skipMissing,
deps,
devDeps,
peerDeps,
optionalDeps,
parsers,
detectors,
}) {
}) => {
const allDeps = lodash.union(deps, devDeps);
return checkDirectory(
rootDir,
return checkDirectory({
dir: rootDir,
rootDir,
ignorer,
allDeps,
importsMap,
deps: allDeps,
parsers,
detectors,
).then((result) =>
buildResult(result, deps, devDeps, peerDeps, optionalDeps, skipMissing),
}).then((result) =>
buildResult({ result, deps, devDeps, peerDeps, optionalDeps, skipMissing }),
);
}
};
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ export default function depcheck(rootDir, options, callback) {
ignoreMatches,
devDependencies,
);
const importsMap = metadata.imports || {};

const ignorer = getIgnorer({ rootDir, ignorePath, ignorePatterns });

return check({
rootDir,
ignorer,
importsMap,
skipMissing,
deps,
devDeps,
Expand Down
11 changes: 11 additions & 0 deletions test/fake_modules/package_import_map/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable no-unused-vars */

// Import some of the modules specified in package.json "imports" field so those are
// considered used by depcheck

import '#simple';
import '#subpath';
import '#conditional';
import '#multi-conditional';
import '#missing';
import '#wildcard/foo';
33 changes: 33 additions & 0 deletions test/fake_modules/package_import_map/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"imports": {
"#simple": "simple-resolved",
"#subpath": "subpath-resolved/subpath",
"#wildcard/*": "wildcard-resolved/*",
"#conditional": {
"node": "conditional-node",
"types": "@types/conditional"
},
"#multi-conditional": {
"node": {
"test": "conditional-node-test"
}
},
"#simple-unused": "unused",
"#subpath-unused": "subpath-resolved-unused/subpath",
"#conditional-unused": {
"node": "conditional-node-unused"
},
"#missing": "missing-dep"
},
"dependencies": {
"simple-resolved": "0.0.1",
"subpath-resolved": "0.0.1",
"conditional-node": "0.0.1",
"@types/conditional": "0.0.1",
"conditional-node-test": "0.0.1",
"unused": "*",
"conditional-node-unused": "*",
"subpath-resolved-unused": "*",
"wildcard-resolved": "*"
}
}
22 changes: 22 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,26 @@ describe('depcheck', () => {
unused.missing.should.deepEqual({});
Object.keys(unused.using).should.deepEqual(['find-me', 'find-me2']);
}));

it('should resolve mapped imports from package.json imports', () =>
check('package_import_map', {}).then((unused) => {
unused.dependencies.should.deepEqual([
'unused',
'conditional-node-unused',
'subpath-resolved-unused',
]);
unused.devDependencies.should.deepEqual([]);
unused.missing.should.deepEqual(
resolveShortPath({ 'missing-dep': ['index.js'] }, 'package_import_map'),
);
Object.keys(unused.using).should.deepEqual([
'simple-resolved',
'subpath-resolved',
'conditional-node',
'@types/conditional',
'conditional-node-test',
'missing-dep',
'wildcard-resolved',
]);
}));
});

0 comments on commit af34e35

Please sign in to comment.