-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
360 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
}); | ||
}); | ||
}); |