eslint-plugin-no-mistakes 0.91.3 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/index.js +4 -0
- package/src/rule-suppression.js +11 -1
- package/src/rules/argv-main-values.js +114 -0
- package/src/rules/forbidden-member-chains-destructure.js +82 -0
- package/src/rules/forbidden-member-chains-match.js +120 -0
- package/src/rules/forbidden-member-chains-schema.js +132 -0
- package/src/rules/forbidden-member-chains.js +50 -0
- package/src/rules/gh-api-argument-safety.js +125 -0
- package/src/rules/gh-api-argv.js +101 -0
- package/src/rules/imported-call-options.js +70 -20
- package/src/rules/no-argv-main-guard.js +59 -0
- package/src/rules/no-comment-phrases.js +113 -0
- package/src/rules/require-options-on-imported-call.js +63 -30
- package/src/rules/required-call-selector-compile.js +99 -0
- package/src/rules/required-call-selectors.js +158 -0
- package/src/rules/test-no-skips-bindings.js +43 -4
package/README.md
CHANGED
|
@@ -34,3 +34,19 @@ test and Vitest config files selected by your flat-config globs. It defaults to
|
|
|
34
34
|
5,000 ms for configured test and hook timeouts and 30,000 ms for per-test and
|
|
35
35
|
hook overrides. See the [rule documentation](../../docs/eslint-rules/vitest-timeout-cap.md)
|
|
36
36
|
for options and examples.
|
|
37
|
+
|
|
38
|
+
`gh-api-argument-safety` is an opt-in rule for structured endpoint fields and explicit API methods.
|
|
39
|
+
See [options and examples](../../docs/eslint-rules/gh-api-argument-safety.md).
|
|
40
|
+
|
|
41
|
+
`no-argv-main-guard` is an opt-in rule for symlink-sensitive entrypoint comparisons.
|
|
42
|
+
See [options and examples](../../docs/eslint-rules/no-argv-main-guard.md).
|
|
43
|
+
|
|
44
|
+
`no-comment-phrases` is an opt-in rule for configured phrases in line and block comments.
|
|
45
|
+
See [options and examples](../../docs/eslint-rules/no-comment-phrases.md).
|
|
46
|
+
|
|
47
|
+
`forbidden-member-chains` is an opt-in rule for configured static member-access chains.
|
|
48
|
+
See [options and examples](../../docs/eslint-rules/forbidden-member-chains.md).
|
|
49
|
+
|
|
50
|
+
`require-options-on-imported-call` requires a static options object on configured
|
|
51
|
+
imports, globals with no local binding, and terminal methods with a matching receiver shape.
|
|
52
|
+
See [options and examples](../../docs/eslint-rules/require-options-on-imported-call.md).
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const rules = {
|
|
4
|
+
"forbidden-member-chains": require("./rules/forbidden-member-chains"),
|
|
5
|
+
"gh-api-argument-safety": require("./rules/gh-api-argument-safety"),
|
|
6
|
+
"no-argv-main-guard": require("./rules/no-argv-main-guard"),
|
|
7
|
+
"no-comment-phrases": require("./rules/no-comment-phrases"),
|
|
4
8
|
"async-call-disposition": require("./rules/async-call-disposition"),
|
|
5
9
|
"async-try-catch-return-await": require("./rules/async-try-catch-return-await"),
|
|
6
10
|
"await-array-methods": require("./rules/await-array-methods"),
|
package/src/rule-suppression.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
// Parse actual comments once; directive-looking string literals never suppress.
|
|
4
|
-
|
|
4
|
+
// requireReason is opt-in. Callers that omit it keep today's directive match.
|
|
5
|
+
function ruleSuppression(context, ruleId, suppressionOptions) {
|
|
5
6
|
const lines = new Set();
|
|
6
7
|
let file = false;
|
|
7
8
|
const source = context.sourceCode;
|
|
8
9
|
const first = source.ast.tokens[0];
|
|
10
|
+
const requireReason = suppressionOptions?.requireReason === true;
|
|
9
11
|
for (const comment of source.getAllComments()) {
|
|
10
12
|
for (const [offset, text] of comment.value.split(/\r?\n/).entries()) {
|
|
11
13
|
const match = /^\s*\*?\s*no-mistakes-disable-(file|line|next-line)\b(.*)$/.exec(text);
|
|
@@ -20,6 +22,7 @@ function ruleSuppression(context, ruleId) {
|
|
|
20
22
|
!names.some((name) => name === ruleId || name === `no-mistakes/${ruleId}`)
|
|
21
23
|
)
|
|
22
24
|
continue;
|
|
25
|
+
if (requireReason && !directiveReason(match[2])) continue;
|
|
23
26
|
if (match[1] === "file") {
|
|
24
27
|
if (!first || comment.range[1] <= first.range[0]) file = true;
|
|
25
28
|
} else {
|
|
@@ -29,4 +32,11 @@ function ruleSuppression(context, ruleId) {
|
|
|
29
32
|
}
|
|
30
33
|
return (node) => file || lines.has(node.loc.start.line);
|
|
31
34
|
}
|
|
35
|
+
|
|
36
|
+
function directiveReason(tail) {
|
|
37
|
+
const marker = tail.indexOf("--");
|
|
38
|
+
if (marker === -1) return "";
|
|
39
|
+
return tail.slice(marker + 2).trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
module.exports = { ruleSuppression };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { unwrapExpression } = require("./async-ast");
|
|
3
|
+
const { resolveVariable, memberPropertyName, isLocalRequire } = require("./async-target-bindings");
|
|
4
|
+
const { literalString } = require("./module-mock-helpers");
|
|
5
|
+
|
|
6
|
+
function globalName(node, name, context) {
|
|
7
|
+
if (node?.type !== "Identifier" || node.name !== name) return false;
|
|
8
|
+
return !resolveVariable(node, context)?.defs.length;
|
|
9
|
+
}
|
|
10
|
+
function nodeProcess(node, context) {
|
|
11
|
+
if (globalName(node, "process", context)) return true;
|
|
12
|
+
if (node?.type !== "Identifier") return false;
|
|
13
|
+
const variable = resolveVariable(node, context);
|
|
14
|
+
const def = variable?.defs.length === 1 ? variable.defs[0] : null;
|
|
15
|
+
if (def?.type === "ImportBinding") {
|
|
16
|
+
return (
|
|
17
|
+
["ImportDefaultSpecifier", "ImportNamespaceSpecifier"].includes(def.node.type) &&
|
|
18
|
+
["process", "node:process"].includes(def.parent.source.value)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const init = unwrapExpression(def?.node.init);
|
|
22
|
+
return (
|
|
23
|
+
def?.parent?.kind === "const" &&
|
|
24
|
+
def.node.id.type === "Identifier" &&
|
|
25
|
+
init?.type === "CallExpression" &&
|
|
26
|
+
init.callee.type === "Identifier" &&
|
|
27
|
+
init.callee.name === "require" &&
|
|
28
|
+
!isLocalRequire(init.callee, context) &&
|
|
29
|
+
["process", "node:process"].includes(literalString(init.arguments[0]))
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const ARGV = 1;
|
|
33
|
+
const MAIN = 2;
|
|
34
|
+
const PROCESS = 3;
|
|
35
|
+
const ARGUMENTS = 4;
|
|
36
|
+
function createClassifier(context, wrappers) {
|
|
37
|
+
const stable = new WeakMap();
|
|
38
|
+
const aliases = new WeakMap();
|
|
39
|
+
function classify(input, depth = 0) {
|
|
40
|
+
const node = unwrapExpression(input);
|
|
41
|
+
if (!node || depth > 32) return 0;
|
|
42
|
+
if (node.type === "Identifier") {
|
|
43
|
+
const variable = resolveVariable(node, context);
|
|
44
|
+
if (!variable) {
|
|
45
|
+
if (nodeProcess(node, context)) return PROCESS;
|
|
46
|
+
return globalName(node, "__filename", context) ? MAIN : 0;
|
|
47
|
+
}
|
|
48
|
+
if (!stable.has(variable))
|
|
49
|
+
stable.set(variable, !variable.references.some((r) => r.isWrite() && !r.init));
|
|
50
|
+
if (!stable.get(variable)) return 0;
|
|
51
|
+
if (nodeProcess(node, context)) return PROCESS;
|
|
52
|
+
if (globalName(node, "__filename", context)) return MAIN;
|
|
53
|
+
const def = variable.defs.length === 1 ? variable.defs[0] : null;
|
|
54
|
+
if (
|
|
55
|
+
def?.type !== "Variable" ||
|
|
56
|
+
def.parent.kind !== "const" ||
|
|
57
|
+
def.node.id.type !== "Identifier" ||
|
|
58
|
+
def.node.range[1] > node.range[0]
|
|
59
|
+
)
|
|
60
|
+
return 0;
|
|
61
|
+
if (!aliases.has(variable)) {
|
|
62
|
+
aliases.set(variable, 0);
|
|
63
|
+
aliases.set(variable, classify(def.node.init, depth + 1));
|
|
64
|
+
}
|
|
65
|
+
return aliases.get(variable);
|
|
66
|
+
}
|
|
67
|
+
if (node.type === "MemberExpression") {
|
|
68
|
+
const name = memberPropertyName(node);
|
|
69
|
+
if (
|
|
70
|
+
node.object.type === "MetaProperty" &&
|
|
71
|
+
node.object.meta.name === "import" &&
|
|
72
|
+
node.object.property.name === "meta" &&
|
|
73
|
+
["url", "filename"].includes(name)
|
|
74
|
+
)
|
|
75
|
+
return MAIN;
|
|
76
|
+
const object = classify(node.object, depth + 1);
|
|
77
|
+
if (object === PROCESS && name === "argv") return ARGUMENTS;
|
|
78
|
+
if (
|
|
79
|
+
object === ARGUMENTS &&
|
|
80
|
+
node.computed &&
|
|
81
|
+
node.property.type === "Literal" &&
|
|
82
|
+
node.property.value === 1
|
|
83
|
+
)
|
|
84
|
+
return ARGV;
|
|
85
|
+
// A URL conversion's href preserves the same module/argv identity.
|
|
86
|
+
if (
|
|
87
|
+
name === "href" &&
|
|
88
|
+
unwrapExpression(node.object).type === "CallExpression" &&
|
|
89
|
+
wrappers.resolveCallTarget(unwrapExpression(node.object))?.calleeName === "pathToFileURL"
|
|
90
|
+
)
|
|
91
|
+
return object;
|
|
92
|
+
}
|
|
93
|
+
if (node.type === "CallExpression") {
|
|
94
|
+
const callee = unwrapExpression(node.callee);
|
|
95
|
+
if (
|
|
96
|
+
callee?.type === "MemberExpression" &&
|
|
97
|
+
memberPropertyName(callee) === "at" &&
|
|
98
|
+
classify(callee.object, depth + 1) === ARGUMENTS &&
|
|
99
|
+
node.arguments.length === 1 &&
|
|
100
|
+
node.arguments[0].type === "Literal" &&
|
|
101
|
+
node.arguments[0].value === 1
|
|
102
|
+
)
|
|
103
|
+
return ARGV;
|
|
104
|
+
if (node.arguments.length !== 1 || node.arguments[0].type === "SpreadElement") return 0;
|
|
105
|
+
if (globalName(callee, "Boolean", context) || wrappers.resolveCallTarget(node)) {
|
|
106
|
+
const value = classify(node.arguments[0], depth + 1);
|
|
107
|
+
return value === ARGV || value === MAIN ? value : 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
return (node) => classify(node);
|
|
113
|
+
}
|
|
114
|
+
module.exports = { createClassifier, ARGV, MAIN };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { matches, expressionInfo } = require("./forbidden-member-chains-match");
|
|
4
|
+
|
|
5
|
+
function patternKey(prop) {
|
|
6
|
+
const key = prop.key;
|
|
7
|
+
if (key.type === "Identifier") return key.name;
|
|
8
|
+
if (key.type === "Literal" && (typeof key.value === "string" || typeof key.value === "number")) {
|
|
9
|
+
return String(key.value);
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function objectPattern(node) {
|
|
15
|
+
let current = node;
|
|
16
|
+
while (current?.type === "AssignmentPattern") current = current.left;
|
|
17
|
+
return current?.type === "ObjectPattern" ? current : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function destructureVisitors({ compiled, context, suppressed, info }) {
|
|
21
|
+
// The outer pattern and a default can reach the same property and chain.
|
|
22
|
+
// `const { panel: { open } = other.panel } = other` reports that pair once.
|
|
23
|
+
const reported = new WeakMap();
|
|
24
|
+
|
|
25
|
+
function report(node, chain) {
|
|
26
|
+
if (suppressed(node)) return;
|
|
27
|
+
let seen = reported.get(node);
|
|
28
|
+
if (seen?.has(chain)) return;
|
|
29
|
+
if (!seen) {
|
|
30
|
+
seen = new Set();
|
|
31
|
+
reported.set(node, seen);
|
|
32
|
+
}
|
|
33
|
+
seen.add(chain);
|
|
34
|
+
context.report({ node, messageId: "forbiddenChain", data: { message: chain.message } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Computed keys are unproven, including string and numeric literals.
|
|
38
|
+
// A chain already matched by an outer property is not reported again.
|
|
39
|
+
function walk(pattern, chainInfo, blocked) {
|
|
40
|
+
for (const prop of pattern.properties) {
|
|
41
|
+
if (prop.type !== "Property" || prop.computed) continue;
|
|
42
|
+
const name = patternKey(prop);
|
|
43
|
+
if (name == null) continue;
|
|
44
|
+
const next = { segments: chainInfo.segments.concat(name), rooted: chainInfo.rooted };
|
|
45
|
+
const blockedHere = new Set(blocked);
|
|
46
|
+
for (const chain of compiled) {
|
|
47
|
+
if (blockedHere.has(chain) || !matches(next, chain)) continue;
|
|
48
|
+
report(prop, chain);
|
|
49
|
+
blockedHere.add(chain);
|
|
50
|
+
}
|
|
51
|
+
const nested = objectPattern(prop.value);
|
|
52
|
+
if (nested) walk(nested, next, blockedHere);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function check(pattern, receiver) {
|
|
57
|
+
const chainInfo = expressionInfo(receiver, info);
|
|
58
|
+
if (!chainInfo) return;
|
|
59
|
+
walk(pattern, chainInfo, new Set());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
VariableDeclarator(node) {
|
|
64
|
+
const pattern = objectPattern(node.id);
|
|
65
|
+
if (pattern && node.init) check(pattern, node.init);
|
|
66
|
+
},
|
|
67
|
+
AssignmentExpression(node) {
|
|
68
|
+
const pattern = objectPattern(node.left);
|
|
69
|
+
if (pattern) check(pattern, node.right);
|
|
70
|
+
},
|
|
71
|
+
// A default is a receiver at any depth, not only on a function parameter.
|
|
72
|
+
// The parser only allows one in a parameter, property, or array element.
|
|
73
|
+
// `const { x: { open } = other.panel } = {}` reports `*.panel.open`.
|
|
74
|
+
// An array element is too: `const [{ open } = other.panel] = []`.
|
|
75
|
+
AssignmentPattern(node) {
|
|
76
|
+
const pattern = objectPattern(node.left);
|
|
77
|
+
if (pattern) check(pattern, node.right);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { destructureVisitors };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const MAX_WALK = 32;
|
|
4
|
+
const TRANSPARENT = new Set([
|
|
5
|
+
"ChainExpression",
|
|
6
|
+
"ParenthesizedExpression",
|
|
7
|
+
"TSAsExpression",
|
|
8
|
+
"TSSatisfiesExpression",
|
|
9
|
+
"TSNonNullExpression",
|
|
10
|
+
"TSTypeAssertion",
|
|
11
|
+
"TSInstantiationExpression",
|
|
12
|
+
"TypeCastExpression",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function unwrap(node) {
|
|
16
|
+
let current = node;
|
|
17
|
+
while (current && TRANSPARENT.has(current.type)) current = current.expression;
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function literalKey(node) {
|
|
22
|
+
const value = unwrap(node);
|
|
23
|
+
if (
|
|
24
|
+
value?.type === "Literal" &&
|
|
25
|
+
(typeof value.value === "string" || typeof value.value === "number")
|
|
26
|
+
) {
|
|
27
|
+
return String(value.value);
|
|
28
|
+
}
|
|
29
|
+
if (value?.type === "TemplateLiteral" && value.expressions.length === 0) {
|
|
30
|
+
return value.quasis.map((quasi) => quasi.value.cooked).join("");
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function staticProperty(member) {
|
|
36
|
+
if (member.computed) return literalKey(member.property);
|
|
37
|
+
const property = member.property;
|
|
38
|
+
return property.type === "PrivateIdentifier" ? `#${property.name}` : property.name;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rooted(segments, node) {
|
|
42
|
+
if (node?.type === "Identifier") segments.unshift(node.name);
|
|
43
|
+
else if (node?.type === "ThisExpression") segments.unshift("this");
|
|
44
|
+
else if (node?.type === "Super") segments.unshift("super");
|
|
45
|
+
else if (node?.type === "MetaProperty") segments.unshift(node.meta.name, node.property.name);
|
|
46
|
+
else return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function computeChain(member) {
|
|
51
|
+
const segments = [];
|
|
52
|
+
let current = member;
|
|
53
|
+
for (let steps = 0; current?.type === "MemberExpression"; steps += 1) {
|
|
54
|
+
if (steps >= MAX_WALK) return null;
|
|
55
|
+
const name = staticProperty(current);
|
|
56
|
+
if (name == null) return { segments, rooted: false };
|
|
57
|
+
segments.unshift(name);
|
|
58
|
+
current = unwrap(current.object);
|
|
59
|
+
}
|
|
60
|
+
return { segments, rooted: rooted(segments, current) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function matches(info, chain) {
|
|
64
|
+
if (!info) return false;
|
|
65
|
+
const { segments, rooted: fromRoot } = info;
|
|
66
|
+
const { parts } = chain;
|
|
67
|
+
if (segments.length < parts.length) return false;
|
|
68
|
+
const start = segments.length - parts.length;
|
|
69
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
70
|
+
if (segments[start + index] !== parts[index]) return false;
|
|
71
|
+
}
|
|
72
|
+
// A wildcard needs a receiver. An opaque receiver is not one of the segments.
|
|
73
|
+
if (!chain.wildcard) return fromRoot && start === 0;
|
|
74
|
+
return start > 0 || !fromRoot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function surface(node) {
|
|
78
|
+
let current = node;
|
|
79
|
+
while (
|
|
80
|
+
current.parent &&
|
|
81
|
+
TRANSPARENT.has(current.parent.type) &&
|
|
82
|
+
unwrap(current.parent) === node
|
|
83
|
+
) {
|
|
84
|
+
current = current.parent;
|
|
85
|
+
}
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasMatchingAncestor(node, chain, info) {
|
|
90
|
+
let current = node;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const parent = surface(current).parent;
|
|
93
|
+
if (!parent || parent.type !== "MemberExpression" || unwrap(parent.object) !== current) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (matches(info(parent), chain)) return true;
|
|
97
|
+
current = parent;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function invocationOf(member) {
|
|
102
|
+
const parent = surface(member).parent;
|
|
103
|
+
if (parent.type !== "CallExpression" && parent.type !== "NewExpression") return null;
|
|
104
|
+
return unwrap(parent.callee) === member ? parent : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function expressionInfo(node, memberInfo) {
|
|
108
|
+
const value = unwrap(node);
|
|
109
|
+
if (value.type === "MemberExpression") return memberInfo(value);
|
|
110
|
+
const segments = [];
|
|
111
|
+
return { segments, rooted: rooted(segments, value) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
matches,
|
|
116
|
+
computeChain,
|
|
117
|
+
hasMatchingAncestor,
|
|
118
|
+
invocationOf,
|
|
119
|
+
expressionInfo,
|
|
120
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const MAX_SEGMENTS = 16;
|
|
4
|
+
// A segment is non-empty and is not exactly "*". The star is only a leading receiver.
|
|
5
|
+
const SEGMENT = "(?!\\*(?:\\.|$))[^.]+";
|
|
6
|
+
const OPEN_PATH = `^(?:\\*(?:\\.${SEGMENT}){1,15}|${SEGMENT}(?:\\.${SEGMENT}){0,15})$`;
|
|
7
|
+
// A named receiver replaces a leading "*" and still counts toward 16 segments.
|
|
8
|
+
const NAMED_PATH = `^(?:\\*|\\*(?:\\.${SEGMENT}){1,15}|${SEGMENT}(?:\\.${SEGMENT}){0,14})$`;
|
|
9
|
+
|
|
10
|
+
function text(maxLength, pattern) {
|
|
11
|
+
return pattern
|
|
12
|
+
? { type: "string", minLength: 1, maxLength, pattern }
|
|
13
|
+
: { type: "string", minLength: 1, maxLength };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nonStar() {
|
|
17
|
+
return text(128, "^(?!\\*$)[\\s\\S]+$");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Segment rules run in assertPath so a bad path names the segment. A JSON Schema
|
|
21
|
+
// anyOf only says the value matched none of the alternatives.
|
|
22
|
+
const pathValue = {
|
|
23
|
+
anyOf: [
|
|
24
|
+
{ type: "string", minLength: 1, maxLength: 256 },
|
|
25
|
+
{
|
|
26
|
+
type: "array",
|
|
27
|
+
minItems: 1,
|
|
28
|
+
maxItems: 32,
|
|
29
|
+
items: { type: "string", minLength: 1, maxLength: 128 },
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function chain(properties, required) {
|
|
35
|
+
return {
|
|
36
|
+
type: "object",
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
required,
|
|
39
|
+
properties: { message: text(512), ...properties },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const schema = [
|
|
44
|
+
{
|
|
45
|
+
type: "object",
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
properties: {
|
|
48
|
+
chains: {
|
|
49
|
+
type: "array",
|
|
50
|
+
maxItems: 64,
|
|
51
|
+
items: {
|
|
52
|
+
anyOf: [
|
|
53
|
+
chain({ path: pathValue }, ["path", "message"]),
|
|
54
|
+
chain(
|
|
55
|
+
{
|
|
56
|
+
path: pathValue,
|
|
57
|
+
receiver: { type: "string", enum: ["*"] },
|
|
58
|
+
},
|
|
59
|
+
["path", "message", "receiver"],
|
|
60
|
+
),
|
|
61
|
+
chain(
|
|
62
|
+
{
|
|
63
|
+
path: pathValue,
|
|
64
|
+
receiver: nonStar(),
|
|
65
|
+
},
|
|
66
|
+
["path", "message", "receiver"],
|
|
67
|
+
),
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function shownPath(path) {
|
|
76
|
+
return Array.isArray(path) ? path.join(".") : String(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertPath(path, named) {
|
|
80
|
+
const parts = Array.isArray(path) ? path : String(path).split(".");
|
|
81
|
+
const shown = shownPath(path);
|
|
82
|
+
const empty = parts.findIndex((part) => part === "");
|
|
83
|
+
if (empty !== -1) throw new Error(`empty segment at index ${empty} in '${shown}'`);
|
|
84
|
+
const star = parts.findIndex((part, index) => part === "*" && index !== 0);
|
|
85
|
+
if (star !== -1) {
|
|
86
|
+
throw new Error(`'*' segment at index ${star} in '${shown}'; '*' is only a leading segment`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof path === "string") {
|
|
89
|
+
const pattern = new RegExp(named ? NAMED_PATH : OPEN_PATH);
|
|
90
|
+
if (pattern.test(path)) return;
|
|
91
|
+
const limit = named && parts[0] !== "*" ? MAX_SEGMENTS - 1 : MAX_SEGMENTS;
|
|
92
|
+
if (parts.length > limit) {
|
|
93
|
+
throw new Error(`path '${shown}' has ${parts.length} segments; the maximum is ${limit}`);
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`malformed path '${shown}'`);
|
|
96
|
+
}
|
|
97
|
+
const leadingStar = parts[0] === "*";
|
|
98
|
+
const limit = leadingStar ? MAX_SEGMENTS : named ? MAX_SEGMENTS - 1 : MAX_SEGMENTS;
|
|
99
|
+
const min = leadingStar ? (named ? 1 : 2) : 1;
|
|
100
|
+
if (parts.length < min) {
|
|
101
|
+
throw new Error(`path '${shown}' has ${parts.length} segments; at least ${min} are required`);
|
|
102
|
+
}
|
|
103
|
+
if (parts.length > limit) {
|
|
104
|
+
throw new Error(`path '${shown}' has ${parts.length} segments; the maximum is ${limit}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertChains(entries) {
|
|
109
|
+
if (!Array.isArray(entries)) return;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
assertPath(entry.path, typeof entry.receiver === "string" && entry.receiver !== "*");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compile(entry) {
|
|
116
|
+
assertPath(entry.path, typeof entry.receiver === "string" && entry.receiver !== "*");
|
|
117
|
+
const parts = Array.isArray(entry.path) ? entry.path.slice() : entry.path.split(".");
|
|
118
|
+
let wildcard = false;
|
|
119
|
+
let body = parts;
|
|
120
|
+
if (body[0] === "*") {
|
|
121
|
+
wildcard = true;
|
|
122
|
+
body = body.slice(1);
|
|
123
|
+
}
|
|
124
|
+
if (entry.receiver === "*") wildcard = true;
|
|
125
|
+
else if (typeof entry.receiver === "string") {
|
|
126
|
+
body = [entry.receiver, ...body];
|
|
127
|
+
wildcard = false;
|
|
128
|
+
}
|
|
129
|
+
return { wildcard, parts: body, message: entry.message };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { schema, compile, assertChains };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { ruleSuppression } = require("../rule-suppression");
|
|
4
|
+
const { schema, compile, assertChains } = require("./forbidden-member-chains-schema");
|
|
5
|
+
const {
|
|
6
|
+
matches,
|
|
7
|
+
computeChain,
|
|
8
|
+
hasMatchingAncestor,
|
|
9
|
+
invocationOf,
|
|
10
|
+
} = require("./forbidden-member-chains-match");
|
|
11
|
+
const { destructureVisitors } = require("./forbidden-member-chains-destructure");
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
meta: {
|
|
15
|
+
type: "problem",
|
|
16
|
+
docs: { description: "ban configured static member-access chains", recommended: false },
|
|
17
|
+
schema,
|
|
18
|
+
messages: { forbiddenChain: "{{message}}" },
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
const compiled = [];
|
|
22
|
+
const entries = context.options[0]?.chains;
|
|
23
|
+
assertChains(entries);
|
|
24
|
+
if (Array.isArray(entries)) {
|
|
25
|
+
for (const entry of entries) compiled.push(compile(entry));
|
|
26
|
+
}
|
|
27
|
+
if (compiled.length === 0) return {};
|
|
28
|
+
const suppressed = ruleSuppression(context, "forbidden-member-chains");
|
|
29
|
+
const cache = new WeakMap();
|
|
30
|
+
const info = (member) => {
|
|
31
|
+
if (cache.has(member)) return cache.get(member);
|
|
32
|
+
const value = computeChain(member);
|
|
33
|
+
cache.set(member, value);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const report = (node, chain) => {
|
|
37
|
+
if (suppressed(node)) return;
|
|
38
|
+
context.report({ node, messageId: "forbiddenChain", data: { message: chain.message } });
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
MemberExpression(node) {
|
|
42
|
+
for (const chain of compiled) {
|
|
43
|
+
if (!matches(info(node), chain) || hasMatchingAncestor(node, chain, info)) continue;
|
|
44
|
+
report(invocationOf(node) || node, chain);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
...destructureVisitors({ compiled, context, suppressed, info }),
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|