Skip to content

Commit

Permalink
new rule exports-valid
Browse files Browse the repository at this point in the history
  • Loading branch information
mightyiam committed Sep 17, 2020
1 parent 00fac18 commit 653e05c
Show file tree
Hide file tree
Showing 2 changed files with 360 additions and 0 deletions.
126 changes: 126 additions & 0 deletions 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,
};
234 changes: 234 additions & 0 deletions 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);
});
});
});

0 comments on commit 653e05c

Please sign in to comment.