@locker/eslint-rule-maker
Advanced tools
Comparing version 0.11.7 to 0.11.9
/** | ||
* Copyright (C) 2019 salesforce.com, inc. | ||
* Copyright (C) 2020 salesforce.com, inc. | ||
*/ | ||
@@ -8,188 +8,206 @@ 'use strict'; | ||
var shared = require('@locker/shared'); | ||
var astLibMaker = require('@locker/ast-lib-maker'); | ||
const NO_FIX_OVERRIDE = { | ||
fix: null, | ||
}; | ||
const defaultConfig = { | ||
create: undefined, | ||
meta: { | ||
type: 'suggestion', | ||
docs: { | ||
category: 'Locker', | ||
}, | ||
schema: [], | ||
fixable: undefined, | ||
type: 'problem', | ||
}, | ||
rule: { | ||
fix: undefined, | ||
message: '', | ||
message: undefined, | ||
onMatch: undefined, | ||
search: [], | ||
}, | ||
}; | ||
const { hasOwnProperty } = Object.prototype; | ||
const { isArray } = Array; | ||
const windowAliases = ['frames', 'globalThis', 'self', 'window']; | ||
function cloneConfig(config) { | ||
return JSON.parse(JSON.stringify(config)); | ||
const configClone = JSON.parse(JSON.stringify(config)); | ||
if (shared.isFunction(config.create)) { | ||
configClone.create = config.create; | ||
} | ||
if (shared.isObject(config.rule)) { | ||
const rule = config.rule; | ||
if (shared.isFunction(rule.fix)) { | ||
configClone.rule.fix = rule.fix; | ||
} | ||
if (shared.isFunction(rule.message)) { | ||
configClone.rule.message = rule.message; | ||
} | ||
if (shared.isFunction(rule.onMatch)) { | ||
configClone.rule.onMatch = rule.onMatch; | ||
} | ||
} | ||
return configClone; | ||
} | ||
function defaults(object, source) { | ||
for (const name in source) { | ||
if (has(source, name) && (object[name] === undefined || !has(object, name))) { | ||
object[name] = source[name]; | ||
function getGlobalScopeByContext(context) { | ||
return context.getSourceCode().scopeManager.globalScope; | ||
} | ||
function getGlobalIdentifiersByContext(context) { | ||
const scope = getGlobalScopeByContext(context); | ||
const identifiers = []; | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const variable of scope.variables) { | ||
// ESLint global identifiers have the 'writeable' key. | ||
if (typeof variable.writeable === 'boolean') { | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const { identifier } of variable.references) { | ||
identifiers.push(identifier); | ||
} | ||
} | ||
} | ||
return object; | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const { identifier } of scope.through) { | ||
identifiers.push(identifier); | ||
} | ||
return identifiers; | ||
} | ||
function has(object, name) { | ||
return object != null && hasOwnProperty.call(object, name); | ||
function getScopeIdentifiersByContext(context) { | ||
const scope = context.getScope(); | ||
const identifiers = []; | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const variable of scope.variables) { | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const { identifier } of variable.references) { | ||
identifiers.push(identifier); | ||
} | ||
} | ||
return identifiers; | ||
} | ||
function isWindowSearch(searchPath) { | ||
return windowAliases.some(alias => searchPath.startsWith(`${alias}.`)); | ||
function inNodeSet(set, node) { | ||
let currentNode = node; | ||
while (currentNode !== null) { | ||
if (set.has(currentNode)) { | ||
return true; | ||
} | ||
currentNode = currentNode.parent; | ||
} | ||
return false; | ||
} | ||
/** | ||
* While the `search` property is defined as an array of object property path | ||
* arrays, for ease of use popular convention is to provide an array of object | ||
* property path strings instead: | ||
* ```js | ||
* search: [ | ||
* 'document.defaultView.top', | ||
* 'window.top' | ||
* ] | ||
* ``` | ||
* The string paths are expanded, for window aliases, and converted to arrays: | ||
* ```js | ||
* search: [ | ||
* ['document', 'defaultView', 'top'], | ||
* ['window', 'top'], | ||
* ['top'], | ||
* ['frames', 'top'], | ||
* ['globalThis', 'top'], | ||
* ['self', 'top'] | ||
* ] | ||
* ``` | ||
*/ | ||
function normalizeSearch(search) { | ||
return (search | ||
.map((searchPath) => { | ||
// Dehydrate search paths to strings. | ||
searchPath = isArray(searchPath) ? searchPath.join('.') : String(searchPath); | ||
// Remove whitespace. | ||
return searchPath.replace(/\s/g, ''); | ||
}) | ||
.reduce((expanded, searchPath, _index, dehydrated) => { | ||
// Skip empty search paths. | ||
if (searchPath !== '') { | ||
expanded.push(searchPath); | ||
const topLevelPath = isWindowSearch(searchPath) | ||
? searchPath.slice(searchPath.indexOf('.') + 1) | ||
: searchPath; | ||
// Expand search paths to a top-level identifier. | ||
if (searchPath !== topLevelPath && !dehydrated.includes(topLevelPath)) { | ||
expanded.push(topLevelPath); | ||
const ast = astLibMaker.createLib(); | ||
function matchAsNonReadableNonWritable({ node }) { | ||
return ast.isNodeOfType(node, 'MemberExpression') && NO_FIX_OVERRIDE; | ||
} | ||
function matchAsNonWritable({ node }) { | ||
const parent = ast.getParentNode(node); | ||
return (ast.isNodeOfType(parent, 'AssignmentExpression') && parent.left === node && NO_FIX_OVERRIDE); | ||
} | ||
function matchAsNullishAndNonWritable(data) { | ||
const { node } = data; | ||
const parent = ast.getParentNode(node); | ||
// If `parent` is a MemberExpression then its AST represents a child property | ||
// access. For example, with a `pattern` of `window.top` the matched `node` | ||
// represents `window.top` and `parent` represents `window.top.pageXOffset` | ||
// which is a lint error since `window.top` is treated as nullish. | ||
return ast.isNodeOfType(parent, 'MemberExpression') || matchAsNonWritable(data); | ||
} | ||
const matchers = { | ||
matchAsNonReadableNonWritable, | ||
matchAsNonWritable, | ||
matchAsNullishAndNonWritable, | ||
}; | ||
function createRule(config) { | ||
const configClone = cloneConfig(config); | ||
const defaultConfigClone = cloneConfig(defaultConfig); | ||
defaultConfigClone.create = function create(context) { | ||
const checked = new Set(); | ||
const reported = new Set(); | ||
function report(data) { | ||
const matchedNode = data.node; | ||
if (inNodeSet(reported, matchedNode)) { | ||
return; | ||
} | ||
// Expand search paths to other `window` aliases. | ||
for (const alias of windowAliases) { | ||
const searchAliasPath = `${alias}.${topLevelPath}`; | ||
if (searchPath !== searchAliasPath && | ||
!dehydrated.includes(searchAliasPath)) { | ||
expanded.push(searchAliasPath); | ||
reported.add(matchedNode); | ||
const matchedData = { | ||
context, | ||
identifier: data.identifier, | ||
node: matchedNode, | ||
pattern: data.pattern, | ||
}; | ||
const { onMatch } = configClone.rule; | ||
const matcherData = !shared.isFunction(onMatch) || onMatch.call(config.rule, matchedData); | ||
if (!matcherData) { | ||
return; | ||
} | ||
let { fix, message } = configClone.rule; | ||
if (shared.isObject(matcherData)) { | ||
if (shared.ObjectHasOwnProperty(matcherData, 'fix')) { | ||
fix = matcherData.fix; | ||
} | ||
if (shared.ObjectHasOwnProperty(matcherData, 'message')) { | ||
message = matcherData.message; | ||
} | ||
} | ||
if (typeof fix === 'string') { | ||
const replacementCode = fix; | ||
fix = (fixer) => [ | ||
fixer.insertTextAfter(matchedNode, replacementCode), | ||
fixer.remove(matchedNode), | ||
]; | ||
} | ||
else if (shared.isFunction(fix)) { | ||
const oldFix = fix; | ||
fix = (fixer) => oldFix.call(config.rule, fixer, matchedData); | ||
} | ||
else { | ||
fix = undefined; | ||
} | ||
if (shared.isFunction(message)) { | ||
message = message.call(config.rule, matchedData); | ||
} | ||
context.report({ | ||
fix: fix, | ||
node: matchedNode, | ||
message: message, | ||
}); | ||
} | ||
return expanded; | ||
}, []) | ||
// Rehydrate search paths to arrays. | ||
.map(searchPath => searchPath.split('.'))); | ||
} | ||
function create(config) { | ||
const defaultConfigClone = cloneConfig(defaultConfig); | ||
config.meta = defaults(config.meta, defaultConfigClone.meta); | ||
config.rule = defaults(config.rule, defaultConfigClone.rule); | ||
const { meta, rule } = config; | ||
meta.docs = defaults(config.meta.docs, defaultConfigClone.meta.docs); | ||
rule.search = normalizeSearch(rule.search); | ||
if (meta.fixable === undefined && rule.fix !== undefined) { | ||
meta.fixable = 'code'; | ||
return { | ||
MemberExpression(node) { | ||
if (inNodeSet(checked, node)) { | ||
return; | ||
} | ||
checked.add(node); | ||
const patternsWithAsterisks = configClone.rule.search.filter((pattern) => pattern[0] === '*'); | ||
const matches = ast.matchAll(getScopeIdentifiersByContext(context), patternsWithAsterisks); | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const matchData of matches) { | ||
report(matchData); | ||
} | ||
}, | ||
'Program:exit': function ProgramExit() { | ||
const matches = ast.matchAll(getGlobalIdentifiersByContext(context), configClone.rule.search); | ||
// eslint-disable-next-line no-restricted-syntax | ||
for (const matchData of matches) { | ||
report(matchData); | ||
} | ||
}, | ||
}; | ||
}; | ||
// Populate first level default properties. | ||
shared.defaults(configClone, defaultConfigClone); | ||
// Populate second level default properties. | ||
configClone.meta = shared.defaults(configClone.meta, defaultConfigClone.meta); | ||
configClone.rule = shared.defaults(configClone.rule, defaultConfigClone.rule); | ||
// Populate third level default properties. | ||
configClone.rule.search = ast.expandPatterns(configClone.rule.search); | ||
if (configClone.meta.fixable === undefined && configClone.rule.fix !== undefined) { | ||
configClone.meta.fixable = 'code'; | ||
} | ||
return { | ||
meta, | ||
create(context) { | ||
return { | ||
'Program:exit': function () { | ||
let nodeForRef; | ||
// The top level scope is the 'global' scope. Depending on | ||
// the 'env' property in the eslint config, references might | ||
// not be resolved at the global scope. The most reliable way | ||
// to get all unresolved references from the module is to get | ||
// them from the 'module' scope. | ||
const moduleScope = context | ||
.getScope() | ||
.childScopes.find(({ type }) => type === 'module'); | ||
const ref = moduleScope.through.find(({ identifier }) => { | ||
searchLoop: for (const searchPath of rule.search) { | ||
// Skip fast for mismatched identifiers. | ||
if (identifier.name !== searchPath[0]) { | ||
continue; | ||
} | ||
// Exit early for non-member identifier matches. | ||
if (searchPath.length === 1) { | ||
nodeForRef = identifier; | ||
return true; | ||
} | ||
// Skip fast for the unexpected. | ||
const { parent } = identifier; | ||
if (parent.type !== 'MemberExpression') { | ||
continue; | ||
} | ||
let currentNode = parent; | ||
for (let i = 0; i < searchPath.length; i += 1) { | ||
nodeForRef = undefined; | ||
// Skip for the unexpected. | ||
if (currentNode === undefined || | ||
currentNode.type !== 'MemberExpression') { | ||
continue searchLoop; | ||
} | ||
const { object, property } = currentNode; | ||
if (object.type === 'Identifier') { | ||
// Skip for mismatched object identifiers. | ||
if (object.name !== searchPath[i]) { | ||
continue searchLoop; | ||
} | ||
i += 1; | ||
} | ||
// Skip nodes that aren't part of a property chain. | ||
else if (object.type !== 'MemberExpression') { | ||
continue searchLoop; | ||
} | ||
// Skip for mismatched property identifiers. | ||
if (property.type !== 'Identifier' || | ||
property.name !== searchPath[i]) { | ||
continue searchLoop; | ||
} | ||
nodeForRef = currentNode; | ||
currentNode = currentNode.parent; | ||
} | ||
// If we've made it this far it's a match! | ||
return true; | ||
} | ||
return false; | ||
}); | ||
if (ref) { | ||
let { fix } = rule; | ||
if (typeof fix === 'string') { | ||
fix = (fixer) => [ | ||
fixer.insertTextAfter(nodeForRef, rule.fix), | ||
fixer.remove(nodeForRef), | ||
]; | ||
} | ||
else if (typeof fix === 'function') { | ||
fix = (fixer) => rule.fix.call(undefined, fixer, nodeForRef); | ||
} | ||
context.report({ | ||
fix, | ||
node: nodeForRef, | ||
message: rule.message, | ||
}); | ||
} | ||
}, | ||
}; | ||
}, | ||
}; | ||
if (shared.isObject(config.rule) && !shared.isFunction(configClone.rule.onMatch)) { | ||
configClone.rule.onMatch = matchers.matchAsNullishAndNonWritable; | ||
} | ||
// Remove 'rule' from `exportedConfig` so it aligns with the expected | ||
// ESRule.RuleModule interface. | ||
const exportedConfig = cloneConfig(configClone); | ||
delete exportedConfig.rule; | ||
return exportedConfig; | ||
} | ||
exports.create = create; | ||
/** version: 0.11.7 */ | ||
exports.ast = ast; | ||
exports.createRule = createRule; | ||
exports.matchers = matchers; | ||
/** version: 0.11.9 */ |
{ | ||
"name": "@locker/eslint-rule-maker", | ||
"version": "0.11.7", | ||
"version": "0.11.9", | ||
"license": "MIT", | ||
"description": "Locker Next eslint rule maker utility package", | ||
"description": "Locker Next eslint rule maker utility", | ||
"keywords": [ | ||
@@ -17,3 +17,3 @@ "eslint", | ||
"clean": "locker-trash dist/ types/", | ||
"test": "jest" | ||
"test": "yarn build && jest" | ||
}, | ||
@@ -23,4 +23,10 @@ "publishConfig": { | ||
}, | ||
"dependencies": { | ||
"@locker/ast-lib-maker": "0.11.9", | ||
"@locker/shared": "0.11.9" | ||
}, | ||
"devDependencies": { | ||
"@types/eslint": "6.8.0", | ||
"@types/eslint": "7.2.2", | ||
"@types/estree": "0.0.45", | ||
"eslint": "7.2.0", | ||
"rollup-plugin-typescript": "1.0.1", | ||
@@ -30,5 +36,5 @@ "typescript": "3.8.3" | ||
"files": [ | ||
"lib" | ||
"dist" | ||
], | ||
"gitHead": "d95b59d9623ee44939cd6d3f2b33b487ea308bdc" | ||
"gitHead": "7fd681f08f87206dfb944c258dac4eb12a98a6eb" | ||
} |
# @locker/eslint-rule-maker | ||
The `@locker/eslint-rule-maker` package is used to help make linting rules | ||
for [`@locker`](https://github.com/salesforce/locker). | ||
The `@locker/eslint-rule-maker` package is used to make linting rules for | ||
[`@locker`](https://github.com/salesforce/locker). | ||
## Usage | ||
Use the `create()` utility when defining an eslint rule. | ||
Use `createRule(config)` when defining an eslint rule. | ||
<!-- eslint-disable import/no-extraneous-dependencies --> | ||
```js | ||
const { create } = require('@locker/eslint-rule-maker'); | ||
const { createRule } = require('@locker/eslint-rule-maker'); | ||
module.exports = create({ | ||
// Add a meta object according to | ||
module.exports = createRule({ | ||
// Add an optional meta object according to | ||
// https://eslint.org/docs/developer-guide/working-with-rules | ||
@@ -19,4 +19,3 @@ // | ||
// `meta.fixable`: `'code'` (when specifying `rule.fix`) | ||
// `meta.type`: `'suggestion'` | ||
// `meta.docs.category`: `'Locker'` | ||
// `meta.type`: `'problem'` | ||
rule: { | ||
@@ -28,5 +27,5 @@ // The message provided to `context.report()`. | ||
// An array of object property paths to search for. | ||
search: ['document.defaultView.top', 'window.top'], | ||
search: ['window.top'], | ||
}, | ||
}); | ||
``` |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
18111
5
412
2
5
30
1
+ Added@locker/ast-lib-maker@0.11.9
+ Added@locker/shared@0.11.9
+ Added@locker/ast-lib-maker@0.11.9(transitive)
+ Added@locker/shared@0.11.9(transitive)