Skip to content

Commit

Permalink
fix(rules): no-missing-exports supports arrays
Browse files Browse the repository at this point in the history
It seems like if a subpath export is an array, Node uses only the first item of that array and ignores everything else.
  • Loading branch information
boneskull committed Aug 24, 2023
1 parent ef4ef53 commit 6dc69c2
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 30 deletions.
85 changes: 57 additions & 28 deletions src/rules/builtin/no-missing-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const CONDITIONAL_EXPORT_REQUIRE = 'require';
const CONDITIONAL_EXPORT_IMPORT = 'import';
const CONDITIONAL_EXPORT_TYPES = 'types';

type ExportsField =
| string
| string[]
| Record<
string,
string | null | string[] | Record<string, string | null | string[]>
>;

function isESMPkg(pkgJson: PackageJson) {
return 'type' in pkgJson && pkgJson.type === 'module';
}
Expand Down Expand Up @@ -138,7 +146,10 @@ const noMissingExports = createRule({
* `RuleFailure` if the `default` key is _not_ the last key in the object
* @param exports Conditional or subpath exports
*/
const checkOrder = (exports: Record<string, string | null>) => {
const checkOrder = (exports: ExportsField) => {
if (!exports || typeof exports === 'string' || Array.isArray(exports)) {
return;
}
if (opts?.order && CONDITIONAL_EXPORT_DEFAULT in exports) {
const keys = Object.keys(exports);
if (keys[keys.length - 1] !== CONDITIONAL_EXPORT_DEFAULT) {
Expand All @@ -151,49 +162,67 @@ const noMissingExports = createRule({
}
};

const exports = pkgJson[EXPORTS_FIELD] as
| string
| null
| Record<string, string | null>;
const exports = pkgJson[EXPORTS_FIELD] as ExportsField;

if (exports === null) {
return [fail(`"${EXPORTS_FIELD}" field canot be null`)];
}

// yeah yeah
let result:
| (CheckFailure | undefined | (CheckFailure | undefined)[])[]
| CheckFailure
| (
| CheckFailure
| undefined
| (CheckFailure | undefined)[]
| (CheckFailure | undefined | (CheckFailure | undefined)[])[]
)[]
| undefined;

if (typeof exports === 'string') {
result = await checkFile(exports);
result = [await checkFile(exports)];
} else if (Array.isArray(exports)) {
result = await Promise.all(
exports.map(async (relativePath) => checkFile(relativePath)),
);
} else {
result =
checkOrder(exports) ??
(await Promise.all(
Object.entries(exports).map(([name, relativePath]) => {
// most certainly an object
if (relativePath && typeof relativePath === 'object') {
return (
checkOrder(relativePath) ??
Promise.all(
Object.entries(relativePath).map(
([deepName, relativePath]) => {
// don't think this can be an object, but might be wrong!
return checkFile(
relativePath as string | null,
deepName,
`${name} » ${deepName}`,
);
},
),
)
Object.entries(exports).map(async ([topKey, topValue]) => {
if (topValue === null) {
return undefined;
}
if (typeof topValue === 'string') {
return checkFile(topValue, topKey);
}
if (Array.isArray(topValue)) {
if (topKey.startsWith('.')) {
return fail(
`Subpath export "${topKey}" should be a string instead of an array`,
);
}
return Promise.all(
topValue.map(async (file) => checkFile(file, topKey)),
);
}

// string or null
return checkFile(relativePath, name);
return (
checkOrder(topValue) ??
Promise.all(
Object.entries(topValue).map(async ([key, value]) => {
if (typeof value === 'string') {
return checkFile(value, key, `${topKey} » ${key}`);
}
if (Array.isArray(value)) {
return Promise.all(
value.map(async (file) =>
checkFile(file, key, `${topKey} » ${key}`),
),
);
}
return undefined;
}),
)
);
}),
));
}
Expand Down
1 change: 1 addition & 0 deletions test/e2e/rules/fixture/no-missing-exports-array/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
9 changes: 9 additions & 0 deletions test/e2e/rules/fixture/no-missing-exports-array/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "no-missing-exports-array",
"version": "1.0.0",
"exports": [
"./index.js",
"./index-missing.js"
],
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "no-missing-exports-conditional-array",
"version": "1.0.0",
"exports": {
"default": [
"./index.js",
"./index-missing.js"
]
},
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = 2;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "no-missing-exports-subpath",
"version": "1.0.0",
"exports": {
".": [
"./index.js",
"./another-index.cjs"
]
},
"type": "module"
}
81 changes: 79 additions & 2 deletions test/e2e/rules/no-missing-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('midnight-smoker', function () {
});
});

describe('when the package contains subpath "exports"', function () {
describe('when the package contains subpath "exports" field', function () {
describe('when a file is missing', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest('no-missing-exports-subpath'));
Expand All @@ -109,6 +109,32 @@ describe('midnight-smoker', function () {
},
);
});

describe('when the value is an array', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest(
'no-missing-exports-subpath-array',
));
});

it('should return a RuleFailure', async function () {
await expect(
applyRules(ruleConfig, pkg, ruleCont),
'to be fulfilled with value satisfying',
{
passed: expect.it('to be empty'),
failed: [
{
rule: noMissingExports.toJSON(),
message:
'Subpath export "." should be a string instead of an array',
failed: true,
},
],
},
);
});
});
});

describe('when no files are missing', function () {
Expand Down Expand Up @@ -195,7 +221,7 @@ describe('midnight-smoker', function () {
});
});

describe('when the package contains conditional "exports"', function () {
describe('when the package contains conditional "exports" field', function () {
describe('when a file is missing', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest(
Expand All @@ -222,6 +248,32 @@ describe('midnight-smoker', function () {
});
});

describe('when the value is an array', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest(
'no-missing-exports-conditional-array',
));
});

it('should return a RuleFailure', async function () {
await expect(
applyRules(ruleConfig, pkg, ruleCont),
'to be fulfilled with value satisfying',
{
passed: expect.it('to be empty'),
failed: [
{
rule: noMissingExports.toJSON(),
message:
'Export "default" unreadable at path: ./index-missing.js',
failed: true,
},
],
},
);
});
});

describe('when no files are missing', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest(
Expand Down Expand Up @@ -363,6 +415,31 @@ describe('midnight-smoker', function () {
});
});
});

describe('when the package contains an array "exports" field', function () {
describe('when a file is missing', function () {
beforeEach(function () {
({ruleConfig, pkg} = setupRuleTest('no-missing-exports-array'));
});

it('should return a RuleFailure', async function () {
await expect(
applyRules(ruleConfig, pkg, ruleCont),
'to be fulfilled with value satisfying',
{
passed: expect.it('to be empty'),
failed: [
{
rule: noMissingExports.toJSON(),
message: 'Export unreadable at path: ./index-missing.js',
failed: true,
},
],
},
);
});
});
});
});
});
});

0 comments on commit 6dc69c2

Please # to comment.