@eagleoutice/eslint-config-flowr 2.0.2 → 2.1.1

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/patterns.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Default patterns of `flowr/replacement-pattern`, see the README for the format.
4
+ * The helpers they point at live in flowR: `DfEdge` in `dataflow/graph/edge`, the vertex helpers in
5
+ * `dataflow/graph/vertex`, and `NoEdges` in `dataflow/graph/graph`.
6
+ */
7
+ const EDGE = 'dataflow/graph/edge';
8
+ const VERTEX = 'dataflow/graph/vertex';
9
+ const GRAPH = 'dataflow/graph/graph';
10
+ /** `x.types` under the given path */
11
+ const types = (prefix) => `[${prefix}.type="MemberExpression"][${prefix}.computed=false][${prefix}.property.name="types"]`;
12
+ /** `x.tag` under the given path */
13
+ const tag = (prefix) => `[${prefix}.computed=false][${prefix}.property.name="tag"]`;
14
+ /** `EdgeType.<Member>` / `VertexType.<Member>` on the right */
15
+ const rightIs = (name) => `[right.type="MemberExpression"][right.computed=false][right.object.name="${name}"]`;
16
+ /** compared against the literal `0`, either for "some bit is set" or for "none is" */
17
+ const versusZero = (nonZero) => `BinaryExpression[operator=${nonZero ? '/^(!==|>)$/' : '"==="'}][right.type="Literal"][right.value=0]`;
18
+ /** the `x.types & T` on the left of such a comparison */
19
+ const mask = '[left.type="BinaryExpression"][left.operator="&"]';
20
+ /** a call of `<prefix>.<property>(...)` */
21
+ const call = (prefix, property) => `[${prefix}.type="CallExpression"][${prefix}.callee.type="MemberExpression"][${prefix}.callee.property.name="${property}"]`;
22
+ const destructuringHint = ' Prefer keeping the edge over destructuring `types`, the helpers take it directly and the wrapper object goes away.';
23
+ /** `x.types === T`, on the edge itself or on a destructured `types` */
24
+ const edgeIsOnly = (id, { negated = false, destructured = false } = {}) => ({
25
+ id,
26
+ selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"]`
27
+ + (destructured ? `[left.type="Identifier"][left.name="types"]${rightIs('EdgeType')}` : `${types('left')}:not([right.type="Literal"])`),
28
+ capture: destructured ? { type: 'right' } : { edge: 'left.object', type: 'right' },
29
+ replace: `${negated ? '!' : ''}DfEdge.isOnlyType(${destructured ? '{ types }' : '{{edge}}'}, {{type}})`,
30
+ declaredIn: destructured ? { 'right.object': EDGE } : { 'left.property': EDGE },
31
+ message: `\`${destructured ? '' : 'x.'}types ${negated ? '!==' : '==='} T\` `
32
+ + (negated ? 'holds as soon as any other type is set' : 'holds only if T is the *only* type')
33
+ + `; say so with \`${negated ? '!' : ''}DfEdge.isOnlyType\`, or use \`DfEdge.${negated ? 'doesNotIncludeType' : 'includesType'}\` for "${negated ? 'has not' : 'has'} this type".`
34
+ + (destructured ? destructuringHint : '')
35
+ });
36
+ /** `(x.types & T) !== 0` and its inverse */
37
+ const edgeMask = (id, helper, nonZero) => ({
38
+ id,
39
+ selector: `${versusZero(nonZero)}${mask}${types('left.left')}`,
40
+ capture: { edge: 'left.left.object', type: 'left.right' },
41
+ replace: `DfEdge.${helper}({{edge}}, {{type}})`,
42
+ declaredIn: { 'left.left.property': EDGE },
43
+ message: `Use \`DfEdge.${helper}\` instead of testing the bitmask by hand.`
44
+ });
45
+ /** `x.types !== 0`, the literal-0 comparisons {@link edgeIsOnly} leaves out */
46
+ const edgeStates = (id, helper, what, nonZero) => ({
47
+ id,
48
+ selector: `${versusZero(nonZero)}${types('left')}`,
49
+ capture: { edge: 'left.object' },
50
+ replace: `DfEdge.${helper}({{edge}})`,
51
+ declaredIn: { 'left.property': EDGE },
52
+ message: `Use \`DfEdge.${helper}\` to ask whether the edge states ${what}.`
53
+ });
54
+ /** `v.tag === VertexType.X`, with `{{type|last}}` spelling the name of the matching helper */
55
+ const vertexIs = (id, { negated = false, optional = false } = {}) => {
56
+ const vertex = optional ? 'left.expression.object' : 'left.object';
57
+ const left = optional ? 'left.expression' : 'left';
58
+ return {
59
+ id,
60
+ selector: `BinaryExpression[operator="${negated ? '!==' : '==='}"][left.type="${optional ? 'ChainExpression' : 'MemberExpression'}"]${tag(left)}${rightIs('VertexType')}`,
61
+ capture: { vertex, type: 'right' },
62
+ replace: `${negated ? '!' : ''}{{type|last}}Vertex.is({{vertex}})`,
63
+ declaredIn: { [`${left}.property`]: VERTEX, 'right.object': VERTEX },
64
+ message: 'Compare through the vertex helper (`{{type|last}}Vertex.is`), it narrows the type as well.'
65
+ };
66
+ };
67
+ /** `FunctionCallVertex.is(v) && v.origin.includes(T)`, the body of `hasOrigin` written out */
68
+ const hasOrigin = (id) => ({
69
+ id,
70
+ selector: 'LogicalExpression[operator="&&"]'
71
+ + `${call('left', 'is')}[left.callee.object.name="FunctionCallVertex"]`
72
+ + `${call('right', 'includes')}[right.callee.object.type="MemberExpression"][right.callee.object.property.name="origin"]`,
73
+ capture: { vertex: 'left.arguments.0', origin: 'right.arguments.0' },
74
+ replace: 'FunctionCallVertex.hasOrigin({{vertex}}, {{origin}})',
75
+ declaredIn: { 'left.callee.object': VERTEX, 'right.callee.object.property': VERTEX },
76
+ sameText: [['left.arguments.0', 'right.callee.object.object']],
77
+ /* `hasOrigin` is no type predicate, so the replacement can drop a narrowing the code below relies on */
78
+ fix: false,
79
+ message: 'Use `FunctionCallVertex.hasOrigin`, it is exactly this check. Check the narrowing first, `hasOrigin` returns a plain boolean.'
80
+ });
81
+ const patterns = [
82
+ edgeIsOnly('edge-is-only-type'),
83
+ edgeIsOnly('edge-is-not-only-type', { negated: true }),
84
+ edgeIsOnly('edge-is-only-type-destructured', { destructured: true }),
85
+ edgeIsOnly('edge-is-not-only-type-destructured', { negated: true, destructured: true }),
86
+ edgeStates('edge-has-any-type', 'hasAnyType', 'anything', true),
87
+ edgeStates('edge-has-no-type', 'hasNoType', 'nothing', false),
88
+ edgeMask('edge-includes-type', 'includesType', true),
89
+ edgeMask('edge-does-not-include-type', 'doesNotIncludeType', false),
90
+ {
91
+ id: 'edge-includes-type-truthy',
92
+ selector: `:matches(IfStatement, ConditionalExpression, WhileStatement, DoWhileStatement, LogicalExpression, UnaryExpression[operator="!"], ArrowFunctionExpression) > BinaryExpression[operator="&"]${types('left')}`,
93
+ capture: { edge: 'left.object', type: 'right' },
94
+ replace: 'DfEdge.includesType({{edge}}, {{type}})',
95
+ declaredIn: { 'left.property': EDGE },
96
+ message: 'Do not rely on the bitmask being truthy, `DfEdge.includesType` says what is meant.'
97
+ },
98
+ hasOrigin('vertex-has-origin'),
99
+ {
100
+ /* the `[]` fallback allocates on every miss, and these sit in traversal loops */
101
+ id: 'graph-edges-no-alloc',
102
+ selector: 'LogicalExpression[operator="??"][right.type="ArrayExpression"][right.elements.length=0][left.type="CallExpression"][left.callee.type="MemberExpression"][left.callee.property.name=/^(outgoingEdges|ingoingEdges)$/]',
103
+ capture: { edges: 'left' },
104
+ replace: '{{edges}} ?? NoEdges',
105
+ declaredIn: { 'left.callee.property': GRAPH },
106
+ message: 'The `[]` fallback allocates on every miss, use the shared `NoEdges`.'
107
+ },
108
+ vertexIs('vertex-is'),
109
+ vertexIs('vertex-is-not', { negated: true }),
110
+ vertexIs('vertex-is-optional', { optional: true }),
111
+ vertexIs('vertex-is-not-optional', { negated: true, optional: true })
112
+ ];
113
+ module.exports = patterns;
package/plugin.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ declare const plugin: {
2
+ meta: {
3
+ name: string;
4
+ };
5
+ rules: {
6
+ 'use-instead': import("eslint").Rule.RuleModule;
7
+ 'replacement-pattern': import("eslint").Rule.RuleModule;
8
+ };
9
+ };
10
+ export = plugin;
package/plugin.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /** The flowR-specific rules, see the README for the tags they read. */
6
+ const replacement_pattern_1 = __importDefault(require("./rules/replacement-pattern"));
7
+ const use_instead_1 = __importDefault(require("./rules/use-instead"));
8
+ const plugin = {
9
+ meta: { name: '@eagleoutice/eslint-config-flowr' },
10
+ rules: {
11
+ 'use-instead': use_instead_1.default,
12
+ 'replacement-pattern': replacement_pattern_1.default
13
+ }
14
+ };
15
+ module.exports = plugin;
@@ -0,0 +1,3 @@
1
+ import type { Rule } from 'eslint';
2
+ declare const rule: Rule.RuleModule;
3
+ export = rule;
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ const util_1 = require("./util");
3
+ /** Resolves a dotted path (`left.object`) relative to the matched node. */
4
+ function resolvePath(node, path) {
5
+ let current = node;
6
+ for (const step of path.split('.')) {
7
+ if (current === null || current === undefined) {
8
+ return undefined;
9
+ }
10
+ current = current[step];
11
+ }
12
+ return (current ?? undefined);
13
+ }
14
+ /**
15
+ * Fills `{{name}}` with the source text of a capture and `{{name|last}}` with the part behind its
16
+ * last dot. Returns `undefined` if a capture is missing.
17
+ */
18
+ function render(template, captures, node, sourceCode) {
19
+ let failed = false;
20
+ const filled = template.replace(/\{\{\s*([A-Za-z0-9_]+)\s*(?:\|\s*(last)\s*)?\}\}/g, (_all, name, filter) => {
21
+ const captured = captures[name] === undefined ? undefined : resolvePath(node, captures[name]);
22
+ if (captured === undefined) {
23
+ failed = true;
24
+ return '';
25
+ }
26
+ const text = sourceCode.getText(captured);
27
+ return filter === 'last' ? text.slice(text.lastIndexOf('.') + 1) : text;
28
+ });
29
+ return failed ? undefined : filled;
30
+ }
31
+ const rule = {
32
+ meta: {
33
+ type: 'suggestion',
34
+ fixable: 'code',
35
+ hasSuggestions: true,
36
+ docs: {
37
+ description: 'suggest the flowR helper that replaces a hand-written pattern',
38
+ url: 'https://github.com/flowr-analysis/flowr-lint#flowrreplacement-pattern'
39
+ },
40
+ schema: [{
41
+ type: 'object',
42
+ properties: {
43
+ patterns: {
44
+ type: 'array',
45
+ items: {
46
+ type: 'object',
47
+ properties: {
48
+ /** identifies the pattern in `@lintIgnore` and in the message */
49
+ id: { type: 'string' },
50
+ /** esquery selector, the language `no-restricted-syntax` uses */
51
+ selector: { type: 'string' },
52
+ message: { type: 'string' },
53
+ /** capture name to a path relative to the match */
54
+ capture: { type: 'object', additionalProperties: { type: 'string' } },
55
+ /** replacement template, filled from the captures */
56
+ replace: { type: 'string' },
57
+ /** path to a substring of the file its symbol must be declared in, this is what keeps the pattern flowR-specific */
58
+ declaredIn: { type: 'object', additionalProperties: { type: 'string' } },
59
+ /** pairs of paths that have to spell the same code, esquery has no back-references */
60
+ sameText: { type: 'array', items: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 2 } },
61
+ /** `true` forces the autofix, `false` forces a suggestion; by default it is fixed when the replacement is already in scope */
62
+ fix: { type: 'boolean' }
63
+ },
64
+ required: ['selector', 'replace'],
65
+ additionalProperties: false
66
+ }
67
+ }
68
+ },
69
+ additionalProperties: false
70
+ }],
71
+ messages: {
72
+ replacement: '{{message}}',
73
+ suggest: 'Replace with `{{replacement}}`.'
74
+ }
75
+ },
76
+ create(context) {
77
+ const sourceCode = context.sourceCode;
78
+ const services = (0, util_1.typeServices)(context);
79
+ const checker = services?.program.getTypeChecker();
80
+ const patterns = context.options[0]?.patterns ?? [];
81
+ /* several patterns may share a selector, so they are grouped instead of overwriting each other */
82
+ const bySelector = new Map();
83
+ for (const pattern of patterns) {
84
+ const group = bySelector.get(pattern.selector);
85
+ if (group) {
86
+ group.push(pattern);
87
+ }
88
+ else {
89
+ bySelector.set(pattern.selector, [pattern]);
90
+ }
91
+ }
92
+ /**
93
+ * The match only counts if every named node resolves to a declaration of the expected flowR file.
94
+ * The declaring file itself is exempt, it is where the helper is built.
95
+ */
96
+ function isFlowr(node, pattern) {
97
+ if (!pattern.declaredIn) {
98
+ return true;
99
+ }
100
+ else if (!services || !checker) {
101
+ return false;
102
+ }
103
+ return Object.entries(pattern.declaredIn).every(([path, expected]) => {
104
+ const file = (0, util_1.declarationFile)((0, util_1.symbolOf)(resolvePath(node, path), services, checker));
105
+ return file !== undefined && file !== context.filename && file.replaceAll('\\', '/').includes(expected);
106
+ });
107
+ }
108
+ /** esquery cannot demand that two sub-nodes are the same, so the pattern names the pairs itself */
109
+ function agrees(node, pattern) {
110
+ return (pattern.sameText ?? []).every(([a, b]) => {
111
+ const left = resolvePath(node, a), right = resolvePath(node, b);
112
+ return left !== undefined && right !== undefined && sourceCode.getText(left) === sourceCode.getText(right);
113
+ });
114
+ }
115
+ function check(node, pattern) {
116
+ const ids = [pattern.id, 'replacement-pattern'].filter(id => id !== undefined);
117
+ if (!agrees(node, pattern) || !isFlowr(node, pattern) || (0, util_1.suppressed)(context, node, ids)) {
118
+ return;
119
+ }
120
+ const captures = pattern.capture ?? {};
121
+ const replacement = render(pattern.replace, captures, node, sourceCode);
122
+ const message = (pattern.message === undefined ? undefined : render(pattern.message, captures, node, sourceCode))
123
+ ?? (replacement ? `Use \`${replacement}\` here.` : `\`${pattern.id ?? pattern.selector}\` has a helper replacement.`);
124
+ if (replacement === undefined) {
125
+ context.report({ node, messageId: 'replacement', data: { message } });
126
+ return;
127
+ }
128
+ const fix = (fixer) => fixer.replaceText(node, replacement);
129
+ /* fixed on the spot where the helper is already in scope, offered as a suggestion otherwise */
130
+ const applicable = pattern.fix ?? (0, util_1.isApplicable)(context, node, replacement);
131
+ context.report({
132
+ node,
133
+ messageId: 'replacement',
134
+ data: { message },
135
+ ...applicable ? { fix } : { suggest: [{ messageId: 'suggest', data: { replacement }, fix }] }
136
+ });
137
+ }
138
+ const listeners = {};
139
+ for (const [selector, group] of bySelector) {
140
+ listeners[selector] = (node) => group.forEach(pattern => check(node, pattern));
141
+ }
142
+ return listeners;
143
+ }
144
+ };
145
+ module.exports = rule;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The slice of the TypeScript compiler API the rules touch, spelled out structurally so that
3
+ * `typescript` stays out of this package's dependencies.
4
+ */
5
+ export interface TsSourceFile {
6
+ fileName: string;
7
+ }
8
+ export interface TsDeclaration {
9
+ getSourceFile(): TsSourceFile | undefined;
10
+ }
11
+ export interface TsJsDocTag {
12
+ name: string;
13
+ text?: readonly {
14
+ text: string;
15
+ }[];
16
+ }
17
+ export interface TsSymbol {
18
+ flags: number;
19
+ declarations?: readonly TsDeclaration[];
20
+ getName(): string;
21
+ getJsDocTags(checker: TsTypeChecker): readonly TsJsDocTag[];
22
+ }
23
+ export interface TsType {
24
+ getProperties(): readonly TsSymbol[];
25
+ }
26
+ export interface TsTypeChecker {
27
+ getSymbolAtLocation(node: unknown): TsSymbol | undefined;
28
+ getAliasedSymbol(symbol: TsSymbol): TsSymbol;
29
+ getTypeOfSymbolAtLocation(symbol: TsSymbol, location: TsDeclaration): TsType;
30
+ }
31
+ /** What typescript-eslint hands to a type-aware rule. */
32
+ export interface TypeServices {
33
+ program: {
34
+ getTypeChecker(): TsTypeChecker;
35
+ };
36
+ esTreeNodeToTSNodeMap: {
37
+ get(node: unknown): unknown;
38
+ };
39
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ /**
3
+ * The slice of the TypeScript compiler API the rules touch, spelled out structurally so that
4
+ * `typescript` stays out of this package's dependencies.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ import type { Rule } from 'eslint';
2
+ declare const rule: Rule.RuleModule;
3
+ export = rule;
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ const util_1 = require("./util");
3
+ /** documentation is read once per symbol and reused for every reference */
4
+ const tagCache = new WeakMap();
5
+ /** the `@useInstead` tags of all members of a helper object, keyed by the object's symbol */
6
+ const memberCache = new WeakMap();
7
+ function cachedTag(symbol, checker) {
8
+ let tag = tagCache.get(symbol);
9
+ if (tag === undefined) {
10
+ tag = (0, util_1.useInsteadOf)(symbol, checker) ?? null;
11
+ tagCache.set(symbol, tag);
12
+ }
13
+ return tag;
14
+ }
15
+ function cachedMemberTags(symbol, checker) {
16
+ const cached = memberCache.get(symbol);
17
+ if (cached) {
18
+ return cached;
19
+ }
20
+ const tags = new Map();
21
+ const declaration = symbol.declarations?.[0];
22
+ if (declaration) {
23
+ try {
24
+ for (const property of checker.getTypeOfSymbolAtLocation(symbol, declaration).getProperties()) {
25
+ const tag = (0, util_1.useInsteadOf)(property, checker);
26
+ if (tag !== undefined) {
27
+ tags.set(property.getName(), tag);
28
+ }
29
+ }
30
+ }
31
+ catch {
32
+ /* nothing to check without a resolvable type */
33
+ }
34
+ }
35
+ memberCache.set(symbol, tags);
36
+ return tags;
37
+ }
38
+ /** `{@link Resolve.toValue}` becomes `Resolve.toValue`, owned by `Resolve`. */
39
+ function parseTarget(tag) {
40
+ const display = tag.replace(/^\{\s*@link\s*/, '').replace(/\s*\|.*$/, '').replace(/\s*\}\s*$/, '').trim();
41
+ return { display, owner: display.split('.')[0] };
42
+ }
43
+ /** The references that build the replacement in the first place: helper-object wiring and re-exports. */
44
+ function isWiring(identifier) {
45
+ const parent = identifier.parent;
46
+ switch (parent?.type) {
47
+ case 'Property':
48
+ return parent.value === identifier && parent.parent?.type === 'ObjectExpression';
49
+ case 'SpreadElement':
50
+ return parent.parent?.type === 'ObjectExpression';
51
+ case 'ExportSpecifier':
52
+ case 'ImportSpecifier':
53
+ case 'ImportDefaultSpecifier':
54
+ case 'ImportNamespaceSpecifier':
55
+ return true;
56
+ default:
57
+ return false;
58
+ }
59
+ }
60
+ const rule = {
61
+ meta: {
62
+ type: 'suggestion',
63
+ fixable: 'code',
64
+ hasSuggestions: true,
65
+ docs: {
66
+ description: 'enforce the replacement named by the `@useInstead` documentation tag',
67
+ url: 'https://github.com/flowr-analysis/flowr-lint#flowruse-instead'
68
+ },
69
+ schema: [{
70
+ type: 'object',
71
+ properties: { helperObjectPattern: { type: 'string' } },
72
+ additionalProperties: false
73
+ }],
74
+ messages: {
75
+ useInstead: 'Do not use `{{name}}` directly, use `{{replacement}}` instead.',
76
+ useInsteadPlain: 'Do not use `{{name}}` directly, see its documentation for the replacement.',
77
+ suggest: 'Replace with `{{replacement}}`.'
78
+ }
79
+ },
80
+ create(context) {
81
+ const services = (0, util_1.typeServices)(context);
82
+ if (!services) {
83
+ return {};
84
+ }
85
+ const checker = services.program.getTypeChecker();
86
+ const sourceCode = context.sourceCode;
87
+ /* helper objects are PascalCase, so member checks stay cheap */
88
+ const helperObjectPattern = new RegExp(context.options[0]?.helperObjectPattern ?? '^[A-Z]');
89
+ const candidates = new Map();
90
+ /** the helper objects declared here, a file may use what it wraps */
91
+ const owned = new Set();
92
+ function report(node, name, tag) {
93
+ const { display, owner } = parseTarget(tag);
94
+ if (owned.has(owner) || (0, util_1.suppressed)(context, node, ['use-instead'])) {
95
+ return;
96
+ }
97
+ const fix = (fixer) => fixer.replaceText(node, display);
98
+ /* fixed on the spot where the helper is already in scope, offered as a suggestion otherwise */
99
+ const applicable = display !== '' && (0, util_1.isApplicable)(context, node, display);
100
+ context.report({
101
+ node,
102
+ messageId: display ? 'useInstead' : 'useInsteadPlain',
103
+ data: { name, replacement: display },
104
+ ...display === '' ? {} : applicable ? { fix } : { suggest: [{ messageId: 'suggest', data: { replacement: display }, fix }] }
105
+ });
106
+ }
107
+ return {
108
+ Program() {
109
+ const global = sourceCode.scopeManager.globalScope;
110
+ const module = global?.childScopes.find(s => s.type === 'module') ?? global;
111
+ const foreign = [];
112
+ for (const variable of module?.variables ?? []) {
113
+ const declaration = variable.defs[0]?.name;
114
+ if (declaration?.type !== 'Identifier') {
115
+ continue;
116
+ }
117
+ const symbol = (0, util_1.symbolOf)(declaration, services, checker);
118
+ if (!symbol) {
119
+ continue;
120
+ }
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
+ if (helperObjectPattern.test(variable.name)) {
127
+ candidates.set(variable.name, symbol);
128
+ }
129
+ const tag = cachedTag(symbol, checker);
130
+ if (tag !== null) {
131
+ foreign.push([variable, declaration, tag]);
132
+ }
133
+ }
134
+ for (const [variable, declaration, tag] of foreign) {
135
+ for (const { identifier } of variable.references) {
136
+ if (identifier !== declaration) {
137
+ const node = identifier;
138
+ if (!isWiring(node)) {
139
+ report(node, variable.name, tag);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ },
145
+ MemberExpression(node) {
146
+ if (node.computed || node.object.type !== 'Identifier' || node.property.type !== 'Identifier') {
147
+ return;
148
+ }
149
+ const symbol = candidates.get(node.object.name);
150
+ if (!symbol) {
151
+ return;
152
+ }
153
+ const tag = cachedMemberTags(symbol, checker).get(node.property.name);
154
+ if (tag !== undefined) {
155
+ report(node, `${node.object.name}.${node.property.name}`, tag);
156
+ }
157
+ }
158
+ };
159
+ }
160
+ };
161
+ module.exports = rule;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared helpers of the flowR rules.
3
+ * Tags: `@useInstead <target>` names a replacement, `@performanceCritical` and `@lintIgnore [ids]` silence the rules.
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ import type { Node } from 'estree';
7
+ import type { TsSymbol, TsTypeChecker, TypeServices } from './ts-types';
8
+ /** The type-aware services, or `undefined` if the file is parsed without a program. */
9
+ export declare function typeServices(context: Rule.RuleContext): TypeServices | undefined;
10
+ /**
11
+ * Whether `node` sits below a declaration documented with a suppressing tag, or in a file whose
12
+ * header comment carries one. Guarded by one string search, so untagged files pay nothing.
13
+ */
14
+ export declare function suppressed(context: Rule.RuleContext, node: Rule.Node, ids: readonly string[]): boolean;
15
+ /** The symbol an ESTree node resolves to, with import aliases followed. */
16
+ export declare function symbolOf(node: Node | undefined, services: TypeServices, checker: TsTypeChecker): TsSymbol | undefined;
17
+ /** The file a symbol is declared in. */
18
+ export declare function declarationFile(symbol: TsSymbol | undefined): string | undefined;
19
+ /** The text of the `@useInstead` tag of a symbol, `undefined` if it carries none. */
20
+ export declare function useInsteadOf(symbol: TsSymbol, checker: TsTypeChecker): string | undefined;
21
+ /**
22
+ * Whether the replacement can be applied on the spot: every name it adds over the matched code has to be
23
+ * bound already and usable as a value, a fixer cannot add the import that would be missing otherwise.
24
+ */
25
+ export declare function isApplicable(context: Rule.RuleContext, node: Node, replacement: string): boolean;
package/rules/util.js ADDED
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.typeServices = typeServices;
4
+ exports.suppressed = suppressed;
5
+ exports.symbolOf = symbolOf;
6
+ exports.declarationFile = declarationFile;
7
+ exports.useInsteadOf = useInsteadOf;
8
+ exports.isApplicable = isApplicable;
9
+ const USE_INSTEAD_TAG = 'useInstead';
10
+ const SUPPRESS_TAGS = ['@performanceCritical', '@lintIgnore'];
11
+ /* ts.SymbolFlags.Alias, spelled out to keep typescript out of the dependencies */
12
+ const ALIAS_FLAG = 1 << 21;
13
+ /** The type-aware services, or `undefined` if the file is parsed without a program. */
14
+ function typeServices(context) {
15
+ const services = context.sourceCode.parserServices;
16
+ return services?.program && services.esTreeNodeToTSNodeMap ? services : undefined;
17
+ }
18
+ function isDocComment(comment) {
19
+ return comment.type === 'Block' && comment.value.startsWith('*');
20
+ }
21
+ /** Whether the documentation comment silences the given rule or pattern id. */
22
+ function silences(comment, ids) {
23
+ if (!isDocComment(comment)) {
24
+ return false;
25
+ }
26
+ else if (comment.value.includes('@performanceCritical')) {
27
+ return true;
28
+ }
29
+ const ignore = /@lintIgnore([^\n@*]*)/.exec(comment.value);
30
+ if (!ignore) {
31
+ return false;
32
+ }
33
+ const listed = ignore[1].split(/[\s,]+/).filter(s => s.length > 0);
34
+ return listed.length === 0 || listed.some(id => ids.includes(id));
35
+ }
36
+ /**
37
+ * Whether `node` sits below a declaration documented with a suppressing tag, or in a file whose
38
+ * header comment carries one. Guarded by one string search, so untagged files pay nothing.
39
+ */
40
+ function suppressed(context, node, ids) {
41
+ const sourceCode = context.sourceCode;
42
+ if (!SUPPRESS_TAGS.some(tag => sourceCode.text.includes(tag))) {
43
+ return false;
44
+ }
45
+ /* a header comment marks the whole file: above the imports, or separated by a blank line */
46
+ const first = sourceCode.getAllComments()[0];
47
+ const top = sourceCode.ast.body[0];
48
+ if (first?.range && top?.range && first.range[1] < top.range[0]
49
+ && (top.type === 'ImportDeclaration' || /\n\s*\n/.test(sourceCode.text.slice(first.range[1], top.range[0])))
50
+ && silences(first, ids)) {
51
+ return true;
52
+ }
53
+ for (let current = node; current; current = current.parent) {
54
+ /* `export function f()` carries its documentation on the export */
55
+ const parent = current.parent;
56
+ const documented = parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration' ? parent : current;
57
+ if (sourceCode.getCommentsBefore(documented).some(c => silences(c, ids))) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+ /** Resolves import aliases so the documentation of the original declaration is read. */
64
+ function resolveAlias(symbol, checker) {
65
+ return (symbol.flags & ALIAS_FLAG) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
66
+ }
67
+ /** The symbol an ESTree node resolves to, with import aliases followed. */
68
+ function symbolOf(node, services, checker) {
69
+ const tsNode = node ? services.esTreeNodeToTSNodeMap.get(node) : undefined;
70
+ const symbol = tsNode ? checker.getSymbolAtLocation(tsNode) : undefined;
71
+ return symbol ? resolveAlias(symbol, checker) : undefined;
72
+ }
73
+ /** The file a symbol is declared in. */
74
+ function declarationFile(symbol) {
75
+ return symbol?.declarations?.[0]?.getSourceFile()?.fileName;
76
+ }
77
+ /** The text of the `@useInstead` tag of a symbol, `undefined` if it carries none. */
78
+ function useInsteadOf(symbol, checker) {
79
+ for (const tag of symbol.getJsDocTags(checker)) {
80
+ if (tag.name === USE_INSTEAD_TAG) {
81
+ return (tag.text ?? []).map(p => p.text).join('').trim();
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ /** the names a chunk of code depends on: the root of every member chain, `a.b(c)` gives `a` and `c` */
87
+ function rootNames(text) {
88
+ const names = new Set();
89
+ for (const [, name] of text.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*)/g)) {
90
+ names.add(name);
91
+ }
92
+ return names;
93
+ }
94
+ function isValueInScope(sourceCode, node, name) {
95
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
96
+ const variable = scope.set.get(name);
97
+ if (variable) {
98
+ /* a type-only binding of the same name is in scope but cannot be used as a value */
99
+ /* `Type` is typescript-eslint's own definition kind, which eslint's types do not know about */
100
+ return variable.defs.some(d => d.type !== 'Type'
101
+ && !(d.type === 'ImportBinding' && (isTypeOnly(d.node) || isTypeOnly(d.parent))));
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+ function isTypeOnly(node) {
107
+ return node?.importKind === 'type';
108
+ }
109
+ /**
110
+ * Whether the replacement can be applied on the spot: every name it adds over the matched code has to be
111
+ * bound already and usable as a value, a fixer cannot add the import that would be missing otherwise.
112
+ */
113
+ function isApplicable(context, node, replacement) {
114
+ const present = rootNames(context.sourceCode.getText(node));
115
+ return [...rootNames(replacement)].every(name => present.has(name) || isValueInScope(context.sourceCode, node, name));
116
+ }
package/tsdoc.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json",
3
+ "tagDefinitions": [
4
+ {
5
+ "tagName": "@useInstead",
6
+ "syntaxKind": "block"
7
+ },
8
+ {
9
+ "tagName": "@performanceCritical",
10
+ "syntaxKind": "modifier"
11
+ },
12
+ {
13
+ "tagName": "@lintIgnore",
14
+ "syntaxKind": "block"
15
+ }
16
+ ]
17
+ }