diff --git a/src/rules/exports-valid.js b/src/rules/exports-valid.js new file mode 100755 index 000000000..65cac5933 --- /dev/null +++ b/src/rules/exports-valid.js @@ -0,0 +1,126 @@ +const isPlainObj = require('is-plain-obj'); +const LintIssue = require('../LintIssue'); +const {exists} = require('../validators/property'); + +const lintId = 'exports-valid'; +const nodeName = 'exports'; +const ruleType = 'standard'; + +const isValidPathKey = (key) => key.startsWith('.') || key.startsWith('./'); + +const isValidPath = (value) => value.startsWith('./'); + +const validateFallbacks = (fallbacks) => { + if (fallbacks.length === 0) return 'empty fallback array'; + + let hasValidPath; + let hasInvalidPath; + + for (let i = 0; i < fallbacks.length; i += 1) { + const cur = fallbacks[i]; + + if (typeof cur !== 'string') { + return 'fallback array must have only strings'; + } + + if (i + 1 === fallbacks.length) { + if (isValidPath(cur)) { + if (hasInvalidPath) { + return true; + } + + return `fallback array path \`${cur}\` must follow invalid value`; + } + + if (hasValidPath) { + return true; + } + + return `fallback array value \`${cur}\` must be followed by valid path`; + } + + if (isValidPath(cur)) { + if (hasValidPath) { + return `fallback path ${cur} follows an already valid path`; + } + + hasValidPath = true; + } else { + hasInvalidPath = true; + } + } + + return true; +}; + +const lint = (packageJsonData, severity, config = {conditions: []}) => { + const conditions = [...(config.conditions || []), 'default']; + + if (!exists(packageJsonData, nodeName)) return true; + + const issue = (message) => new LintIssue(lintId, severity, nodeName, message); + + // eslint-disable-next-line complexity,max-statements + const traverse = (parentKey, parentType, exports) => { + const invalidPathMessage = (invalidPath) => `invalid path \`${invalidPath}\`. Paths must start with \`./\``; + + if (typeof exports === 'string') { + // https://nodejs.org/api/esm.html#esm_exports_sugar + return isValidPath(exports) ? true : issue(invalidPathMessage(exports)); + } + + if (Array.isArray(exports)) { + // https://nodejs.org/api/esm.html#esm_package_exports_fallbacks + // eslint-disable-next-line no-restricted-syntax + const result = validateFallbacks(exports); + + return typeof result === 'string' ? issue(result) : true; + } + + if (!isPlainObj(exports)) { + return issue(`unexpected ${typeof exports}`); + } + + // either a paths object or a conditions object + let objectType; + + // eslint-disable-next-line no-restricted-syntax + for (const [key, value] of Object.entries(exports)) { + if (isValidPathKey(key)) { + if (objectType === 'conditions') { + return issue(`found path key \`${key}\` in a conditions object`); + } + + if (parentType === 'paths') { + return issue(`key \`${parentKey}\` has paths object vaule but only conditions may be nested`); + } + + objectType = 'paths'; + + const result = traverse(key, objectType, value); + + if (result !== true) return result; + } else if (conditions.includes(key)) { + if (objectType === 'paths') { + return issue(`found condition key \`${key}\` in a paths object`); + } + + objectType = 'conditions'; + const result = traverse(key, objectType, value); + + if (result !== true) return result; + } else { + return issue(`unsupported condition \`${key}\`. Supported conditions are \`${conditions}\``); + } + } + + return true; + }; + + return traverse(nodeName, 'root', packageJsonData[nodeName]); +}; + +module.exports = { + lint, + ruleType, +}; diff --git a/test/unit/rules/exports-valid.test.js b/test/unit/rules/exports-valid.test.js new file mode 100755 index 000000000..45e3ab93a --- /dev/null +++ b/test/unit/rules/exports-valid.test.js @@ -0,0 +1,234 @@ +const ruleModule = require('../../../src/rules/exports-valid'); + +const {lint, ruleType} = ruleModule; + +describe('exports-valid Unit Tests', () => { + describe('a rule type value should be exported', () => { + test('it should equal "standard"', () => { + expect(ruleType).toStrictEqual('standard'); + }); + }); + + describe('when package.json has invalid node', () => { + const invalids = [ + { + title: 'root is `true`', + input: true, + message: 'unexpected `boolean`', + }, + { + title: 'root is a number', + input: 4, + message: 'unexpected `number`', + }, + { + title: 'key is `/`', + input: {'/': 'foo.js'}, + message: 'unsupported condition key `/`. Supported conditions are `[]`', + }, + { + title: 'key starts with `/`', + input: {'/foo': 'foo.js'}, + message: 'unsupported condition key `/foo`. Supported conditions are `[]`', + }, + { + title: 'key is short relative path', + input: {foo: 'foo.js'}, + message: 'unsupported condition key `foo`. Supported conditions are `[]`', + }, + { + title: 'main-only sugar path starts with `/`', + input: '/main.js', + message: 'invalid path `/main.js`. Paths must start with `./`', + }, + { + title: 'main-only sugar path short form relative', + input: 'main.js', + message: 'invalid path `main.js`. Paths must start with `./`', + }, + { + title: 'short form relative path', + input: {'./a': 'a.js'}, + message: 'invalid path `a.js`. Paths must start with `./`', + }, + { + title: 'unsupported condition', + config: {conditions: ['foo']}, + input: {bar: './main.js'}, + message: "unsupported condition `bar`. Supported conditions are `['foo']`", + }, + { + title: 'folder mapped to file', + input: {'./': './a.js'}, + message: 'the value of the folder mapping key `./` must end with `/`', + }, + { + title: 'path key in conditions object', + config: {conditions: ['foo']}, + input: {foo: './foo.js', './a': './a.js'}, + message: 'found path key `./a` in a conditions object', + }, + { + title: 'condition key in paths object', + config: {conditions: ['foo']}, + input: {'./a': './a.js', foo: './foo.js'}, + message: 'found condition key `foo` in a paths object', + }, + { + title: '`default` condition not last', + config: {conditions: ['foo']}, + input: {default: './a.js', foo: './b.js'}, + message: 'condition `default` must be the last key', + }, + { + title: 'two valid values in fallback array', + input: {'./a': ['invalid', './a.js', './b.js']}, + message: 'fallback path `./b.js` follows an already valid path', + }, + { + title: 'empty fallback array', + input: {'./a': []}, + message: 'empty fallback array', + }, + { + title: 'no invalid value in fallback array', + input: {'./a': ['./a.js']}, + message: 'fallback array path `./a.js` must follow invalid value', + }, + { + title: 'no valid value in fallback array', + input: {'./a': ['invalid-a', 'invalid-b']}, + message: 'fallback array value `invalid-b` must be followed by valid path', + }, + { + title: 'empty fallback array', + input: {'./a': []}, + message: 'empty fallback array', + }, + { + title: 'conditions in fallback array', + input: {'./a': ['invalid-a', {node: './node.js'}, './a.js']}, + message: 'fallback array must have only strings', + }, + { + title: 'nested fallback array', + input: {'./a': ['invalid-a', ['invalid', './b.js'], './a.js']}, + message: 'fallback array must have only strings', + }, + { + title: 'nested paths object', + input: {'./a': {'./b': './b.js'}}, + message: 'key `./a` has paths object vaule but only conditions may be nested', + }, + ]; + invalids.forEach(({title, config, input, message}) => { + // eslint-disable-next-line jest/valid-title + test(title, () => { + if (title === 'two valid values in fallback array') { + debugger + } + const response = lint({exports: input}, 'error', config); + + expect(response).not.toStrictEqual(true); + expect(response.lintId).toStrictEqual('exports-valid'); + expect(response.severity).toStrictEqual('error'); + expect(response.node).toStrictEqual('exports'); + expect(response.lintMessage).toStrictEqual(message); + }); + }); + }); + + describe('when package.json has valid node', () => { + const valids = [ + { + title: 'empty exports', + input: {}, + }, + { + title: 'a valid key', + input: {'./a': './a.js'}, + }, + { + title: 'multiple valid keys', + input: {'./a': './a.js', './b': './b.js'}, + }, + { + title: 'a valid key with slashes', + input: {'./a/b': './a/b.js'}, + }, + { + title: 'a valid key with file extension', + input: {'./a.js': './a.js'}, + }, + { + title: 'main-only sugar', + input: './main.js', + }, + { + title: 'a valid path', + input: {'./a': './a.js'}, + }, + { + title: 'a valid path in sub-directory', + input: {'./a': './a/b.js'}, + }, + { + title: 'supported condition', + config: {conditions: ['foo']}, + input: {foo: './main.js'}, + }, + { + title: 'multiple supported conditions', + config: {conditions: ['foo', 'bar']}, + input: {foo: './main.js', bar: './bar.js'}, + }, + { + title: 'default condition', + config: {conditions: ['a', 'default']}, + input: {a: './main.js', default: './bar.js'}, + }, + { + title: 'folder mapping', + input: {'./': './a/'}, + }, + { + title: 'sub-folder mapping', + input: {'./a/': './a/b/'}, + }, + { + title: 'fallback array', + input: {'./a': ['invalid', './a.js']}, + }, + { + title: 'fallback array with two invalids', + input: {'./a': ['invalid-a', 'invalid-b', './a.js']}, + }, + { + title: 'conditions under path', + config: {conditions: ['node']}, + input: {'./a': {node: './node.js', default: './a.js'}}, + }, + { + title: 'nested conditions under path', + config: {conditions: ['node', 'import', 'require']}, + input: {'./a': {node: {import: './node.mjs', require: './node.cjs'}, default: './a.js'}}, + }, + ]; + valids.forEach(({title, input, config}) => { + // eslint-disable-next-line jest/valid-title + test(title, () => { + const response = lint({exports: input}, 'error', config); + expect(response).toBe(true); + }); + }); + }); + + describe('when package.json does not have node', () => { + test('true should be returned', () => { + const packageJsonData = {}; + const response = lint(packageJsonData, 'error'); + + expect(response).toBe(true); + }); + }); +});