@eagleoutice/eslint-config-flowr 2.1.9 → 2.1.11
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 +1 -0
- package/package.json +1 -1
- package/plugin.d.ts +1 -0
- package/plugin.js +3 -1
- package/rules/no-semantic-widening.d.ts +3 -0
- package/rules/no-semantic-widening.js +108 -0
- package/rules/replacement-pattern.js +3 -32
- package/rules/ts-types.d.ts +25 -3
- package/rules/use-instead.js +26 -15
- package/rules/util.d.ts +13 -1
- package/rules/util.js +105 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ below `test/`.
|
|
|
27
27
|
| :-- | :-- | :-- |
|
|
28
28
|
| `flowr/use-instead` | the `@useInstead` tag | a reference to what a helper object replaced |
|
|
29
29
|
| `flowr/replacement-pattern` | [`patterns.ts`](patterns.ts) | a shape of code with a helper equivalent |
|
|
30
|
+
| `flowr/no-semantic-widening` | the types | a value of a `__brand`ed type (`NodeId`, `BrandedIdentifier`, ...) that silently becomes a plain `string` or `number`, also within `string[]`, `Set<string>`, or `Map<string, ...>` |
|
|
30
31
|
|
|
31
32
|
Both fix in place when every name the replacement needs is already in scope, and offer an editor suggestion otherwise,
|
|
32
33
|
since a fixer cannot add the missing import. A type-only binding of the right name does not count.
|
package/package.json
CHANGED
package/plugin.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ declare const plugin: {
|
|
|
6
6
|
'use-instead': import("eslint").Rule.RuleModule;
|
|
7
7
|
'replacement-pattern': import("eslint").Rule.RuleModule;
|
|
8
8
|
'file-naming': import("eslint").Rule.RuleModule;
|
|
9
|
+
'no-semantic-widening': import("eslint").Rule.RuleModule;
|
|
9
10
|
};
|
|
10
11
|
};
|
|
11
12
|
export = plugin;
|
package/plugin.js
CHANGED
|
@@ -6,12 +6,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
const replacement_pattern_1 = __importDefault(require("./rules/replacement-pattern"));
|
|
7
7
|
const use_instead_1 = __importDefault(require("./rules/use-instead"));
|
|
8
8
|
const file_naming_1 = __importDefault(require("./rules/file-naming"));
|
|
9
|
+
const no_semantic_widening_1 = __importDefault(require("./rules/no-semantic-widening"));
|
|
9
10
|
const plugin = {
|
|
10
11
|
meta: { name: '@eagleoutice/eslint-config-flowr' },
|
|
11
12
|
rules: {
|
|
12
13
|
'use-instead': use_instead_1.default,
|
|
13
14
|
'replacement-pattern': replacement_pattern_1.default,
|
|
14
|
-
'file-naming': file_naming_1.default
|
|
15
|
+
'file-naming': file_naming_1.default,
|
|
16
|
+
'no-semantic-widening': no_semantic_widening_1.default
|
|
15
17
|
}
|
|
16
18
|
};
|
|
17
19
|
module.exports = plugin;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const util_1 = require("./util");
|
|
3
|
+
/* ts.TypeFlags and ts.SymbolFlags, spelled out to keep typescript out of the dependencies */
|
|
4
|
+
const StringFlag = 1 << 2;
|
|
5
|
+
const NumberFlag = 1 << 3;
|
|
6
|
+
const UndefinedFlag = 1 << 15;
|
|
7
|
+
const NullFlag = 1 << 16;
|
|
8
|
+
const TypeParameterFlag = 1 << 18;
|
|
9
|
+
const BrandProperty = '__brand';
|
|
10
|
+
const Collections = new Set(['Array', 'ReadonlyArray', 'Set', 'ReadonlySet', 'Map', 'ReadonlyMap', 'DefaultMap']);
|
|
11
|
+
function membersOf(type) {
|
|
12
|
+
return (type.isUnion() ? type.types : [type]).filter(t => (t.flags & (UndefinedFlag | NullFlag)) === 0);
|
|
13
|
+
}
|
|
14
|
+
/** A semantic type carries a brand: `string & { __brand?: 'node-id' }`. */
|
|
15
|
+
function isSemantic(type, checker) {
|
|
16
|
+
const members = membersOf(type);
|
|
17
|
+
return members.length > 0 && members.every(t => checker.getPropertyOfType(t, BrandProperty) !== undefined);
|
|
18
|
+
}
|
|
19
|
+
function isPlainPrimitive(type, checker) {
|
|
20
|
+
const members = membersOf(type);
|
|
21
|
+
return members.length > 0 && members.every(t => (t.flags & (StringFlag | NumberFlag)) !== 0 && checker.getPropertyOfType(t, BrandProperty) === undefined);
|
|
22
|
+
}
|
|
23
|
+
function collectionArguments(type, checker) {
|
|
24
|
+
const [member] = membersOf(type);
|
|
25
|
+
const name = member?.getSymbol()?.getName();
|
|
26
|
+
return name !== undefined && Collections.has(name) ? checker.getTypeArguments(member) : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** Whether a value of type `source` loses its meaning in a place of type `target`, on its own or inside a collection. */
|
|
29
|
+
function widens(source, target, checker) {
|
|
30
|
+
if (isSemantic(source, checker)) {
|
|
31
|
+
return isPlainPrimitive(target, checker);
|
|
32
|
+
}
|
|
33
|
+
const from = collectionArguments(source, checker);
|
|
34
|
+
const to = collectionArguments(target, checker);
|
|
35
|
+
return from !== undefined && to !== undefined && from.some((f, i) => to[i] !== undefined && widens(f, to[i], checker));
|
|
36
|
+
}
|
|
37
|
+
/** A parameter a library declares as a plain string takes any string, while one of a collection (`Set<string>`) states what the code chose. */
|
|
38
|
+
function acceptedByLibrary(call, argument, tsCall, checker) {
|
|
39
|
+
if (call.type !== 'CallExpression' && call.type !== 'NewExpression') {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const declaration = checker.getResolvedSignature(tsCall)?.declaration;
|
|
43
|
+
if (declaration?.getSourceFile()?.fileName.includes('/node_modules/') !== true) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const parameters = declaration.parameters;
|
|
47
|
+
const parameter = parameters?.[Math.min(call.arguments.indexOf(argument), parameters.length - 1)];
|
|
48
|
+
return parameter !== undefined && (checker.getTypeAtLocation(parameter).flags & TypeParameterFlag) === 0;
|
|
49
|
+
}
|
|
50
|
+
const rule = {
|
|
51
|
+
meta: {
|
|
52
|
+
type: 'problem',
|
|
53
|
+
docs: { description: 'a value of a semantic type (NodeId, BrandedIdentifier, ...) must not silently become a plain string or number' },
|
|
54
|
+
messages: {
|
|
55
|
+
widens: 'This turns a {{source}} into a plain {{target}}. Use a semantic type for what receives it, or cast explicitly if the meaning really ends here.'
|
|
56
|
+
},
|
|
57
|
+
schema: []
|
|
58
|
+
},
|
|
59
|
+
create(context) {
|
|
60
|
+
const services = (0, util_1.typeServices)(context);
|
|
61
|
+
if (!services) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const checker = services.program.getTypeChecker();
|
|
65
|
+
const check = (node) => {
|
|
66
|
+
if (!node || node.type === 'SpreadElement') {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
|
|
70
|
+
const target = checker.getContextualType(tsNode);
|
|
71
|
+
if (target === undefined) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const source = checker.getTypeAtLocation(tsNode);
|
|
75
|
+
if (!widens(source, target, checker) || (0, util_1.suppressed)(context, node, ['no-semantic-widening'])) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const parent = node.parent;
|
|
79
|
+
if (acceptedByLibrary(parent, node, services.esTreeNodeToTSNodeMap.get(parent), checker)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
context.report({ node, messageId: 'widens', data: { source: checker.typeToString(source), target: checker.typeToString(target) } });
|
|
83
|
+
};
|
|
84
|
+
const checkArguments = (call) => call.arguments.forEach(check);
|
|
85
|
+
return {
|
|
86
|
+
CallExpression: checkArguments,
|
|
87
|
+
NewExpression: checkArguments,
|
|
88
|
+
ReturnStatement: node => check(node.argument),
|
|
89
|
+
VariableDeclarator(node) {
|
|
90
|
+
check(node.init);
|
|
91
|
+
},
|
|
92
|
+
AssignmentExpression(node) {
|
|
93
|
+
if (node.operator === '=') {
|
|
94
|
+
check(node.right);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
Property(node) {
|
|
98
|
+
check(node.value);
|
|
99
|
+
},
|
|
100
|
+
ArrowFunctionExpression(node) {
|
|
101
|
+
if (node.body.type !== 'BlockStatement') {
|
|
102
|
+
check(node.body);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
module.exports = rule;
|
|
@@ -28,32 +28,6 @@ function render(template, captures, node, sourceCode) {
|
|
|
28
28
|
});
|
|
29
29
|
return failed ? undefined : filled;
|
|
30
30
|
}
|
|
31
|
-
/** the name a replacement calls, `escapeRegex` for `escapeRegex(s)` and `toBuiltIn` for `!NodeId.toBuiltIn(x)` */
|
|
32
|
-
function calledBy(replacement) {
|
|
33
|
-
return /^!?(?:[A-Za-z_$][\w$]*\.)*([A-Za-z_$][\w$]*)\(/.exec(replacement)?.[1];
|
|
34
|
-
}
|
|
35
|
-
/** the name a function is declared or bound under, as a declaration, an object member, or a variable */
|
|
36
|
-
function nameOf(fn, parent) {
|
|
37
|
-
if (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') {
|
|
38
|
-
if (fn.id) {
|
|
39
|
-
return fn.id.name;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (parent?.type === 'Property' || parent?.type === 'MethodDefinition') {
|
|
43
|
-
return parent.key.type === 'Identifier' ? parent.key.name : undefined;
|
|
44
|
-
}
|
|
45
|
-
return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' ? parent.id.name : undefined;
|
|
46
|
-
}
|
|
47
|
-
/** Whether `node` sits in the function the replacement would call, where the fix would make the helper call itself. */
|
|
48
|
-
function withinReplacement(node, replacement, sourceCode) {
|
|
49
|
-
const called = calledBy(replacement);
|
|
50
|
-
if (called === undefined) {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
const ancestors = sourceCode.getAncestors(node);
|
|
54
|
-
return ancestors.some((fn, i) => (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression' || fn.type === 'ArrowFunctionExpression')
|
|
55
|
-
&& nameOf(fn, ancestors[i - 1]) === called);
|
|
56
|
-
}
|
|
57
31
|
/** per program, whether one of its files matches a `helperIn` path; every file of a run shares the program */
|
|
58
32
|
const helperFiles = new WeakMap();
|
|
59
33
|
function programHas(program, path) {
|
|
@@ -131,10 +105,7 @@ const rule = {
|
|
|
131
105
|
bySelector.set(pattern.selector, [pattern]);
|
|
132
106
|
}
|
|
133
107
|
}
|
|
134
|
-
/**
|
|
135
|
-
* The match only counts if every named node resolves to a declaration of the expected flowR file.
|
|
136
|
-
* The declaring file itself is exempt, it is where the helper is built.
|
|
137
|
-
*/
|
|
108
|
+
/** The match only counts if every named node resolves to a declaration of the expected flowR file. */
|
|
138
109
|
function isFlowr(node, pattern) {
|
|
139
110
|
if (pattern.helperIn !== undefined && (!services || !programHas(services.program, pattern.helperIn))) {
|
|
140
111
|
return false;
|
|
@@ -147,7 +118,7 @@ const rule = {
|
|
|
147
118
|
}
|
|
148
119
|
return Object.entries(pattern.declaredIn).every(([path, expected]) => {
|
|
149
120
|
const file = (0, util_1.declarationFile)((0, util_1.symbolOf)(resolvePath(node, path), services, checker));
|
|
150
|
-
return file !== undefined && file
|
|
121
|
+
return file !== undefined && file.replaceAll('\\', '/').includes(expected);
|
|
151
122
|
});
|
|
152
123
|
}
|
|
153
124
|
/** esquery cannot demand that two sub-nodes are the same, so the pattern names the pairs itself */
|
|
@@ -166,7 +137,7 @@ const rule = {
|
|
|
166
137
|
const replacement = render(pattern.replace, captures, node, sourceCode);
|
|
167
138
|
const message = (pattern.message === undefined ? undefined : render(pattern.message, captures, node, sourceCode))
|
|
168
139
|
?? (replacement ? `Use \`${replacement}\` here.` : `\`${pattern.id ?? pattern.selector}\` has a helper replacement.`);
|
|
169
|
-
if (replacement !== undefined &&
|
|
140
|
+
if (replacement !== undefined && (0, util_1.insideReplacement)(node, replacement, sourceCode, services)) {
|
|
170
141
|
return;
|
|
171
142
|
}
|
|
172
143
|
else if (replacement === undefined) {
|
package/rules/ts-types.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface TsSourceFile {
|
|
|
7
7
|
}
|
|
8
8
|
export interface TsDeclaration {
|
|
9
9
|
getSourceFile(): TsSourceFile | undefined;
|
|
10
|
+
/** what a property or variable is set to, `frames: framesOf` binds `framesOf` */
|
|
11
|
+
initializer?: unknown;
|
|
10
12
|
}
|
|
11
13
|
export interface TsJsDocTag {
|
|
12
14
|
name: string;
|
|
@@ -21,20 +23,40 @@ export interface TsSymbol {
|
|
|
21
23
|
getJsDocTags(checker: TsTypeChecker): readonly TsJsDocTag[];
|
|
22
24
|
}
|
|
23
25
|
export interface TsType {
|
|
26
|
+
flags: number;
|
|
27
|
+
types?: readonly TsType[];
|
|
24
28
|
getProperties(): readonly TsSymbol[];
|
|
29
|
+
getSymbol(): TsSymbol | undefined;
|
|
30
|
+
isUnion(): this is TsType & {
|
|
31
|
+
types: readonly TsType[];
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** What a call resolves to, its parameters only for a declaration in code. */
|
|
35
|
+
export interface TsSignature {
|
|
36
|
+
declaration?: TsDeclaration & {
|
|
37
|
+
parameters?: readonly unknown[];
|
|
38
|
+
};
|
|
25
39
|
}
|
|
26
40
|
export interface TsTypeChecker {
|
|
27
41
|
getSymbolAtLocation(node: unknown): TsSymbol | undefined;
|
|
28
42
|
getAliasedSymbol(symbol: TsSymbol): TsSymbol;
|
|
29
43
|
getTypeOfSymbolAtLocation(symbol: TsSymbol, location: TsDeclaration): TsType;
|
|
44
|
+
getTypeAtLocation(node: unknown): TsType;
|
|
45
|
+
getContextualType(node: unknown): TsType | undefined;
|
|
46
|
+
getPropertyOfType(type: TsType, name: string): TsSymbol | undefined;
|
|
47
|
+
getTypeArguments(type: TsType): readonly TsType[];
|
|
48
|
+
getResolvedSignature(node: unknown): TsSignature | undefined;
|
|
49
|
+
typeToString(type: TsType): string;
|
|
50
|
+
/** what a module exports, its symbol taken from the source file */
|
|
51
|
+
getExportsOfModule(module: TsSymbol): readonly TsSymbol[];
|
|
52
|
+
/** the variable a shorthand property `{ framesOf }` stands for */
|
|
53
|
+
getShorthandAssignmentValueSymbol(declaration: TsDeclaration): TsSymbol | undefined;
|
|
30
54
|
}
|
|
31
55
|
/** What typescript-eslint hands to a type-aware rule. */
|
|
32
56
|
export interface TypeServices {
|
|
33
57
|
program: {
|
|
34
58
|
getTypeChecker(): TsTypeChecker;
|
|
35
|
-
getSourceFiles(): readonly
|
|
36
|
-
fileName: string;
|
|
37
|
-
}[];
|
|
59
|
+
getSourceFiles(): readonly TsSourceFile[];
|
|
38
60
|
};
|
|
39
61
|
esTreeNodeToTSNodeMap: {
|
|
40
62
|
get(node: unknown): unknown;
|
package/rules/use-instead.js
CHANGED
|
@@ -35,10 +35,16 @@ function cachedMemberTags(symbol, checker) {
|
|
|
35
35
|
memberCache.set(symbol, tags);
|
|
36
36
|
return tags;
|
|
37
37
|
}
|
|
38
|
-
/** `
|
|
38
|
+
/** `X.member` around a reference to `X`, with the member it reads */
|
|
39
|
+
function memberRead(node) {
|
|
40
|
+
const parent = node.parent;
|
|
41
|
+
return parent?.type === 'MemberExpression' && parent.object === node && !parent.computed && parent.property.type === 'Identifier'
|
|
42
|
+
? { node: parent, member: parent.property.name } : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** `{@link Resolve.toValue}` becomes `Resolve.toValue`. */
|
|
39
45
|
function parseTarget(tag) {
|
|
40
46
|
const display = tag.replace(/^\{\s*@link\s*/, '').replace(/\s*\|.*$/, '').replace(/\s*\}\s*$/, '').trim();
|
|
41
|
-
return { display
|
|
47
|
+
return { display };
|
|
42
48
|
}
|
|
43
49
|
/** The references that build the replacement in the first place: helper-object wiring and re-exports. */
|
|
44
50
|
function isWiring(identifier) {
|
|
@@ -74,6 +80,7 @@ const rule = {
|
|
|
74
80
|
messages: {
|
|
75
81
|
useInstead: 'Do not use `{{name}}` directly, use `{{replacement}}` instead.',
|
|
76
82
|
useInsteadPlain: 'Do not use `{{name}}` directly, see its documentation for the replacement.',
|
|
83
|
+
useInsteadMissing: 'Do not use `{{name}}` directly, use `{{replacement}}` instead, which has no counterpart of `{{member}}` yet.',
|
|
77
84
|
suggest: 'Replace with `{{replacement}}`.'
|
|
78
85
|
}
|
|
79
86
|
},
|
|
@@ -87,21 +94,28 @@ const rule = {
|
|
|
87
94
|
/* helper objects are PascalCase, so member checks stay cheap */
|
|
88
95
|
const helperObjectPattern = new RegExp(context.options[0]?.helperObjectPattern ?? '^[A-Z]');
|
|
89
96
|
const candidates = new Map();
|
|
90
|
-
/** the helper objects declared here, a file may use what it wraps */
|
|
91
|
-
const owned = new Set();
|
|
92
97
|
function report(node, name, tag) {
|
|
93
|
-
const { display
|
|
94
|
-
|
|
98
|
+
const { display } = parseTarget(tag);
|
|
99
|
+
/* only the wrapper itself may use what it wraps, anywhere else the replacement holds */
|
|
100
|
+
if ((0, util_1.insideReplacement)(node, display, sourceCode, services) || (0, util_1.suppressed)(context, node, ['use-instead'])) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
/* `Old.member` moves to the member of the replacement that takes its place, which may be named differently */
|
|
104
|
+
const access = memberRead(node);
|
|
105
|
+
const counterpart = display === '' || access === undefined || !services ? null : (0, util_1.counterpartOf)(node, access.member, display, sourceCode, services);
|
|
106
|
+
if (counterpart === undefined) {
|
|
107
|
+
context.report({ node, messageId: 'useInsteadMissing', data: { name, replacement: display, member: access?.member ?? '' } });
|
|
95
108
|
return;
|
|
96
109
|
}
|
|
97
|
-
const
|
|
110
|
+
const [target, text] = counterpart === null || access === undefined ? [node, display] : [access.node, `${display}.${counterpart}`];
|
|
111
|
+
const fix = (fixer) => fixer.replaceText(target, text);
|
|
98
112
|
/* fixed on the spot where the helper is already in scope, offered as a suggestion otherwise */
|
|
99
113
|
const applicable = display !== '' && (0, util_1.isApplicable)(context, node, display);
|
|
100
114
|
context.report({
|
|
101
115
|
node,
|
|
102
116
|
messageId: display ? 'useInstead' : 'useInsteadPlain',
|
|
103
|
-
data: { name, replacement:
|
|
104
|
-
...display === '' ? {} : applicable ? { fix } : { suggest: [{ messageId: 'suggest', data: { replacement:
|
|
117
|
+
data: { name, replacement: text },
|
|
118
|
+
...display === '' ? {} : applicable ? { fix } : { suggest: [{ messageId: 'suggest', data: { replacement: text }, fix }] }
|
|
105
119
|
});
|
|
106
120
|
}
|
|
107
121
|
return {
|
|
@@ -111,18 +125,15 @@ const rule = {
|
|
|
111
125
|
const foreign = [];
|
|
112
126
|
for (const variable of module?.variables ?? []) {
|
|
113
127
|
const declaration = variable.defs[0]?.name;
|
|
114
|
-
|
|
128
|
+
/* only what is imported is someone else's to replace, the file's own declarations may use themselves;
|
|
129
|
+
asked of the code rather than of file paths, which differ between systems */
|
|
130
|
+
if (declaration?.type !== 'Identifier' || variable.defs[0]?.type !== 'ImportBinding') {
|
|
115
131
|
continue;
|
|
116
132
|
}
|
|
117
133
|
const symbol = (0, util_1.symbolOf)(declaration, services, checker);
|
|
118
134
|
if (!symbol) {
|
|
119
135
|
continue;
|
|
120
136
|
}
|
|
121
|
-
else if ((0, util_1.declarationFile)(symbol) === context.filename) {
|
|
122
|
-
/* a declaration may always use itself */
|
|
123
|
-
owned.add(variable.name);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
137
|
if (helperObjectPattern.test(variable.name)) {
|
|
127
138
|
candidates.set(variable.name, symbol);
|
|
128
139
|
}
|
package/rules/util.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared helpers of the flowR rules.
|
|
3
3
|
* Tags: `@useInstead <target>` names a replacement, `@lintIgnore [ids]` silences the rules.
|
|
4
4
|
*/
|
|
5
|
-
import type { Rule } from 'eslint';
|
|
5
|
+
import type { Rule, SourceCode } from 'eslint';
|
|
6
6
|
import type { Node } from 'estree';
|
|
7
7
|
import type { TsSymbol, TsTypeChecker, TypeServices } from './ts-types';
|
|
8
8
|
/** The type-aware services, or `undefined` if the file is parsed without a program. */
|
|
@@ -14,6 +14,18 @@ export declare function typeServices(context: Rule.RuleContext): TypeServices |
|
|
|
14
14
|
export declare function suppressed(context: Rule.RuleContext, node: Rule.Node, ids: readonly string[]): boolean;
|
|
15
15
|
/** The symbol an ESTree node resolves to, with import aliases followed. */
|
|
16
16
|
export declare function symbolOf(node: Node | undefined, services: TypeServices, checker: TsTypeChecker): TsSymbol | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The member of `replacement` that takes the place of `member` of `original`: the one of the same name, else the one
|
|
19
|
+
* bound to the same function under another name. `undefined` if the replacement has none, `null` if it cannot be told.
|
|
20
|
+
*/
|
|
21
|
+
export declare function counterpartOf(original: Node, member: string, replacement: string, sourceCode: SourceCode, services: TypeServices): string | undefined | null;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `node` sits in a function the replacement would call, where putting the replacement in would make that
|
|
24
|
+
* function call itself. A replacement can only call what it names: a function is excluded if it is declared or bound
|
|
25
|
+
* under one of those names, or, with types, if one of them resolves to it, wired up under another name or in another
|
|
26
|
+
* file. Both ask the code alone, never file paths, which differ between systems and how a file was opened.
|
|
27
|
+
*/
|
|
28
|
+
export declare function insideReplacement(node: Node, replacement: string, sourceCode: SourceCode, services?: TypeServices): boolean;
|
|
17
29
|
/** The file a symbol is declared in. */
|
|
18
30
|
export declare function declarationFile(symbol: TsSymbol | undefined): string | undefined;
|
|
19
31
|
/** The text of the `@useInstead` tag of a symbol, `undefined` if it carries none. */
|
package/rules/util.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.typeServices = typeServices;
|
|
4
4
|
exports.suppressed = suppressed;
|
|
5
5
|
exports.symbolOf = symbolOf;
|
|
6
|
+
exports.counterpartOf = counterpartOf;
|
|
7
|
+
exports.insideReplacement = insideReplacement;
|
|
6
8
|
exports.declarationFile = declarationFile;
|
|
7
9
|
exports.useInsteadOf = useInsteadOf;
|
|
8
10
|
exports.isApplicable = isApplicable;
|
|
@@ -67,6 +69,109 @@ function symbolOf(node, services, checker) {
|
|
|
67
69
|
const symbol = tsNode ? checker.getSymbolAtLocation(tsNode) : undefined;
|
|
68
70
|
return symbol ? resolveAlias(symbol, checker) : undefined;
|
|
69
71
|
}
|
|
72
|
+
/** the name node a function is declared or bound under: a declaration, a member of an object or class, a variable, or an assignment */
|
|
73
|
+
function nameOf(fn, parent) {
|
|
74
|
+
if ((fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') && fn.id) {
|
|
75
|
+
return fn.id;
|
|
76
|
+
}
|
|
77
|
+
switch (parent?.type) {
|
|
78
|
+
case 'Property':
|
|
79
|
+
case 'MethodDefinition':
|
|
80
|
+
case 'PropertyDefinition':
|
|
81
|
+
return parent.key;
|
|
82
|
+
case 'VariableDeclarator':
|
|
83
|
+
return parent.id;
|
|
84
|
+
case 'AssignmentExpression':
|
|
85
|
+
return parent.left.type === 'MemberExpression' ? parent.left.property : parent.left;
|
|
86
|
+
default:
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** a symbol together with what it is bound to, so `frames` of `{ frames: framesOf }` also stands for `framesOf` */
|
|
91
|
+
function boundTo(symbol, checker) {
|
|
92
|
+
if (symbol === undefined) {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
const declaration = symbol.declarations?.[0];
|
|
96
|
+
const value = declaration?.initializer === undefined ? declaration && checker.getShorthandAssignmentValueSymbol(declaration) : checker.getSymbolAtLocation(declaration.initializer);
|
|
97
|
+
return value === undefined ? [symbol] : [symbol, resolveAlias(value, checker)];
|
|
98
|
+
}
|
|
99
|
+
/* per program, what its modules export under a name, for a replacement the file does not import yet */
|
|
100
|
+
const exportedNames = new WeakMap();
|
|
101
|
+
/** what `name` stands for at `node`: what is in scope there, else what a module of the program exports under it */
|
|
102
|
+
function rootSymbol(name, node, sourceCode, services) {
|
|
103
|
+
const checker = services.program.getTypeChecker();
|
|
104
|
+
let scope = sourceCode.getScope(node);
|
|
105
|
+
while (scope && !scope.set.has(name)) {
|
|
106
|
+
scope = scope.upper;
|
|
107
|
+
}
|
|
108
|
+
if (scope) {
|
|
109
|
+
return symbolOf(scope.set.get(name)?.defs[0]?.name, services, checker);
|
|
110
|
+
}
|
|
111
|
+
let known = exportedNames.get(services.program);
|
|
112
|
+
if (known === undefined) {
|
|
113
|
+
exportedNames.set(services.program, known = new Map());
|
|
114
|
+
}
|
|
115
|
+
if (!known.has(name)) {
|
|
116
|
+
const modules = services.program.getSourceFiles().map(file => checker.getSymbolAtLocation(file)).filter(m => m !== undefined);
|
|
117
|
+
known.set(name, modules.flatMap(m => checker.getExportsOfModule(m)).find(e => e.getName() === name));
|
|
118
|
+
}
|
|
119
|
+
return known.get(name);
|
|
120
|
+
}
|
|
121
|
+
/** the properties of what a symbol holds */
|
|
122
|
+
function propertiesOf(symbol, checker) {
|
|
123
|
+
const declaration = symbol?.declarations?.[0];
|
|
124
|
+
return symbol === undefined || declaration === undefined ? [] : checker.getTypeOfSymbolAtLocation(symbol, declaration).getProperties();
|
|
125
|
+
}
|
|
126
|
+
/** each link of a dotted name, `Dataflow.origin`, as the symbols it resolves to: the name and what it is bound to */
|
|
127
|
+
function resolveChain(chain, node, sourceCode, services) {
|
|
128
|
+
const checker = services.program.getTypeChecker();
|
|
129
|
+
const [root, ...members] = chain.split('.');
|
|
130
|
+
const links = [boundTo(rootSymbol(root, node, sourceCode, services), checker)];
|
|
131
|
+
for (const member of members) {
|
|
132
|
+
links.push(boundTo(propertiesOf(links[links.length - 1][0], checker).find(p => p.getName() === member), checker));
|
|
133
|
+
}
|
|
134
|
+
return links;
|
|
135
|
+
}
|
|
136
|
+
/** Every symbol the dotted names of a replacement resolve to, `REnvironment.frames` to `REnvironment`, its `frames`, and the function that member is bound to. */
|
|
137
|
+
function symbolsNamedBy(replacement, node, sourceCode, services) {
|
|
138
|
+
return new Set((replacement.match(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g) ?? []).flatMap(chain => resolveChain(chain, node, sourceCode, services).flat()));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The member of `replacement` that takes the place of `member` of `original`: the one of the same name, else the one
|
|
142
|
+
* bound to the same function under another name. `undefined` if the replacement has none, `null` if it cannot be told.
|
|
143
|
+
*/
|
|
144
|
+
function counterpartOf(original, member, replacement, sourceCode, services) {
|
|
145
|
+
const checker = services.program.getTypeChecker();
|
|
146
|
+
const target = resolveChain(replacement, original, sourceCode, services).at(-1)?.[0];
|
|
147
|
+
if (target === undefined) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const offered = propertiesOf(target, checker);
|
|
151
|
+
const was = boundTo(propertiesOf(symbolOf(original, services, checker), checker).find(p => p.getName() === member), checker);
|
|
152
|
+
return (offered.find(p => p.getName() === member) ?? offered.find(p => boundTo(p, checker).some(s => was.includes(s))))?.getName();
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether `node` sits in a function the replacement would call, where putting the replacement in would make that
|
|
156
|
+
* function call itself. A replacement can only call what it names: a function is excluded if it is declared or bound
|
|
157
|
+
* under one of those names, or, with types, if one of them resolves to it, wired up under another name or in another
|
|
158
|
+
* file. Both ask the code alone, never file paths, which differ between systems and how a file was opened.
|
|
159
|
+
*/
|
|
160
|
+
function insideReplacement(node, replacement, sourceCode, services) {
|
|
161
|
+
const names = new Set(replacement.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
|
162
|
+
const ancestors = sourceCode.getAncestors(node);
|
|
163
|
+
const functions = ancestors.flatMap((fn, i) => fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression' || fn.type === 'ArrowFunctionExpression' ? [nameOf(fn, ancestors[i - 1])] : [])
|
|
164
|
+
.filter((name) => name !== undefined);
|
|
165
|
+
if (functions.some(name => name.type === 'Identifier' && names.has(name.name))) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (services === undefined || functions.length === 0) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const called = symbolsNamedBy(replacement, node, sourceCode, services);
|
|
172
|
+
const checker = services.program.getTypeChecker();
|
|
173
|
+
return functions.some(name => boundTo(symbolOf(name, services, checker), checker).some(symbol => called.has(symbol)));
|
|
174
|
+
}
|
|
70
175
|
/** The file a symbol is declared in. */
|
|
71
176
|
function declarationFile(symbol) {
|
|
72
177
|
return symbol?.declarations?.[0]?.getSourceFile()?.fileName;
|