@eagleoutice/eslint-config-flowr 2.1.8 → 2.1.10

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.
@@ -28,6 +28,20 @@ function render(template, captures, node, sourceCode) {
28
28
  });
29
29
  return failed ? undefined : filled;
30
30
  }
31
+ /** per program, whether one of its files matches a `helperIn` path; every file of a run shares the program */
32
+ const helperFiles = new WeakMap();
33
+ function programHas(program, path) {
34
+ let known = helperFiles.get(program);
35
+ if (known === undefined) {
36
+ helperFiles.set(program, known = new Map());
37
+ }
38
+ let has = known.get(path);
39
+ if (has === undefined) {
40
+ has = program.getSourceFiles().some(file => file.fileName.replaceAll('\\', '/').includes(path));
41
+ known.set(path, has);
42
+ }
43
+ return has;
44
+ }
31
45
  const rule = {
32
46
  meta: {
33
47
  type: 'suggestion',
@@ -56,6 +70,8 @@ const rule = {
56
70
  replace: { type: 'string' },
57
71
  /** path to a substring of the file its symbol must be declared in, this is what keeps the pattern flowR-specific */
58
72
  declaredIn: { type: 'object', additionalProperties: { type: 'string' } },
73
+ /** path to a substring of the file the replacement's helper lives in, the pattern is silent in a program without it */
74
+ helperIn: { type: 'string' },
59
75
  /** pairs of paths that have to spell the same code, esquery has no back-references */
60
76
  sameText: { type: 'array', items: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 2 } },
61
77
  /** `true` forces the autofix, `false` forces a suggestion; by default it is fixed when the replacement is already in scope */
@@ -89,12 +105,12 @@ const rule = {
89
105
  bySelector.set(pattern.selector, [pattern]);
90
106
  }
91
107
  }
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
- */
108
+ /** The match only counts if every named node resolves to a declaration of the expected flowR file. */
96
109
  function isFlowr(node, pattern) {
97
- if (!pattern.declaredIn) {
110
+ if (pattern.helperIn !== undefined && (!services || !programHas(services.program, pattern.helperIn))) {
111
+ return false;
112
+ }
113
+ else if (!pattern.declaredIn) {
98
114
  return true;
99
115
  }
100
116
  else if (!services || !checker) {
@@ -102,7 +118,7 @@ const rule = {
102
118
  }
103
119
  return Object.entries(pattern.declaredIn).every(([path, expected]) => {
104
120
  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);
121
+ return file !== undefined && file.replaceAll('\\', '/').includes(expected);
106
122
  });
107
123
  }
108
124
  /** esquery cannot demand that two sub-nodes are the same, so the pattern names the pairs itself */
@@ -121,7 +137,10 @@ const rule = {
121
137
  const replacement = render(pattern.replace, captures, node, sourceCode);
122
138
  const message = (pattern.message === undefined ? undefined : render(pattern.message, captures, node, sourceCode))
123
139
  ?? (replacement ? `Use \`${replacement}\` here.` : `\`${pattern.id ?? pattern.selector}\` has a helper replacement.`);
124
- if (replacement === undefined) {
140
+ if (replacement !== undefined && (0, util_1.insideReplacement)(node, replacement, sourceCode, services)) {
141
+ return;
142
+ }
143
+ else if (replacement === undefined) {
125
144
  context.report({ node, messageId: 'replacement', data: { message } });
126
145
  return;
127
146
  }
@@ -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;
@@ -27,11 +29,16 @@ export interface TsTypeChecker {
27
29
  getSymbolAtLocation(node: unknown): TsSymbol | undefined;
28
30
  getAliasedSymbol(symbol: TsSymbol): TsSymbol;
29
31
  getTypeOfSymbolAtLocation(symbol: TsSymbol, location: TsDeclaration): TsType;
32
+ /** what a module exports, its symbol taken from the source file */
33
+ getExportsOfModule(module: TsSymbol): readonly TsSymbol[];
34
+ /** the variable a shorthand property `{ framesOf }` stands for */
35
+ getShorthandAssignmentValueSymbol(declaration: TsDeclaration): TsSymbol | undefined;
30
36
  }
31
37
  /** What typescript-eslint hands to a type-aware rule. */
32
38
  export interface TypeServices {
33
39
  program: {
34
40
  getTypeChecker(): TsTypeChecker;
41
+ getSourceFiles(): readonly TsSourceFile[];
35
42
  };
36
43
  esTreeNodeToTSNodeMap: {
37
44
  get(node: unknown): unknown;
@@ -35,10 +35,16 @@ function cachedMemberTags(symbol, checker) {
35
35
  memberCache.set(symbol, tags);
36
36
  return tags;
37
37
  }
38
- /** `{@link Resolve.toValue}` becomes `Resolve.toValue`, owned by `Resolve`. */
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, owner: display.split('.')[0] };
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, owner } = parseTarget(tag);
94
- if (owned.has(owner) || (0, util_1.suppressed)(context, node, ['use-instead'])) {
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 fix = (fixer) => fixer.replaceText(node, display);
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: display },
104
- ...display === '' ? {} : applicable ? { fix } : { suggest: [{ messageId: 'suggest', data: { replacement: display }, fix }] }
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
- if (declaration?.type !== 'Identifier') {
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,13 +3,15 @@ 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;
9
- const USE_INSTEAD_TAG = 'useInstead';
10
- const SUPPRESS_TAG = '@lintIgnore';
11
+ const UseInsteadTag = 'useInstead';
12
+ const SuppressTag = '@lintIgnore';
11
13
  /* ts.SymbolFlags.Alias, spelled out to keep typescript out of the dependencies */
12
- const ALIAS_FLAG = 1 << 21;
14
+ const AliasFlag = 1 << 21;
13
15
  /** The type-aware services, or `undefined` if the file is parsed without a program. */
14
16
  function typeServices(context) {
15
17
  const services = context.sourceCode.parserServices;
@@ -36,7 +38,7 @@ function silences(comment, ids) {
36
38
  */
37
39
  function suppressed(context, node, ids) {
38
40
  const sourceCode = context.sourceCode;
39
- if (!sourceCode.text.includes(SUPPRESS_TAG)) {
41
+ if (!sourceCode.text.includes(SuppressTag)) {
40
42
  return false;
41
43
  }
42
44
  /* a header comment marks the whole file: above the imports, or separated by a blank line */
@@ -59,7 +61,7 @@ function suppressed(context, node, ids) {
59
61
  }
60
62
  /** Resolves import aliases so the documentation of the original declaration is read. */
61
63
  function resolveAlias(symbol, checker) {
62
- return (symbol.flags & ALIAS_FLAG) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
64
+ return (symbol.flags & AliasFlag) !== 0 ? checker.getAliasedSymbol(symbol) : symbol;
63
65
  }
64
66
  /** The symbol an ESTree node resolves to, with import aliases followed. */
65
67
  function symbolOf(node, services, checker) {
@@ -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;
@@ -74,7 +179,7 @@ function declarationFile(symbol) {
74
179
  /** The text of the `@useInstead` tag of a symbol, `undefined` if it carries none. */
75
180
  function useInsteadOf(symbol, checker) {
76
181
  for (const tag of symbol.getJsDocTags(checker)) {
77
- if (tag.name === USE_INSTEAD_TAG) {
182
+ if (tag.name === UseInsteadTag) {
78
183
  return (tag.text ?? []).map(p => p.text).join('').trim();
79
184
  }
80
185
  }