Skip to content

Commit 653e05c

Browse files
committed
new rule exports-valid
1 parent 00fac18 commit 653e05c

File tree

2 files changed

+360
-0
lines changed

2 files changed

+360
-0
lines changed

src/rules/exports-valid.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
const isPlainObj = require('is-plain-obj');
2+
const LintIssue = require('../LintIssue');
3+
const {exists} = require('../validators/property');
4+
5+
const lintId = 'exports-valid';
6+
const nodeName = 'exports';
7+
const ruleType = 'standard';
8+
9+
const isValidPathKey = (key) => key.startsWith('.') || key.startsWith('./');
10+
11+
const isValidPath = (value) => value.startsWith('./');
12+
13+
const validateFallbacks = (fallbacks) => {
14+
if (fallbacks.length === 0) return 'empty fallback array';
15+
16+
let hasValidPath;
17+
let hasInvalidPath;
18+
19+
for (let i = 0; i < fallbacks.length; i += 1) {
20+
const cur = fallbacks[i];
21+
22+
if (typeof cur !== 'string') {
23+
return 'fallback array must have only strings';
24+
}
25+
26+
if (i + 1 === fallbacks.length) {
27+
if (isValidPath(cur)) {
28+
if (hasInvalidPath) {
29+
return true;
30+
}
31+
32+
return `fallback array path \`${cur}\` must follow invalid value`;
33+
}
34+
35+
if (hasValidPath) {
36+
return true;
37+
}
38+
39+
return `fallback array value \`${cur}\` must be followed by valid path`;
40+
}
41+
42+
if (isValidPath(cur)) {
43+
if (hasValidPath) {
44+
return `fallback path ${cur} follows an already valid path`;
45+
}
46+
47+
hasValidPath = true;
48+
} else {
49+
hasInvalidPath = true;
50+
}
51+
}
52+
53+
return true;
54+
};
55+
56+
const lint = (packageJsonData, severity, config = {conditions: []}) => {
57+
const conditions = [...(config.conditions || []), 'default'];
58+
59+
if (!exists(packageJsonData, nodeName)) return true;
60+
61+
const issue = (message) => new LintIssue(lintId, severity, nodeName, message);
62+
63+
// eslint-disable-next-line complexity,max-statements
64+
const traverse = (parentKey, parentType, exports) => {
65+
const invalidPathMessage = (invalidPath) => `invalid path \`${invalidPath}\`. Paths must start with \`./\``;
66+
67+
if (typeof exports === 'string') {
68+
// https://nodejs.org/api/esm.html#esm_exports_sugar
69+
return isValidPath(exports) ? true : issue(invalidPathMessage(exports));
70+
}
71+
72+
if (Array.isArray(exports)) {
73+
// https://nodejs.org/api/esm.html#esm_package_exports_fallbacks
74+
// eslint-disable-next-line no-restricted-syntax
75+
const result = validateFallbacks(exports);
76+
77+
return typeof result === 'string' ? issue(result) : true;
78+
}
79+
80+
if (!isPlainObj(exports)) {
81+
return issue(`unexpected ${typeof exports}`);
82+
}
83+
84+
// either a paths object or a conditions object
85+
let objectType;
86+
87+
// eslint-disable-next-line no-restricted-syntax
88+
for (const [key, value] of Object.entries(exports)) {
89+
if (isValidPathKey(key)) {
90+
if (objectType === 'conditions') {
91+
return issue(`found path key \`${key}\` in a conditions object`);
92+
}
93+
94+
if (parentType === 'paths') {
95+
return issue(`key \`${parentKey}\` has paths object vaule but only conditions may be nested`);
96+
}
97+
98+
objectType = 'paths';
99+
100+
const result = traverse(key, objectType, value);
101+
102+
if (result !== true) return result;
103+
} else if (conditions.includes(key)) {
104+
if (objectType === 'paths') {
105+
return issue(`found condition key \`${key}\` in a paths object`);
106+
}
107+
108+
objectType = 'conditions';
109+
const result = traverse(key, objectType, value);
110+
111+
if (result !== true) return result;
112+
} else {
113+
return issue(`unsupported condition \`${key}\`. Supported conditions are \`${conditions}\``);
114+
}
115+
}
116+
117+
return true;
118+
};
119+
120+
return traverse(nodeName, 'root', packageJsonData[nodeName]);
121+
};
122+
123+
module.exports = {
124+
lint,
125+
ruleType,
126+
};

