@blumintinc/eslint-plugin-blumint 1.20.152 → 1.20.154
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/lib/index.js +1 -1
- package/lib/rules/enforce-boolean-naming-prefixes.js +173 -0
- package/lib/rules/enforce-object-literal-as-const.js +31 -15
- package/lib/rules/global-const-style.js +149 -3
- package/lib/rules/no-explicit-return-type.js +136 -0
- package/lib/rules/parallelize-async-operations.js +75 -8
- package/lib/utils/docsFixtures.d.ts +89 -0
- package/lib/utils/docsFixtures.js +244 -0
- package/package.json +1 -1
- package/release-manifest.json +52 -0
package/lib/index.js
CHANGED
|
@@ -69,6 +69,111 @@ function memberNameOf(key) {
|
|
|
69
69
|
}
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
72
|
+
const EQUALITY_OPERATORS = new Set([
|
|
73
|
+
'===',
|
|
74
|
+
'!==',
|
|
75
|
+
'==',
|
|
76
|
+
'!=',
|
|
77
|
+
]);
|
|
78
|
+
/**
|
|
79
|
+
* The text of a literal string, written either way round: `'string'` and
|
|
80
|
+
* `` `string` `` assert the same thing about the operand beside them.
|
|
81
|
+
*/
|
|
82
|
+
function stringLiteralValueOf(node) {
|
|
83
|
+
if (node.type === utils_1.AST_NODE_TYPES.Literal && typeof node.value === 'string') {
|
|
84
|
+
return node.value;
|
|
85
|
+
}
|
|
86
|
+
if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
87
|
+
node.expressions.length === 0 &&
|
|
88
|
+
node.quasis.length === 1) {
|
|
89
|
+
return node.quasis[0].value.cooked;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The operand an equality comparison holds opposite `operand`, so operand
|
|
95
|
+
* order carries no meaning: `typeof x === 'string'` and
|
|
96
|
+
* `'string' === typeof x` are the same assertion.
|
|
97
|
+
*/
|
|
98
|
+
function comparedAgainst(comparison, operand) {
|
|
99
|
+
if (!EQUALITY_OPERATORS.has(comparison.operator))
|
|
100
|
+
return undefined;
|
|
101
|
+
if (comparison.left === operand)
|
|
102
|
+
return comparison.right;
|
|
103
|
+
if (comparison.right === operand)
|
|
104
|
+
return comparison.left;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The outermost node standing for the same value, so a contradiction written
|
|
109
|
+
* around `verdict!` or `verdict as string` is a contradiction about
|
|
110
|
+
* `verdict`.
|
|
111
|
+
*/
|
|
112
|
+
function passthroughValueOf(node) {
|
|
113
|
+
let current = node;
|
|
114
|
+
while (current.parent) {
|
|
115
|
+
const { parent } = current;
|
|
116
|
+
const wraps = (parent.type === utils_1.AST_NODE_TYPES.TSNonNullExpression ||
|
|
117
|
+
parent.type === utils_1.AST_NODE_TYPES.TSAsExpression ||
|
|
118
|
+
parent.type === utils_1.AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
119
|
+
parent.type === utils_1.AST_NODE_TYPES.TSTypeAssertion ||
|
|
120
|
+
parent.type === utils_1.AST_NODE_TYPES.ChainExpression) &&
|
|
121
|
+
parent.expression ===
|
|
122
|
+
current;
|
|
123
|
+
if (!wraps)
|
|
124
|
+
break;
|
|
125
|
+
current = parent;
|
|
126
|
+
}
|
|
127
|
+
return current;
|
|
128
|
+
}
|
|
129
|
+
/** `Error`, `TypeError` and any `…Error` class take a string message. */
|
|
130
|
+
function isErrorConstructor(callee) {
|
|
131
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
132
|
+
return callee.name.endsWith('Error');
|
|
133
|
+
}
|
|
134
|
+
if (callee.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
135
|
+
const member = memberNameOf(callee.property);
|
|
136
|
+
return !!member && member.name.endsWith('Error');
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether this reference uses the value in a way a boolean could not be
|
|
142
|
+
* used, which disproves a booleanness read off a name.
|
|
143
|
+
*/
|
|
144
|
+
function referenceContradictsBoolean(reference) {
|
|
145
|
+
const value = passthroughValueOf(reference);
|
|
146
|
+
const { parent } = value;
|
|
147
|
+
if (!parent)
|
|
148
|
+
return false;
|
|
149
|
+
// `typeof verdict === 'string'`. A tag of `'boolean'` AFFIRMS the boolean
|
|
150
|
+
// reading whichever equality operator carries it — `!== 'boolean'` is how
|
|
151
|
+
// a boolean guard is spelled — so only some other tag contradicts.
|
|
152
|
+
if (parent.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
|
153
|
+
parent.operator === 'typeof' &&
|
|
154
|
+
parent.argument === value) {
|
|
155
|
+
const comparison = parent.parent;
|
|
156
|
+
if (comparison?.type !== utils_1.AST_NODE_TYPES.BinaryExpression)
|
|
157
|
+
return false;
|
|
158
|
+
const other = comparedAgainst(comparison, parent);
|
|
159
|
+
const tag = other ? stringLiteralValueOf(other) : undefined;
|
|
160
|
+
return tag !== undefined && tag !== 'boolean';
|
|
161
|
+
}
|
|
162
|
+
// `verdict === 'occupied'` — a value compared with a string is not a
|
|
163
|
+
// boolean, since no boolean is ever equal to one.
|
|
164
|
+
if (parent.type === utils_1.AST_NODE_TYPES.BinaryExpression) {
|
|
165
|
+
const other = comparedAgainst(parent, value);
|
|
166
|
+
return !!other && stringLiteralValueOf(other) !== undefined;
|
|
167
|
+
}
|
|
168
|
+
// `throw new Error(verdict)` — the message parameter is a string, so the
|
|
169
|
+
// binding carries the failure reason rather than a verdict flag.
|
|
170
|
+
if (parent.type === utils_1.AST_NODE_TYPES.NewExpression &&
|
|
171
|
+
parent.arguments[0] === value &&
|
|
172
|
+
isErrorConstructor(parent.callee)) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
72
177
|
exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
73
178
|
name: 'enforce-boolean-naming-prefixes',
|
|
74
179
|
meta: {
|
|
@@ -914,6 +1019,66 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
914
1019
|
}
|
|
915
1020
|
return calleeReturnEvaluation(calleeName) !== 'nonBoolean';
|
|
916
1021
|
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Whether the only evidence that a binding holds a boolean is a NAME.
|
|
1024
|
+
*
|
|
1025
|
+
* `calleeReturnEvaluation` answers "does this callee demonstrably return a
|
|
1026
|
+
* non-boolean?"; its 'indeterminate' verdict is the case where the callee's
|
|
1027
|
+
* body is out of reach (an import, a parameter, a value read off a builder
|
|
1028
|
+
* chain) and the callee's `is`/`has`/`can` prefix is all that is left. A
|
|
1029
|
+
* boolean-sounding property (`state.isValid`) is the same kind of evidence.
|
|
1030
|
+
*
|
|
1031
|
+
* Everything else — an explicit `: boolean` annotation, a boolean literal, a
|
|
1032
|
+
* comparison or negation, a `Boolean()` coercion, a resolvable declaration
|
|
1033
|
+
* whose return classifies as boolean — is evidence about the VALUE, which no
|
|
1034
|
+
* use site is allowed to outrank.
|
|
1035
|
+
*/
|
|
1036
|
+
function booleanEvidenceIsNameOnly(declarator) {
|
|
1037
|
+
if (declarator.id.type !== utils_1.AST_NODE_TYPES.Identifier ||
|
|
1038
|
+
hasBooleanTypeAnnotation(declarator.id) ||
|
|
1039
|
+
!declarator.init) {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
const restsOnName = (expression) => {
|
|
1043
|
+
const value = unwrapChainExpression(expression);
|
|
1044
|
+
// A property name is the whole of the evidence in
|
|
1045
|
+
// `isLikelyBooleanByMemberExpression`, the only path that reads one.
|
|
1046
|
+
if (value.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
if (value.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
1050
|
+
value.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
1051
|
+
return (!isGlobalBooleanCall(value) &&
|
|
1052
|
+
calleeReturnEvaluation(value.callee.name) === 'indeterminate');
|
|
1053
|
+
}
|
|
1054
|
+
// `isFoo(x) || fallback` reaches booleanness through its left operand.
|
|
1055
|
+
if (value.type === utils_1.AST_NODE_TYPES.LogicalExpression &&
|
|
1056
|
+
value.operator === '||') {
|
|
1057
|
+
return restsOnName(value.left);
|
|
1058
|
+
}
|
|
1059
|
+
return false;
|
|
1060
|
+
};
|
|
1061
|
+
return restsOnName(declarator.init);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Whether any use of the binding contradicts booleanness.
|
|
1065
|
+
*
|
|
1066
|
+
* Validator families built on `ValidatorPipeline` return `true | string` —
|
|
1067
|
+
* `true` for a pass, the failure message for a fail — while
|
|
1068
|
+
* `enforce-is-prefix-validators` requires the validator itself to be
|
|
1069
|
+
* `is`-prefixed. Inferring the result's booleanness from that mandated
|
|
1070
|
+
* prefix makes the two rules unsatisfiable together, so a use site that
|
|
1071
|
+
* reads the value as a string settles it against the name.
|
|
1072
|
+
*
|
|
1073
|
+
* References come from the scope manager, never from matching the name as
|
|
1074
|
+
* text: a contradiction must belong to THIS binding, not to a shadowing
|
|
1075
|
+
* inner one, a sibling scope's binding, or an unrelated same-named value.
|
|
1076
|
+
*/
|
|
1077
|
+
function useSiteContradictsBoolean(declarator) {
|
|
1078
|
+
return context
|
|
1079
|
+
.getDeclaredVariables(declarator)
|
|
1080
|
+
.some((variable) => variable.references.some((reference) => referenceContradictsBoolean(reference.identifier)));
|
|
1081
|
+
}
|
|
917
1082
|
/**
|
|
918
1083
|
* Check if a variable is used in a while loop condition and is likely a DOM element or tree node
|
|
919
1084
|
* This helps identify variables like 'parent', 'element', 'node', etc. that are used
|
|
@@ -1233,6 +1398,14 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
1233
1398
|
utils_1.AST_NODE_TYPES.TSBooleanKeyword) {
|
|
1234
1399
|
isBooleanVar = true;
|
|
1235
1400
|
}
|
|
1401
|
+
// A booleanness read off a name loses to a use site that treats the value
|
|
1402
|
+
// as something else, which is what keeps this rule satisfiable alongside
|
|
1403
|
+
// `enforce-is-prefix-validators` for `true | string` validator verdicts.
|
|
1404
|
+
if (isBooleanVar &&
|
|
1405
|
+
booleanEvidenceIsNameOnly(node) &&
|
|
1406
|
+
useSiteContradictsBoolean(node)) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1236
1409
|
if (isBooleanVar && !hasApprovedPrefix(variableName)) {
|
|
1237
1410
|
context.report({
|
|
1238
1411
|
node: node.id,
|
|
@@ -207,25 +207,41 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
|
|
|
207
207
|
return undefined;
|
|
208
208
|
}
|
|
209
209
|
/**
|
|
210
|
-
* `as const` turns an array literal into a readonly *tuple*,
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
210
|
+
* `as const` turns an array literal into a fixed-length readonly *tuple*,
|
|
211
|
+
* strictly narrower than the mutable array the literal otherwise gets. Two
|
|
212
|
+
* separate breakages follow from that narrowing, and neither is visible at
|
|
213
|
+
* the literal:
|
|
214
|
+
*
|
|
215
|
+
* - Where the enclosing signature declares a mutable array or tuple, TS4104
|
|
216
|
+
* refuses the assignment, so appending `as const` breaks the build. No
|
|
217
|
+
* edit at the literal satisfies the rule — honouring it means rewriting
|
|
218
|
+
* the signature, a call the author has to make (#1526).
|
|
219
|
+
* - Where the signature is inferred, the frozen arity becomes part of the
|
|
220
|
+
* return type and every caller inherits it: `.length` narrows to a literal
|
|
221
|
+
* number (TS2367 against any other length), `.includes` narrows its
|
|
222
|
+
* parameter to the element union — `never` for `[]` — (TS2345), and the
|
|
223
|
+
* value stops satisfying a mutable `T[]` parameter. The break lands in a
|
|
224
|
+
* different function than the one edited, and the callers are beyond what
|
|
225
|
+
* the rule can see (#2015).
|
|
226
|
+
*
|
|
227
|
+
* So an array literal is left alone unless the enclosing signature states a
|
|
228
|
+
* type that accepts a readonly tuple. An annotation the rule cannot resolve
|
|
229
|
+
* still counts as accepting, per `acceptsReadonlyArray`: the annotation, not
|
|
230
|
+
* the literal, is what callers read, so the arity never escapes.
|
|
217
231
|
*
|
|
218
232
|
* Object literals are unaffected: `readonly` property modifiers do not
|
|
219
233
|
* enter assignability, so `{ a: 1 } as const` still satisfies a mutable
|
|
220
|
-
* `{ a: number }
|
|
234
|
+
* `{ a: number }`, and freezing one fixes no arity.
|
|
221
235
|
*/
|
|
222
|
-
function
|
|
236
|
+
function freezingArrayIsUnsafe(literal, ancestors) {
|
|
223
237
|
if (!isArrayLiteral(literal)) {
|
|
224
238
|
return false;
|
|
225
239
|
}
|
|
226
240
|
const enclosingFunction = enclosingFunctionOf(ancestors);
|
|
227
|
-
|
|
228
|
-
|
|
241
|
+
// With no declared return type in view, the inferred tuple is what the
|
|
242
|
+
// callers get.
|
|
243
|
+
if (!enclosingFunction || !declaredReturnTypeOf(enclosingFunction)) {
|
|
244
|
+
return true;
|
|
229
245
|
}
|
|
230
246
|
const returnedValueType = returnedValueTypeOf(enclosingFunction);
|
|
231
247
|
return !!returnedValueType && !acceptsReadonlyArray(returnedValueType);
|
|
@@ -285,10 +301,10 @@ exports.enforceObjectLiteralAsConst = (0, createRule_1.createRule)({
|
|
|
285
301
|
if (isInsideReactHook(ancestors) && isArrayLiteral(literal)) {
|
|
286
302
|
return;
|
|
287
303
|
}
|
|
288
|
-
// Skip arrays
|
|
289
|
-
//
|
|
290
|
-
// (#
|
|
291
|
-
if (
|
|
304
|
+
// Skip arrays whose enclosing signature does not accept the readonly
|
|
305
|
+
// tuple `as const` produces — declared mutable (#1526) or inferred, in
|
|
306
|
+
// which case the frozen arity reaches every caller (#2015)
|
|
307
|
+
if (freezingArrayIsUnsafe(literal, ancestors)) {
|
|
292
308
|
return;
|
|
293
309
|
}
|
|
294
310
|
// Report the issue and provide a fix
|
|
@@ -108,6 +108,139 @@ const isComponentFactoryCall = (node) => {
|
|
|
108
108
|
// the same terms as `const Row = (props) => {...}` (Issue #1681).
|
|
109
109
|
const isFunctionValue = (node) => node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
110
110
|
node.type === utils_1.AST_NODE_TYPES.FunctionExpression;
|
|
111
|
+
// `as const` does more than pin literal types: it makes the value deeply
|
|
112
|
+
// `readonly`. A binding that is written through after its declaration therefore
|
|
113
|
+
// cannot carry the assertion at all — appending it turns compiling code into
|
|
114
|
+
// `TS2339: Property 'push' does not exist on type 'readonly []'` for an array
|
|
115
|
+
// and `TS2540: Cannot assign to 'a' because it is a read-only property` for an
|
|
116
|
+
// object (Issue #2013). These are the built-in methods that mutate their
|
|
117
|
+
// receiver rather than returning a fresh value, so a call to one of them is a
|
|
118
|
+
// write even though no assignment target names the binding.
|
|
119
|
+
const MUTATING_METHOD_NAMES = new Set([
|
|
120
|
+
'push',
|
|
121
|
+
'pop',
|
|
122
|
+
'shift',
|
|
123
|
+
'unshift',
|
|
124
|
+
'splice',
|
|
125
|
+
'sort',
|
|
126
|
+
'reverse',
|
|
127
|
+
'fill',
|
|
128
|
+
'copyWithin',
|
|
129
|
+
]);
|
|
130
|
+
/**
|
|
131
|
+
* Climbs out of the wrappers that denote the same value as `node` — type
|
|
132
|
+
* wrappers (`(X as any).push()`, `X!.push()`) and the `ChainExpression` an
|
|
133
|
+
* optional access hangs on the outside of the whole chain (`delete X?.a`). The
|
|
134
|
+
* role a node plays in its statement is decided by the outermost such wrapper,
|
|
135
|
+
* so a classifier that reads `node.parent` directly answers for the wrapper
|
|
136
|
+
* instead of the access.
|
|
137
|
+
*/
|
|
138
|
+
const outermostValueOf = (node) => {
|
|
139
|
+
let current = node;
|
|
140
|
+
for (;;) {
|
|
141
|
+
const parent = current.parent;
|
|
142
|
+
if (parent &&
|
|
143
|
+
((isValueWrapper(parent) && parent.expression === current) ||
|
|
144
|
+
(parent.type === utils_1.AST_NODE_TYPES.ChainExpression &&
|
|
145
|
+
parent.expression === current))) {
|
|
146
|
+
current = parent;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
return current;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The outermost property-access path rooted at `identifier`: `X` in `X.a.b`
|
|
154
|
+
* yields the `X.a.b` member expression. Returns `null` when the identifier is
|
|
155
|
+
* not the base of any access, which is every reference that merely reads the
|
|
156
|
+
* binding as a value — `other.push(X)` passes it as an ARGUMENT, so the
|
|
157
|
+
* mutation happens to `other`, not to `X`.
|
|
158
|
+
*
|
|
159
|
+
* The climb stops at the first parent that is not a member access on the
|
|
160
|
+
* current node, so `X.map(f).push(1)` yields `X.map`: the mutated receiver
|
|
161
|
+
* there is the array `map` returned, not `X`.
|
|
162
|
+
*/
|
|
163
|
+
const accessPathOf = (identifier) => {
|
|
164
|
+
let current = outermostValueOf(identifier);
|
|
165
|
+
let path = null;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const parent = current.parent;
|
|
168
|
+
if (!parent ||
|
|
169
|
+
parent.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
170
|
+
parent.object !== current) {
|
|
171
|
+
return path;
|
|
172
|
+
}
|
|
173
|
+
path = parent;
|
|
174
|
+
current = outermostValueOf(parent);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** The property name an access reads, for `X.push` and `X['push']` alike. */
|
|
178
|
+
const accessedPropertyName = (path) => {
|
|
179
|
+
if (!path.computed && path.property.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
180
|
+
return path.property.name;
|
|
181
|
+
}
|
|
182
|
+
if (path.computed &&
|
|
183
|
+
path.property.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
184
|
+
typeof path.property.value === 'string') {
|
|
185
|
+
return path.property.value;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
const isMutatingMethodCall = (path) => {
|
|
190
|
+
const propertyName = accessedPropertyName(path);
|
|
191
|
+
if (propertyName === null || !MUTATING_METHOD_NAMES.has(propertyName)) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const callee = outermostValueOf(path);
|
|
195
|
+
return (callee.parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
196
|
+
callee.parent.callee === callee);
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Whether `node` sits in a position that writes to it: the left of an
|
|
200
|
+
* assignment (plain or compound), the operand of `++`/`--` or `delete`, the
|
|
201
|
+
* loop variable of `for…in`/`for…of`, or a slot in a destructuring assignment
|
|
202
|
+
* target (`[X.a] = […]`, `({ p: X.a } = …)`).
|
|
203
|
+
*/
|
|
204
|
+
const isWriteTarget = (node) => {
|
|
205
|
+
const value = outermostValueOf(node);
|
|
206
|
+
const parent = value.parent;
|
|
207
|
+
if (!parent) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
switch (parent.type) {
|
|
211
|
+
case utils_1.AST_NODE_TYPES.AssignmentExpression:
|
|
212
|
+
return parent.left === value;
|
|
213
|
+
case utils_1.AST_NODE_TYPES.UpdateExpression:
|
|
214
|
+
return parent.argument === value;
|
|
215
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
216
|
+
return parent.operator === 'delete' && parent.argument === value;
|
|
217
|
+
case utils_1.AST_NODE_TYPES.ForInStatement:
|
|
218
|
+
case utils_1.AST_NODE_TYPES.ForOfStatement:
|
|
219
|
+
return parent.left === value;
|
|
220
|
+
// Destructuring targets nest, so the answer belongs to the pattern's own
|
|
221
|
+
// position. The same node types appear in ObjectExpression/ArrayExpression
|
|
222
|
+
// VALUES, where the recursion reaches a non-assignment parent and stops.
|
|
223
|
+
case utils_1.AST_NODE_TYPES.ArrayPattern:
|
|
224
|
+
case utils_1.AST_NODE_TYPES.ObjectPattern:
|
|
225
|
+
case utils_1.AST_NODE_TYPES.Property:
|
|
226
|
+
case utils_1.AST_NODE_TYPES.RestElement:
|
|
227
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
228
|
+
return isWriteTarget(parent);
|
|
229
|
+
default:
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Whether the binding is written through anywhere in the file. Answered from
|
|
235
|
+
* the scope manager's reference list rather than a textual search for the
|
|
236
|
+
* name, so a same-named binding in another scope (`const arr` shadowed inside a
|
|
237
|
+
* callback) contributes nothing, and a same-named method on an unrelated
|
|
238
|
+
* receiver (`other.push(1)`) is never even visited.
|
|
239
|
+
*/
|
|
240
|
+
const isBindingMutated = (variable) => variable.references.some((reference) => {
|
|
241
|
+
const path = accessPathOf(reference.identifier);
|
|
242
|
+
return path !== null && (isMutatingMethodCall(path) || isWriteTarget(path));
|
|
243
|
+
});
|
|
111
244
|
/**
|
|
112
245
|
* Walks the scope chain upward from `scope` (inclusive) and reports whether
|
|
113
246
|
* `targetName` is bound anywhere between `scope` and `stopScope` (inclusive).
|
|
@@ -366,9 +499,22 @@ exports.default = (0, createRule_1.createRule)({
|
|
|
366
499
|
(target.value === null || typeof target.value === 'boolean')) {
|
|
367
500
|
return false;
|
|
368
501
|
}
|
|
369
|
-
|
|
370
|
-
target.type
|
|
371
|
-
target.type
|
|
502
|
+
if (target.type !== utils_1.AST_NODE_TYPES.Literal &&
|
|
503
|
+
target.type !== utils_1.AST_NODE_TYPES.ArrayExpression &&
|
|
504
|
+
target.type !== utils_1.AST_NODE_TYPES.ObjectExpression) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
// A binding that is mutated later can never take the assertion:
|
|
508
|
+
// `as const` types the value `readonly`, so the appended text
|
|
509
|
+
// turns working code into TS2339/TS2540 (Issue #2013). The
|
|
510
|
+
// report is withheld rather than merely the fix, on the same
|
|
511
|
+
// terms as the `null`/boolean carve-out above — a violation no
|
|
512
|
+
// legal edit can clear is not a violation. The rename is a
|
|
513
|
+
// separate concern and still applies.
|
|
514
|
+
const declaredVariable = context
|
|
515
|
+
.getDeclaredVariables(declaration)
|
|
516
|
+
.find((variable) => variable.name === name);
|
|
517
|
+
return !declaredVariable || !isBindingMutated(declaredVariable);
|
|
372
518
|
};
|
|
373
519
|
if (shouldHaveAsConst(init)) {
|
|
374
520
|
context.report({
|
|
@@ -713,6 +713,104 @@ function declaresVoidResult(returnType) {
|
|
|
713
713
|
return (typeArguments?.length === 1 &&
|
|
714
714
|
typeArguments[0].type === utils_1.AST_NODE_TYPES.TSVoidKeyword);
|
|
715
715
|
}
|
|
716
|
+
// TypeScript's built-in decorator signatures. A factory annotated with one of
|
|
717
|
+
// these is the one shape where the annotation is WIDER than what inference
|
|
718
|
+
// produces rather than a restatement of it: `MethodDecorator` accepts three
|
|
719
|
+
// parameters, the returned closure typically declares none, and a decoration
|
|
720
|
+
// site requires the declared arity. Stripping the annotation therefore turns
|
|
721
|
+
// every `@Factory()` use into TS1329 (#2014).
|
|
722
|
+
const DECORATOR_TYPE_NAMES = new Set([
|
|
723
|
+
'ClassDecorator',
|
|
724
|
+
'MethodDecorator',
|
|
725
|
+
'ParameterDecorator',
|
|
726
|
+
'PropertyDecorator',
|
|
727
|
+
]);
|
|
728
|
+
/**
|
|
729
|
+
* The identifier a type name resolves to. A qualified name (`ts.MethodDecorator`)
|
|
730
|
+
* denotes the type its right-most segment names, so that segment is what decides
|
|
731
|
+
* — a substring test over the printed annotation would equally match
|
|
732
|
+
* `MyMethodDecoratorConfig`, which is an unrelated user type.
|
|
733
|
+
*/
|
|
734
|
+
function rightmostTypeName(typeName) {
|
|
735
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
736
|
+
return typeName.name;
|
|
737
|
+
}
|
|
738
|
+
if (typeName.type === utils_1.AST_NODE_TYPES.TSQualifiedName) {
|
|
739
|
+
return rightmostTypeName(typeName.right);
|
|
740
|
+
}
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
function namesDecoratorType(annotation) {
|
|
744
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSTypeReference) {
|
|
745
|
+
const name = rightmostTypeName(annotation.typeName);
|
|
746
|
+
return name !== undefined && DECORATOR_TYPE_NAMES.has(name);
|
|
747
|
+
}
|
|
748
|
+
// A factory usable in more than one position (`ClassDecorator &
|
|
749
|
+
// MethodDecorator`) still owes every decoration site the declared shape.
|
|
750
|
+
if (annotation.type === utils_1.AST_NODE_TYPES.TSUnionType ||
|
|
751
|
+
annotation.type === utils_1.AST_NODE_TYPES.TSIntersectionType) {
|
|
752
|
+
return annotation.types.some(namesDecoratorType);
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* The identifier a CALLED decorator invokes: `Log` for `@Log()` and `@Log()()`.
|
|
758
|
+
*
|
|
759
|
+
* Only a called decorator identifies a factory, and only a factory's return type
|
|
760
|
+
* is what the decoration site consumes. A bare `@Log` names the decorator
|
|
761
|
+
* itself, whose annotation restates the value it returns exactly as inference
|
|
762
|
+
* would — so it stays reportable rather than being silenced by proximity to a
|
|
763
|
+
* decorator.
|
|
764
|
+
*
|
|
765
|
+
* An owner-qualified decorator (`@registry.log()`) names a property rather than
|
|
766
|
+
* a binding, and matching it by property name alone would silence the rule on
|
|
767
|
+
* every unrelated method of the same name, so it yields nothing.
|
|
768
|
+
*/
|
|
769
|
+
function decoratorFactoryIdentifier(expression) {
|
|
770
|
+
if (expression.type !== utils_1.AST_NODE_TYPES.CallExpression)
|
|
771
|
+
return undefined;
|
|
772
|
+
const callee = expression.callee;
|
|
773
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
774
|
+
return callee;
|
|
775
|
+
}
|
|
776
|
+
return decoratorFactoryIdentifier(callee);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The declarations invoked by a decorator in this file.
|
|
780
|
+
*
|
|
781
|
+
* This catches the factory whose annotation is a user-defined decorator type
|
|
782
|
+
* (`type Cached = (t: object, k: string, d: PropertyDescriptor) => void`), which
|
|
783
|
+
* no name test can recognise. Each identifier is resolved through the scope
|
|
784
|
+
* manager rather than compared by name, so a same-named binding elsewhere in the
|
|
785
|
+
* file cannot silence the rule on a function no decorator actually reaches.
|
|
786
|
+
*/
|
|
787
|
+
function decoratorReferencedDeclarations(source, visitorKeys) {
|
|
788
|
+
const heads = new Set();
|
|
789
|
+
const stack = [source.ast];
|
|
790
|
+
while (stack.length > 0) {
|
|
791
|
+
const current = stack.pop();
|
|
792
|
+
if (current.type === utils_1.AST_NODE_TYPES.Decorator) {
|
|
793
|
+
const head = decoratorFactoryIdentifier(current.expression);
|
|
794
|
+
if (head) {
|
|
795
|
+
heads.add(head);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
pushChildren(current, visitorKeys, stack);
|
|
799
|
+
}
|
|
800
|
+
const declarations = new Set();
|
|
801
|
+
if (heads.size === 0)
|
|
802
|
+
return declarations;
|
|
803
|
+
for (const scope of source.scopeManager?.scopes ?? []) {
|
|
804
|
+
for (const reference of scope.references) {
|
|
805
|
+
if (!heads.has(reference.identifier))
|
|
806
|
+
continue;
|
|
807
|
+
for (const definition of reference.resolved?.defs ?? []) {
|
|
808
|
+
declarations.add(definition.node);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return declarations;
|
|
813
|
+
}
|
|
716
814
|
function containsRange(outer, inner) {
|
|
717
815
|
return inner[0] >= outer[0] && inner[1] <= outer[1];
|
|
718
816
|
}
|
|
@@ -967,6 +1065,40 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
967
1065
|
// Edges are resolved lazily, and only for functions a direct
|
|
968
1066
|
// self-reference has already failed to explain.
|
|
969
1067
|
const participatesInReturnCycle = createReturnCycleResolver(visitorKeys);
|
|
1068
|
+
// Decorators are visited after the functions they name — a class body is
|
|
1069
|
+
// walked long after the top-level factory it decorates with — so the
|
|
1070
|
+
// answer is computed from the whole tree rather than accumulated during
|
|
1071
|
+
// the walk, and memoised because most files hold no decorator at all.
|
|
1072
|
+
let decoratedDeclarations;
|
|
1073
|
+
const declarationsNamedByDecorators = () => {
|
|
1074
|
+
decoratedDeclarations ??= decoratorReferencedDeclarations(sourceCode, visitorKeys);
|
|
1075
|
+
return decoratedDeclarations;
|
|
1076
|
+
};
|
|
1077
|
+
/**
|
|
1078
|
+
* True when the annotation is what makes the function usable in a
|
|
1079
|
+
* decorator position. TypeScript infers the concrete closure the factory
|
|
1080
|
+
* returns — `() => void` for `return () => {};` — which declares fewer
|
|
1081
|
+
* parameters than a decoration site passes, so removing the annotation
|
|
1082
|
+
* turns every `@Factory()` use into TS1329 (#2014).
|
|
1083
|
+
*
|
|
1084
|
+
* The question is answered syntactically. A `RuleTester` fixture carries
|
|
1085
|
+
* no `parserOptions.project`, so a type-based answer would be untestable
|
|
1086
|
+
* and would silently no-op wherever consumers lint without a program.
|
|
1087
|
+
*/
|
|
1088
|
+
function isDecoratorFactory(node, returnType) {
|
|
1089
|
+
if (namesDecoratorType(returnType.typeAnnotation))
|
|
1090
|
+
return true;
|
|
1091
|
+
const declarations = declarationsNamedByDecorators();
|
|
1092
|
+
if (declarations.size === 0)
|
|
1093
|
+
return false;
|
|
1094
|
+
if (declarations.has(node))
|
|
1095
|
+
return true;
|
|
1096
|
+
// `const Log = (): Cached => ...` is bound by its declarator, which is
|
|
1097
|
+
// what a decorator's identifier resolves to.
|
|
1098
|
+
const parent = node.parent;
|
|
1099
|
+
return (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclarator &&
|
|
1100
|
+
declarations.has(parent));
|
|
1101
|
+
}
|
|
970
1102
|
/**
|
|
971
1103
|
* True when TypeScript cannot infer the return type because the function
|
|
972
1104
|
* is referenced from within its own return expression (TS7023). Removing
|
|
@@ -1100,6 +1232,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
1100
1232
|
if (isTypeGuardFunction(node) ||
|
|
1101
1233
|
isReadonlyWideningReturnType(returnType) ||
|
|
1102
1234
|
isAllowedVoidReturnType(returnType) ||
|
|
1235
|
+
isDecoratorFactory(node, returnType) ||
|
|
1103
1236
|
(mergedOptions.allowRecursiveFunctions &&
|
|
1104
1237
|
isRecursiveFunction(node)) ||
|
|
1105
1238
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -1117,6 +1250,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
1117
1250
|
if (isTypeGuardFunction(node) ||
|
|
1118
1251
|
isReadonlyWideningReturnType(returnType) ||
|
|
1119
1252
|
isAllowedVoidReturnType(returnType) ||
|
|
1253
|
+
isDecoratorFactory(node, returnType) ||
|
|
1120
1254
|
(mergedOptions.allowRecursiveFunctions &&
|
|
1121
1255
|
isRecursiveFunction(node)) ||
|
|
1122
1256
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -1131,6 +1265,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
1131
1265
|
if (isTypeGuardFunction(node) ||
|
|
1132
1266
|
isReadonlyWideningReturnType(returnType) ||
|
|
1133
1267
|
isAllowedVoidReturnType(returnType) ||
|
|
1268
|
+
isDecoratorFactory(node, returnType) ||
|
|
1134
1269
|
isReturnTypeRequiredByRecursion(node)) {
|
|
1135
1270
|
return;
|
|
1136
1271
|
}
|
|
@@ -1156,6 +1291,7 @@ exports.noExplicitReturnType = (0, createRule_1.createRule)({
|
|
|
1156
1291
|
if (isTypeGuardFunction(node.value) ||
|
|
1157
1292
|
isReadonlyWideningReturnType(returnType) ||
|
|
1158
1293
|
isAllowedVoidReturnType(returnType) ||
|
|
1294
|
+
isDecoratorFactory(node, returnType) ||
|
|
1159
1295
|
(mergedOptions.allowAbstractMethodSignatures &&
|
|
1160
1296
|
isInterfaceOrAbstractMethodSignature(node)) ||
|
|
1161
1297
|
isReturnTypeRequiredByRecursion(node)) {
|
|
@@ -1127,9 +1127,54 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1127
1127
|
return variable.defs.every((definition) => definition.name.range[0] >= root.range[0] &&
|
|
1128
1128
|
definition.name.range[1] <= root.range[1]);
|
|
1129
1129
|
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Records the instance slot a method call MUTATES THROUGH ITS RECEIVER.
|
|
1132
|
+
* `this.accumulated.set(doc, 1)` publishes to `this.accumulated` exactly as
|
|
1133
|
+
* `this.accumulated = next` does, but it is a CallExpression rather than an
|
|
1134
|
+
* assignment, so the assignment-shaped visit below never sees it. A sweep
|
|
1135
|
+
* that fills an accumulator through the accumulator's own API then reads as
|
|
1136
|
+
* writing nothing, a later await that reads that slot is classified
|
|
1137
|
+
* independent, and the rewrite runs the read against the still-empty
|
|
1138
|
+
* accumulator. (#2017)
|
|
1139
|
+
*
|
|
1140
|
+
* ANY method invoked on the slot counts, rather than a list of known
|
|
1141
|
+
* mutators. The receiver is already the unit barrier 7 treats as ordered,
|
|
1142
|
+
* and a domain `append`/`record`/`write` mutates its receiver exactly as
|
|
1143
|
+
* `set` does, so naming a subset would leave the same silent reorder
|
|
1144
|
+
* reachable under a different spelling. Over-recording a pure
|
|
1145
|
+
* `this.cache.size()` costs only a missed parallelization, which is the
|
|
1146
|
+
* trade this rule takes everywhere.
|
|
1147
|
+
*
|
|
1148
|
+
* Only calls in DEFERRED position qualify -- those the traversal reaches by
|
|
1149
|
+
* crossing into a callback or a resolved callee body. A call spelled in the
|
|
1150
|
+
* operand's own text already carries a receiver key, so barriers 7 and 12
|
|
1151
|
+
* order it with carve-outs calibrated against exactly this: they return no
|
|
1152
|
+
* key for a call-produced receiver (`this.realtimeDb.ref(pathA).remove()`)
|
|
1153
|
+
* or a varying subscript (`this.handlers[0].read()`), which is what keeps
|
|
1154
|
+
* two argument-disambiguated operations on one handle parallelizable.
|
|
1155
|
+
* Recording those same calls here would mint a write on the shared prefix
|
|
1156
|
+
* and silently override that calibration. Behind a callback no receiver key
|
|
1157
|
+
* exists at the operand level at all, so nothing is overridden -- that is
|
|
1158
|
+
* the blind spot, and its whole extent.
|
|
1159
|
+
*
|
|
1160
|
+
* The BARE instance (`this.storeAll()`) is excluded for the same reason:
|
|
1161
|
+
* recording it would mint a wildcard write overlapping every slot, turning
|
|
1162
|
+
* the precise treatment those barriers give it into a blanket one.
|
|
1163
|
+
*/
|
|
1164
|
+
function collectMutatedReceiver(call, targets) {
|
|
1165
|
+
const callee = unwrapExpression(call.callee);
|
|
1166
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const receiverPath = getInstancePathKey(callee.object);
|
|
1170
|
+
if (receiverPath !== null && receiverPath !== INSTANCE_RECEIVER_KEY) {
|
|
1171
|
+
targets.instancePaths.push(receiverPath);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1130
1174
|
/**
|
|
1131
1175
|
* Collects the state an awaited expression WRITES: the identifier names it
|
|
1132
|
-
* assigns, and the instance paths (`this.mutator`) it assigns
|
|
1176
|
+
* assigns, and the instance paths (`this.mutator`) it assigns or mutates
|
|
1177
|
+
* through a method call. (#1924, #2017)
|
|
1133
1178
|
*
|
|
1134
1179
|
* The traversal deliberately crosses function boundaries, which is the
|
|
1135
1180
|
* opposite of what containsSuspendingAwait needs: the write that matters
|
|
@@ -1144,7 +1189,7 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1144
1189
|
*/
|
|
1145
1190
|
function getAssignedState(node) {
|
|
1146
1191
|
const targets = { identifiers: [], instancePaths: [] };
|
|
1147
|
-
const visit = (current) => {
|
|
1192
|
+
const visit = (current, deferred) => {
|
|
1148
1193
|
if (current.type === utils_1.AST_NODE_TYPES.AssignmentExpression) {
|
|
1149
1194
|
collectAssignmentTarget(current.left, targets);
|
|
1150
1195
|
}
|
|
@@ -1158,6 +1203,10 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1158
1203
|
// iteration; only the declaration form introduces a fresh local.
|
|
1159
1204
|
collectAssignmentTarget(current.left, targets);
|
|
1160
1205
|
}
|
|
1206
|
+
else if (deferred && current.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
|
1207
|
+
collectMutatedReceiver(current, targets);
|
|
1208
|
+
}
|
|
1209
|
+
const childrenDeferred = deferred || FUNCTION_BOUNDARY_TYPES.has(current.type);
|
|
1161
1210
|
for (const key in current) {
|
|
1162
1211
|
if (key === 'parent' || key === 'range' || key === 'loc')
|
|
1163
1212
|
continue;
|
|
@@ -1167,16 +1216,19 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1167
1216
|
if (Array.isArray(child)) {
|
|
1168
1217
|
for (const item of child) {
|
|
1169
1218
|
if (item && typeof item === 'object' && 'type' in item) {
|
|
1170
|
-
visit(item);
|
|
1219
|
+
visit(item, childrenDeferred);
|
|
1171
1220
|
}
|
|
1172
1221
|
}
|
|
1173
1222
|
}
|
|
1174
1223
|
else if ('type' in child) {
|
|
1175
|
-
visit(child);
|
|
1224
|
+
visit(child, childrenDeferred);
|
|
1176
1225
|
}
|
|
1177
1226
|
}
|
|
1178
1227
|
};
|
|
1179
|
-
|
|
1228
|
+
// A resolved callee body is itself deferred relative to the run, and
|
|
1229
|
+
// entering it crosses its own function boundary, so the flag lifts here
|
|
1230
|
+
// exactly as it does for a callback. (#1989, #2017)
|
|
1231
|
+
visit(node, false);
|
|
1180
1232
|
const names = new Set();
|
|
1181
1233
|
for (const target of targets.identifiers) {
|
|
1182
1234
|
if (!isDeclaredWithin(target, node)) {
|
|
@@ -1445,11 +1497,26 @@ exports.parallelizeAsyncOperations = (0, createRule_1.createRule)({
|
|
|
1445
1497
|
]),
|
|
1446
1498
|
};
|
|
1447
1499
|
});
|
|
1500
|
+
//
|
|
1501
|
+
// The READ side resolves the callee body for the same reason the write
|
|
1502
|
+
// side does, and the omission was the other half of #2017: `await
|
|
1503
|
+
// this.storeAll()` spells only the slot `this.storeAll`, so a preceding
|
|
1504
|
+
// write to `this.accumulated` -- the slot `storeAll` actually reads --
|
|
1505
|
+
// compares as disjoint and the pair parallelizes. Reading the resolved
|
|
1506
|
+
// body restores the edge. An unresolvable callee (inherited, computed,
|
|
1507
|
+
// imported) yields null and leaves the operand keyed on its own text, as
|
|
1508
|
+
// before.
|
|
1448
1509
|
const readInstancePaths = awaitNodes.map((node) => {
|
|
1449
1510
|
const awaitExpr = getAwaitExpression(node);
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1511
|
+
if (!awaitExpr) {
|
|
1512
|
+
return new Set();
|
|
1513
|
+
}
|
|
1514
|
+
const read = getInstancePathKeys(awaitExpr.argument);
|
|
1515
|
+
const calleeFunction = resolveCalleeFunction(awaitExpr);
|
|
1516
|
+
if (!calleeFunction) {
|
|
1517
|
+
return read;
|
|
1518
|
+
}
|
|
1519
|
+
return new Set([...read, ...getInstancePathKeys(calleeFunction)]);
|
|
1453
1520
|
});
|
|
1454
1521
|
for (let i = 1; i < awaitNodes.length; i++) {
|
|
1455
1522
|
const currentIds = allIdentifiers[i];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared machinery for reading the documented examples out of `docs/rules/*.md`
|
|
3
|
+
* and linting them.
|
|
4
|
+
*
|
|
5
|
+
* Extracted so that more than one guard can ask a question of the SAME parsed
|
|
6
|
+
* corpus. `docs-examples-conformance` asks whether a block satisfies its own
|
|
7
|
+
* rule; `docs-correct-block-regression` asks whether the blocks #1982 fixed
|
|
8
|
+
* still satisfy the OTHER rule that used to report on them. Hand-rolling the
|
|
9
|
+
* fence walker or the candidate-filename list a second time is how two guards
|
|
10
|
+
* come to disagree about which blocks exist — the failure `fixtureCorpus.ts`
|
|
11
|
+
* exists to prevent on the RuleTester side, and the reason four guards there
|
|
12
|
+
* inherited the same two silent losses (#1984).
|
|
13
|
+
*
|
|
14
|
+
* The filename list in particular is load-bearing and must not be duplicated:
|
|
15
|
+
* many rules key off the path, so judging a block under a path the rule was
|
|
16
|
+
* never meant to see manufactures a failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PREFIX = "@blumintinc/blumint/";
|
|
19
|
+
export declare const DOCS_DIR: string;
|
|
20
|
+
export declare const pageExists: (rule: string) => boolean;
|
|
21
|
+
export declare const readPage: (rule: string) => string | null;
|
|
22
|
+
/** Fence languages that hold lintable TypeScript. */
|
|
23
|
+
export declare const LINTABLE_LANGS: Set<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Candidate filenames, tried in order. Many rules key off the path (cloud
|
|
26
|
+
* function entry points, test-file exemptions, component directories), so a
|
|
27
|
+
* single hard-coded filename would make correct examples report for reasons the
|
|
28
|
+
* doc never claimed.
|
|
29
|
+
*/
|
|
30
|
+
export declare const TS_CANDIDATES: string[];
|
|
31
|
+
export declare const TSX_CANDIDATES: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Rules that match on path segments need a rooted path — `functions/src/types/x.ts`
|
|
34
|
+
* relative does not satisfy the same check that `/repo/functions/src/types/x.ts`
|
|
35
|
+
* does, which would fail a doc example for a reason the doc never claimed.
|
|
36
|
+
*/
|
|
37
|
+
export declare const ROOT = "/repo/";
|
|
38
|
+
export declare const anchor: (p: string) => string;
|
|
39
|
+
export type Block = {
|
|
40
|
+
/**
|
|
41
|
+
* `null` for a fence under no example heading. Such blocks are kept rather
|
|
42
|
+
* than dropped: a page whose fences all come back unlabelled is a detection
|
|
43
|
+
* failure, and dropping them made it indistinguishable from a page that
|
|
44
|
+
* documents no examples at all (#1499).
|
|
45
|
+
*/
|
|
46
|
+
polarity: 'correct' | 'incorrect' | null;
|
|
47
|
+
lang: string;
|
|
48
|
+
code: string;
|
|
49
|
+
line: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Classify an example heading.
|
|
53
|
+
*
|
|
54
|
+
* Only H2+ headings count: the H1 title ends with the rule id, and rule names
|
|
55
|
+
* routinely contain `prefer`, `valid`, or `no`, which would otherwise classify
|
|
56
|
+
* every block in the intro prose. The rule-id parenthetical is stripped for the
|
|
57
|
+
* same reason.
|
|
58
|
+
*
|
|
59
|
+
* Order matters — "incorrect" contains "correct" and "invalid" contains "valid",
|
|
60
|
+
* so the negative spellings must be tested first.
|
|
61
|
+
*/
|
|
62
|
+
export declare function headingPolarity(line: string): Block['polarity'] | null;
|
|
63
|
+
/**
|
|
64
|
+
* Pull every fenced code block, tagged with the polarity of the example heading
|
|
65
|
+
* it sits under (`null` when it sits under none).
|
|
66
|
+
*
|
|
67
|
+
* Polarity is inherited by DEEPER headings, because docs routinely split an
|
|
68
|
+
* example section into named cases (`#### Option 1: …` under `### Examples of
|
|
69
|
+
* correct code`). Treating such a sub-heading as the end of the section dropped
|
|
70
|
+
* every block beneath it, which is how three whole pages asserted nothing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function extractBlocks(md: string): Block[];
|
|
73
|
+
/**
|
|
74
|
+
* Docs declare the context a snippet assumes inside the snippet itself:
|
|
75
|
+
* `// File: functions/src/...` (or a bare path comment) for path-sensitive
|
|
76
|
+
* rules, and `// eslint-options: {...}` for an example that only holds under a
|
|
77
|
+
* non-default option. Honouring both is what lets every correct block be
|
|
78
|
+
* enforced without exempting the awkward ones.
|
|
79
|
+
*/
|
|
80
|
+
export declare function filenameHint(code: string): string | null;
|
|
81
|
+
export declare function optionsHint(code: string): unknown | null;
|
|
82
|
+
export type LintResult = {
|
|
83
|
+
reports: string[];
|
|
84
|
+
/** 1-based lines of the same reports, for segment attribution (#1622). */
|
|
85
|
+
reportLines: number[];
|
|
86
|
+
skipped: boolean;
|
|
87
|
+
reason?: string;
|
|
88
|
+
};
|
|
89
|
+
export declare function lintBlock(ruleName: string, filename: string, code: string, options: unknown | null): LintResult;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.lintBlock = exports.optionsHint = exports.filenameHint = exports.extractBlocks = exports.headingPolarity = exports.anchor = exports.ROOT = exports.TSX_CANDIDATES = exports.TS_CANDIDATES = exports.LINTABLE_LANGS = exports.readPage = exports.pageExists = exports.DOCS_DIR = exports.PREFIX = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const eslint_1 = require("eslint");
|
|
10
|
+
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
11
|
+
const plugin = require('../index');
|
|
12
|
+
const tsParser = require('@typescript-eslint/parser');
|
|
13
|
+
/* eslint-enable @typescript-eslint/no-var-requires */
|
|
14
|
+
/**
|
|
15
|
+
* Shared machinery for reading the documented examples out of `docs/rules/*.md`
|
|
16
|
+
* and linting them.
|
|
17
|
+
*
|
|
18
|
+
* Extracted so that more than one guard can ask a question of the SAME parsed
|
|
19
|
+
* corpus. `docs-examples-conformance` asks whether a block satisfies its own
|
|
20
|
+
* rule; `docs-correct-block-regression` asks whether the blocks #1982 fixed
|
|
21
|
+
* still satisfy the OTHER rule that used to report on them. Hand-rolling the
|
|
22
|
+
* fence walker or the candidate-filename list a second time is how two guards
|
|
23
|
+
* come to disagree about which blocks exist — the failure `fixtureCorpus.ts`
|
|
24
|
+
* exists to prevent on the RuleTester side, and the reason four guards there
|
|
25
|
+
* inherited the same two silent losses (#1984).
|
|
26
|
+
*
|
|
27
|
+
* The filename list in particular is load-bearing and must not be duplicated:
|
|
28
|
+
* many rules key off the path, so judging a block under a path the rule was
|
|
29
|
+
* never meant to see manufactures a failure.
|
|
30
|
+
*/
|
|
31
|
+
exports.PREFIX = '@blumintinc/blumint/';
|
|
32
|
+
exports.DOCS_DIR = path_1.default.join(__dirname, '../../docs/rules');
|
|
33
|
+
const pageExists = (rule) => fs_1.default.existsSync(path_1.default.join(exports.DOCS_DIR, `${rule}.md`));
|
|
34
|
+
exports.pageExists = pageExists;
|
|
35
|
+
const readPage = (rule) => (0, exports.pageExists)(rule)
|
|
36
|
+
? fs_1.default.readFileSync(path_1.default.join(exports.DOCS_DIR, `${rule}.md`), 'utf8')
|
|
37
|
+
: null;
|
|
38
|
+
exports.readPage = readPage;
|
|
39
|
+
/** Fence languages that hold lintable TypeScript. */
|
|
40
|
+
exports.LINTABLE_LANGS = new Set([
|
|
41
|
+
'ts',
|
|
42
|
+
'tsx',
|
|
43
|
+
'js',
|
|
44
|
+
'jsx',
|
|
45
|
+
'typescript',
|
|
46
|
+
'javascript',
|
|
47
|
+
'',
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Candidate filenames, tried in order. Many rules key off the path (cloud
|
|
51
|
+
* function entry points, test-file exemptions, component directories), so a
|
|
52
|
+
* single hard-coded filename would make correct examples report for reasons the
|
|
53
|
+
* doc never claimed.
|
|
54
|
+
*/
|
|
55
|
+
exports.TS_CANDIDATES = [
|
|
56
|
+
'src/util/helper.ts',
|
|
57
|
+
'functions/src/callable/handler.f.ts',
|
|
58
|
+
'functions/src/util/helper.ts',
|
|
59
|
+
'src/util/helper.test.ts',
|
|
60
|
+
'src/components/Widget.tsx',
|
|
61
|
+
];
|
|
62
|
+
exports.TSX_CANDIDATES = [
|
|
63
|
+
'src/components/Widget.tsx',
|
|
64
|
+
'src/pages/index.tsx',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* Rules that match on path segments need a rooted path — `functions/src/types/x.ts`
|
|
68
|
+
* relative does not satisfy the same check that `/repo/functions/src/types/x.ts`
|
|
69
|
+
* does, which would fail a doc example for a reason the doc never claimed.
|
|
70
|
+
*/
|
|
71
|
+
exports.ROOT = '/repo/';
|
|
72
|
+
const anchor = (p) => (p.startsWith('/') ? p : exports.ROOT + p);
|
|
73
|
+
exports.anchor = anchor;
|
|
74
|
+
/**
|
|
75
|
+
* Classify an example heading.
|
|
76
|
+
*
|
|
77
|
+
* Only H2+ headings count: the H1 title ends with the rule id, and rule names
|
|
78
|
+
* routinely contain `prefer`, `valid`, or `no`, which would otherwise classify
|
|
79
|
+
* every block in the intro prose. The rule-id parenthetical is stripped for the
|
|
80
|
+
* same reason.
|
|
81
|
+
*
|
|
82
|
+
* Order matters — "incorrect" contains "correct" and "invalid" contains "valid",
|
|
83
|
+
* so the negative spellings must be tested first.
|
|
84
|
+
*/
|
|
85
|
+
function headingPolarity(line) {
|
|
86
|
+
if (!/^#{2,6}\s/.test(line))
|
|
87
|
+
return null;
|
|
88
|
+
const text = line
|
|
89
|
+
.replace(/^#{2,6}\s*/, '')
|
|
90
|
+
.replace(/\(`?@blumintinc\/blumint\/[^)]*`?\)/g, '')
|
|
91
|
+
.toLowerCase();
|
|
92
|
+
if (/❌|👎|\bincorrect\b|\binvalid\b|\bbad\b|\bwrong\b/.test(text))
|
|
93
|
+
return 'incorrect';
|
|
94
|
+
if (/✅|👍|\bcorrect\b|\bvalid\b|\bgood\b/.test(text))
|
|
95
|
+
return 'correct';
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
exports.headingPolarity = headingPolarity;
|
|
99
|
+
/**
|
|
100
|
+
* Pull every fenced code block, tagged with the polarity of the example heading
|
|
101
|
+
* it sits under (`null` when it sits under none).
|
|
102
|
+
*
|
|
103
|
+
* Polarity is inherited by DEEPER headings, because docs routinely split an
|
|
104
|
+
* example section into named cases (`#### Option 1: …` under `### Examples of
|
|
105
|
+
* correct code`). Treating such a sub-heading as the end of the section dropped
|
|
106
|
+
* every block beneath it, which is how three whole pages asserted nothing.
|
|
107
|
+
*/
|
|
108
|
+
function extractBlocks(md) {
|
|
109
|
+
const lines = md.split('\n');
|
|
110
|
+
const blocks = [];
|
|
111
|
+
let polarity = null;
|
|
112
|
+
let polarityDepth = 0;
|
|
113
|
+
let fence = null;
|
|
114
|
+
let buf = [];
|
|
115
|
+
let lang = '';
|
|
116
|
+
let startLine = 0;
|
|
117
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
118
|
+
const line = lines[i];
|
|
119
|
+
const fenceMatch = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
120
|
+
if (fence) {
|
|
121
|
+
if (fenceMatch &&
|
|
122
|
+
fenceMatch[1][0] === fence[0] &&
|
|
123
|
+
fenceMatch[1].length >= fence.length) {
|
|
124
|
+
blocks.push({
|
|
125
|
+
polarity,
|
|
126
|
+
lang: lang.trim().toLowerCase(),
|
|
127
|
+
code: buf.join('\n'),
|
|
128
|
+
line: startLine,
|
|
129
|
+
});
|
|
130
|
+
fence = null;
|
|
131
|
+
buf = [];
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
buf.push(line);
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const heading = /^(#{1,6})\s/.exec(line);
|
|
139
|
+
if (heading) {
|
|
140
|
+
const depth = heading[1].length;
|
|
141
|
+
const own = headingPolarity(line);
|
|
142
|
+
if (own) {
|
|
143
|
+
polarity = own;
|
|
144
|
+
polarityDepth = depth;
|
|
145
|
+
}
|
|
146
|
+
else if (!(polarity && depth > polarityDepth)) {
|
|
147
|
+
// A sibling or shallower heading ends the example section; a deeper one
|
|
148
|
+
// is a named case inside it and keeps the section's polarity.
|
|
149
|
+
polarity = null;
|
|
150
|
+
polarityDepth = 0;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (fenceMatch) {
|
|
155
|
+
fence = fenceMatch[1];
|
|
156
|
+
lang = fenceMatch[2] || '';
|
|
157
|
+
startLine = i + 1;
|
|
158
|
+
buf = [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return blocks;
|
|
162
|
+
}
|
|
163
|
+
exports.extractBlocks = extractBlocks;
|
|
164
|
+
/**
|
|
165
|
+
* Docs declare the context a snippet assumes inside the snippet itself:
|
|
166
|
+
* `// File: functions/src/...` (or a bare path comment) for path-sensitive
|
|
167
|
+
* rules, and `// eslint-options: {...}` for an example that only holds under a
|
|
168
|
+
* non-default option. Honouring both is what lets every correct block be
|
|
169
|
+
* enforced without exempting the awkward ones.
|
|
170
|
+
*/
|
|
171
|
+
function filenameHint(code) {
|
|
172
|
+
const explicit = /^\s*(?:\/\/|\/\*)\s*File:\s*([^\s*]+)/im.exec(code);
|
|
173
|
+
if (explicit)
|
|
174
|
+
return (0, exports.anchor)(explicit[1].replace(/^\.\//, ''));
|
|
175
|
+
const firstLine = code.split('\n').find((l) => l.trim().length > 0) || '';
|
|
176
|
+
const bare = /^\s*\/\/\s*((?:[\w.-]+\/)+[\w.-]+\.tsx?)\b/.exec(firstLine);
|
|
177
|
+
return bare ? (0, exports.anchor)(bare[1]) : null;
|
|
178
|
+
}
|
|
179
|
+
exports.filenameHint = filenameHint;
|
|
180
|
+
function optionsHint(code) {
|
|
181
|
+
const m = /^\s*\/\/\s*eslint-options:\s*(\{.*\})\s*$/im.exec(code);
|
|
182
|
+
if (!m)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(m[1]);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error(`malformed // eslint-options: ${m[1]}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
exports.optionsHint = optionsHint;
|
|
192
|
+
const linter = new eslint_1.Linter();
|
|
193
|
+
for (const [name, rule] of Object.entries(plugin.rules)) {
|
|
194
|
+
linter.defineRule(exports.PREFIX + name, rule);
|
|
195
|
+
}
|
|
196
|
+
linter.defineParser('ts', tsParser);
|
|
197
|
+
function lintBlock(ruleName, filename, code, options) {
|
|
198
|
+
const config = {
|
|
199
|
+
parser: 'ts',
|
|
200
|
+
parserOptions: {
|
|
201
|
+
ecmaVersion: 2022,
|
|
202
|
+
sourceType: 'module',
|
|
203
|
+
ecmaFeatures: { jsx: filename.endsWith('.tsx') },
|
|
204
|
+
},
|
|
205
|
+
rules: {
|
|
206
|
+
[exports.PREFIX + ruleName]: options
|
|
207
|
+
? ['error', options]
|
|
208
|
+
: 'error',
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
let messages;
|
|
212
|
+
try {
|
|
213
|
+
messages = linter.verify(code, config, { filename });
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// A rule needing type information throws without `parserOptions.project`,
|
|
217
|
+
// which the RuleTester cannot supply; such rules are out of scope here.
|
|
218
|
+
return {
|
|
219
|
+
reports: [],
|
|
220
|
+
reportLines: [],
|
|
221
|
+
skipped: true,
|
|
222
|
+
reason: `the rule threw: ${error.message}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// A block that does not parse never ran the rule. That is not a pass — see
|
|
226
|
+
// UNCHECKABLE_BLOCKS.
|
|
227
|
+
const fatal = messages.find((m) => m.fatal);
|
|
228
|
+
if (fatal) {
|
|
229
|
+
return {
|
|
230
|
+
reports: [],
|
|
231
|
+
reportLines: [],
|
|
232
|
+
skipped: true,
|
|
233
|
+
reason: `parse failure at block line ${fatal.line}: ${fatal.message}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const mine = messages.filter((m) => m.ruleId === exports.PREFIX + ruleName);
|
|
237
|
+
return {
|
|
238
|
+
reports: mine.map((m) => `line ${m.line}: ${m.message}`),
|
|
239
|
+
reportLines: mine.map((m) => m.line),
|
|
240
|
+
skipped: false,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
exports.lintBlock = lintBlock;
|
|
244
|
+
//# sourceMappingURL=docsFixtures.js.map
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.154",
|
|
4
|
+
"date": "2026-08-15T03:47:51.570Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "parallelize-async-operations",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
2017
|
|
11
|
+
],
|
|
12
|
+
"summary": "order callback-deferred instance mutations against later reads (closes #2017)"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "1.20.153",
|
|
18
|
+
"date": "2026-08-14T20:21:23.691Z",
|
|
19
|
+
"rules": [
|
|
20
|
+
{
|
|
21
|
+
"name": "enforce-boolean-naming-prefixes",
|
|
22
|
+
"changeType": "fix",
|
|
23
|
+
"issues": [
|
|
24
|
+
2016
|
|
25
|
+
],
|
|
26
|
+
"summary": "decline when the use site contradicts the callee's name (closes #2016)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-object-literal-as-const",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
2015
|
|
33
|
+
],
|
|
34
|
+
"summary": "keep an unannotated returned array unfrozen (closes #2015)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "global-const-style",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
2013
|
|
41
|
+
],
|
|
42
|
+
"summary": "decline the as const when the binding is mutated later (closes #2013)"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "no-explicit-return-type",
|
|
46
|
+
"changeType": "fix",
|
|
47
|
+
"issues": [
|
|
48
|
+
2014
|
|
49
|
+
],
|
|
50
|
+
"summary": "keep a decorator factory's annotation (closes #2014)"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "1.20.152",
|
|
4
56
|
"date": "2026-08-14T10:28:08.765Z",
|