Skip to content

Commit 6c399a0

Browse files
committed
new rule exports-valid
1 parent 00fac18 commit 6c399a0

File tree

2 files changed

+181
-0
lines changed

2 files changed

+181
-0
lines changed

src/rules/exports-valid.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const isPlainObj = require('is-plain-obj');
2+
const {isObject, isString} = require('../validators/type');
3+
const LintIssue = require('../LintIssue');
4+
const {exists} = require('../validators/property');
5+
6+
const lintId = 'exports-valid';
7+
const nodeName = 'exports';
8+
const ruleType = 'standard';
9+
10+
const getKeyType = (key) => {
11+
if (key.startsWith('/')) return 'invalid';
12+
13+
return key.startsWith('.') ? 'entry' : 'condition';
14+
};
15+
16+
const isValidPath = (value) => {
17+
if (typeof value !== 'string') return false;
18+
19+
if (['', '.', './', '..', '../'].includes(value)) return false;
20+
21+
if (value.includes('../')) return false;
22+
23+
if (value.startsWith('/')) return false;
24+
25+
return true;
26+
};
27+
28+
const traverse = (exports, path = []) => {
29+
const fails = [];
30+
31+
if (typeof exports === 'string') {
32+
if (!isValidPath(exports)) {
33+
fails.push([path, 'String value should be a valid export path']);
34+
}
35+
} else if (isPlainObj(exports)) {
36+
// eslint-disable-next-line no-restricted-syntax
37+
for (const [key, value] of Object.entries(exports)) {
38+
if (getKeyType(key) === 'invalid') {
39+
fails.push([path, 'Key should not be relative path or "condition"']);
40+
} else {
41+
fails.push(...traverse(value));
42+
}
43+
}
44+
} else {
45+
fails.push([path, 'Value should be string or object']);
46+
}
47+
48+
if (Array.isArray(exports)) {
49+
fails.push([path, 'Array']);
50+
}
51+
52+
return fails.map(([arrayOfKeys, message]) => [arrayOfKeys.join('.'), message]);
53+
};
54+
55+
const lint = (packageJsonData, severity) => {
56+
if (!exists(packageJsonData, nodeName)) return true;
57+
58+
const fails = traverse(packageJsonData[nodeName]);
59+
60+
if (fails.length > 0) {
61+
const failsListString = fails.map(([path, message]) => `\`${path}\`: ${message}`);
62+
63+
return new LintIssue(lintId, severity, nodeName, `Invalid paths: ${failsListString}`);
64+
}
65+
66+
return true;
67+
};
68+
69+
module.exports = {
70+
lint,
71+
ruleType,
72+
};
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const ruleModule = require('../../../src/rules/exports-valid');
2+
3+
const {lint, ruleType} = ruleModule;
4+
5+
describe('exports-valid Unit Tests', () => {
6+
describe('a rule type value should be exported', () => {
7+
test('it should equal "standard"', () => {
8+
expect(ruleType).toStrictEqual('standard');
9+
});
10+
});
11+
12+
describe('when package.json has invalid node', () => {
13+
const failures = [
14+
{
15+
title: 'root is `true`',
16+
input: true,
17+
message: 'Value of `exports` field should be string or object',
18+
},
19+
{
20+
title: 'root is a number',
21+
input: 4,
22+
message: 'Value of `exports` field should be string or object',
23+
},
24+
{
25+
title: 'key is `/`',
26+
input: {'/': 'foo.js'},
27+
message: 'Unsupported condition key `/`. Supported conditions are `[]`',
28+
},
29+
{
30+
title: 'key starts with `/`',
31+
input: {'/foo': 'foo.js'},
32+
message: 'Unsupported condition key `/foo`. Supported conditions are `[]`',
33+
},
34+
{
35+
title: 'key is short relative path',
36+
input: {foo: 'foo.js'},
37+
message: 'Unsupported condition key `foo`. Supported conditions are `[]`',
38+
},
39+
{
40+
title: 'main-only sugar path starts with `/`',
41+
input: '/main.js',
42+
message: 'Invalid path `/main.js`. Paths must start with `./`',
43+
},
44+
{
45+
title: 'main-only sugar path short form relative',
46+
input: 'main.js',
47+
message: 'Invalid path `main.js`. Paths must start with `./`',
48+
},
49+
{
50+
title: 'short form relative path',
51+
input: {'./a': 'a.js'},
52+
message: 'Invalid path `a.js`. Paths must start with `./`',
53+
},
54+
{
55+
title: 'unsupported condition',
56+
config: {conditions: ['foo']},
57+
input: {bar: './main.js'},
58+
message: "Unsupported condition `bar`. Supported conditions are `['foo']`",
59+
},
60+
{
61+
title: 'folder mapped to file',
62+
input: {'./': './a.js'},
63+
message: 'The value of the folder mapping key `./` must end with `/`',
64+
},
65+
66+
// conditional import key `node` must be after `import` and `require` if any of them exists
67+
68+
// conditional import key `default` must be last
69+
70+
// support fallbacks. at least one of the values must be valid
71+
];
72+
failures.forEach(({title, input, fails}) => {
73+
// eslint-disable-next-line jest/valid-title
74+
test(title, () => {
75+
const packageJsonData = {exports: input};
76+
const response = lint(packageJsonData, 'error');
77+
78+
expect(response.lintId).toStrictEqual('exports-valid');
79+
expect(response.severity).toStrictEqual('error');
80+
expect(response.node).toStrictEqual('exports');
81+
const failsListString = fails.map(([path, message]) => `\`${path}\`: ${message}`);
82+
expect(response.lintMessage).toStrictEqual(`Invalid paths: ${failsListString}`);
83+
});
84+
});
85+
});
86+
87+
describe('when package.json has valid node', () => {
88+
const valids = [
89+
{
90+
title: 'main-only sugar',
91+
input: './main.js',
92+
},
93+
{
94+
title: 'supported condition',
95+
config: {conditions: ['foo']},
96+
input: {foo: './main.js'},
97+
},
98+
];
99+
});
100+
101+
describe('when package.json does not have node', () => {
102+
test('true should be returned', () => {
103+
const packageJsonData = {};
104+
const response = lint(packageJsonData, 'error');
105+
106+
expect(response).toBe(true);
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)