test/unit/rules/exports-valid.test.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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 invalids = [
14+
{
15+
title: 'root is `true`',
16+
input: true,
17+
message: 'unexpected `boolean`',
18+
},
19+
{
20+
title: 'root is a number',
21+
input: 4,
22+
message: 'unexpected `number`',
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+
title: 'path key in conditions object',
67+
config: {conditions: ['foo']},
68+
input: {foo: './foo.js', './a': './a.js'},
69+
message: 'found path key `./a` in a conditions object',
70+
},
71+
{
72+
title: 'condition key in paths object',
73+
config: {conditions: ['foo']},
74+
input: {'./a': './a.js', foo: './foo.js'},
75+
message: 'found condition key `foo` in a paths object',
76+
},
77+
{
78+
title: '`default` condition not last',
79+
config: {conditions: ['foo']},
80+
input: {default: './a.js', foo: './b.js'},
81+
message: 'condition `default` must be the last key',
82+
},
83+
{
84+
title: 'two valid values in fallback array',
85+
input: {'./a': ['invalid', './a.js', './b.js']},
86+
message: 'fallback path `./b.js` follows an already valid path',
87+
},
88+
{
89+
title: 'empty fallback array',
90+
input: {'./a': []},
91+
message: 'empty fallback array',
92+
},
93+
{
94+
title: 'no invalid value in fallback array',
95+
input: {'./a': ['./a.js']},
96+
message: 'fallback array path `./a.js` must follow invalid value',
97+
},
98+
{
99+
title: 'no valid value in fallback array',
100+
input: {'./a': ['invalid-a', 'invalid-b']},
101+
message: 'fallback array value `invalid-b` must be followed by valid path',
102+
},
103+
{
104+
title: 'empty fallback array',
105+
input: {'./a': []},
106+
message: 'empty fallback array',
107+
},
108+
{
109+
title: 'conditions in fallback array',
110+
input: {'./a': ['invalid-a', {node: './node.js'}, './a.js']},
111+
message: 'fallback array must have only strings',
112+
},
113+
{
114+
title: 'nested fallback array',
115+
input: {'./a': ['invalid-a', ['invalid', './b.js'], './a.js']},
116+
message: 'fallback array must have only strings',
117+
},
118+
{
119+
title: 'nested paths object',
120+
input: {'./a': {'./b': './b.js'}},
121+
message: 'key `./a` has paths object vaule but only conditions may be nested',
122+
},
123+
];
124+
invalids.forEach(({title, config, input, message}) => {
125+
// eslint-disable-next-line jest/valid-title
126+
test(title, () => {
127+
if (title === 'two valid values in fallback array') {
128+
debugger
129+
}
130+
const response = lint({exports: input}, 'error', config);
131+
132+
expect(response).not.toStrictEqual(true);
133+
expect(response.lintId).toStrictEqual('exports-valid');
134+
expect(response.severity).toStrictEqual('error');
135+
expect(response.node).toStrictEqual('exports');
136+
expect(response.lintMessage).toStrictEqual(message);
137+
});
138+
});
139+
});
140+
141+
describe('when package.json has valid node', () => {
142+
const valids = [
143+
{
144+
title: 'empty exports',
145+
input: {},
146+
},
147+
{
148+
title: 'a valid key',
149+
input: {'./a': './a.js'},
150+
},
151+
{
152+
title: 'multiple valid keys',
153+
input: {'./a': './a.js', './b': './b.js'},
154+
},
155+
{
156+
title: 'a valid key with slashes',
157+
input: {'./a/b': './a/b.js'},
158+
},
159+
{
160+
title: 'a valid key with file extension',
161+
input: {'./a.js': './a.js'},
162+
},
163+
{
164+
title: 'main-only sugar',
165+
input: './main.js',
166+
},
167+
{
168+
title: 'a valid path',
169+
input: {'./a': './a.js'},
170+
},
171+
{
172+
title: 'a valid path in sub-directory',
173+
input: {'./a': './a/b.js'},
174+
},
175+
{
176+
title: 'supported condition',
177+
config: {conditions: ['foo']},
178+
input: {foo: './main.js'},
179+
},
180+
{
181+
title: 'multiple supported conditions',
182+
config: {conditions: ['foo', 'bar']},
183+
input: {foo: './main.js', bar: './bar.js'},
184+
},
185+
{
186+
title: 'default condition',
187+
config: {conditions: ['a', 'default']},
188+
input: {a: './main.js', default: './bar.js'},
189+
},
190+
{
191+
title: 'folder mapping',
192+
input: {'./': './a/'},
193+
},
194+
{
195+
title: 'sub-folder mapping',
196+
input: {'./a/': './a/b/'},
197+
},
198+
{
199+
title: 'fallback array',
200+
input: {'./a': ['invalid', './a.js']},
201+
},
202+
{
203+
title: 'fallback array with two invalids',
204+
input: {'./a': ['invalid-a', 'invalid-b', './a.js']},
205+
},
206+
{
207+
title: 'conditions under path',
208+
config: {conditions: ['node']},
209+
input: {'./a': {node: './node.js', default: './a.js'}},
210+
},
211+
{
212+
title: 'nested conditions under path',
213+
config: {conditions: ['node', 'import', 'require']},
214+
input: {'./a': {node: {import: './node.mjs', require: './node.cjs'}, default: './a.js'}},
215+
},
216+
];
217+
valids.forEach(({title, input, config}) => {
218+
// eslint-disable-next-line jest/valid-title
219+
test(title, () => {
220+
const response = lint({exports: input}, 'error', config);
221+
expect(response).toBe(true);
222+
});
223+
});
224+
});
225+
226+
describe('when package.json does not have node', () => {
227+
test('true should be returned', () => {
228+
const packageJsonData = {};
229+
const response = lint(packageJsonData, 'error');
230+
231+
expect(response).toBe(true);
232+
});
233+
});
234+
});

0 commit comments

Comments
 (0)