@blumintinc/eslint-plugin-blumint 1.20.96 → 1.20.98
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 +38 -3
- package/lib/rules/enforce-centralized-mock-firestore.js +193 -65
- package/lib/rules/enforce-microdiff.js +140 -56
- package/lib/rules/enforce-positive-naming.js +112 -24
- package/lib/utils/harvestRuleTesterCases.d.ts +48 -0
- package/lib/utils/harvestRuleTesterCases.js +168 -0
- package/package.json +1 -1
- package/release-manifest.json +46 -0
package/lib/index.js
CHANGED
|
@@ -113,12 +113,25 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
113
113
|
return true;
|
|
114
114
|
}
|
|
115
115
|
const nextChar = normalizedName.charAt(p.length);
|
|
116
|
-
// For SCREAMING_SNAKE_CASE or similar all-uppercase names
|
|
117
|
-
//
|
|
116
|
+
// For SCREAMING_SNAKE_CASE or similar all-uppercase names the prefix must
|
|
117
|
+
// end at a separator, since case can no longer mark the word boundary
|
|
118
|
+
// (ISVALID stays unprefixed). Digits fused onto the prefix belong to that
|
|
119
|
+
// first segment rather than to the next word — ARE2_VALID is the
|
|
120
|
+
// UPPER_SNAKE spelling of are2Valid, which the camelCase branch below
|
|
121
|
+
// accepts — so a trailing digit run is consumed before the separator is
|
|
122
|
+
// examined, and the same separators the camelCase branch honours (`_`,
|
|
123
|
+
// `$`, end of name) close the segment. Only a digit run directly after
|
|
124
|
+
// the prefix qualifies: ARENA2_MAP still fails because a letter follows
|
|
125
|
+
// the prefix, and H2AS_ITEMS never reaches here because the prefix does
|
|
126
|
+
// not match the segment's start.
|
|
118
127
|
const isAllUppercase = normalizedName === normalizedName.toUpperCase() &&
|
|
119
128
|
/[a-z]/i.test(normalizedName);
|
|
120
129
|
if (isAllUppercase) {
|
|
121
|
-
|
|
130
|
+
const afterPrefix = normalizedName
|
|
131
|
+
.slice(p.length)
|
|
132
|
+
.replace(/^\d+/, '');
|
|
133
|
+
const boundaryChar = afterPrefix.charAt(0);
|
|
134
|
+
return (boundaryChar === '' || boundaryChar === '_' || boundaryChar === '$');
|
|
122
135
|
}
|
|
123
136
|
// For camelCase, the next char must be uppercase, a digit, or $
|
|
124
137
|
return (nextChar === '_' ||
|
|
@@ -710,6 +723,28 @@ exports.enforceBooleanNamingPrefixes = (0, createRule_1.createRule)({
|
|
|
710
723
|
expression.name === 'undefined') {
|
|
711
724
|
return 'nonBoolean';
|
|
712
725
|
}
|
|
726
|
+
// A returned binding is governed by this very rule: a boolean variable,
|
|
727
|
+
// parameter or function must carry an approved prefix. So an unprefixed
|
|
728
|
+
// `id` — or the result of calling an unprefixed `compute(x)` — is not a
|
|
729
|
+
// boolean under the regime the rule enforces, and the callee's own name
|
|
730
|
+
// must not override that. Deciding here keeps the exemption in the body
|
|
731
|
+
// rather than in a return annotation, which `no-explicit-return-type`
|
|
732
|
+
// deletes (issue #1691).
|
|
733
|
+
//
|
|
734
|
+
// Member accesses (`source.flag`, `source.read()`) are deliberately
|
|
735
|
+
// excluded: property signatures are only enforced under
|
|
736
|
+
// `enforceForPropertySignatures` and third-party method names are outside
|
|
737
|
+
// this rule's reach, so an unprefixed member may legitimately yield a
|
|
738
|
+
// boolean and the callee's name keeps its say.
|
|
739
|
+
if (expression.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
740
|
+
return identifierIsBoolean(expression) ? 'boolean' : 'nonBoolean';
|
|
741
|
+
}
|
|
742
|
+
if (expression.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
743
|
+
expression.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
744
|
+
return callExpressionLooksBoolean(expression) === 'boolean'
|
|
745
|
+
? 'boolean'
|
|
746
|
+
: 'nonBoolean';
|
|
747
|
+
}
|
|
713
748
|
if (expression.type === utils_1.AST_NODE_TYPES.UnaryExpression) {
|
|
714
749
|
if (expression.operator === '!' || expression.operator === 'delete') {
|
|
715
750
|
return 'boolean';
|
|
@@ -3,7 +3,154 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.enforceCentralizedMockFirestore = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
6
7
|
const MOCK_FIRESTORE_PATH = '../../../../../__test-utils__/mockFirestore';
|
|
8
|
+
function isHorizontalWhitespace(character) {
|
|
9
|
+
return character === ' ' || character === '\t';
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The span a retired declaration occupies, widened to its whole line only when
|
|
13
|
+
* the declaration is the sole occupant of that line. Anything else sharing the
|
|
14
|
+
* line — a live statement, a trailing comment, an `eslint-disable-line`
|
|
15
|
+
* directive — is outside the declaration's range and must survive the
|
|
16
|
+
* retirement: unrelated statements would become unbound references, and a
|
|
17
|
+
* deleted comment is the one damage class a formatter can never restore.
|
|
18
|
+
*/
|
|
19
|
+
function retirementEdit(text, start, end) {
|
|
20
|
+
const lineStart = text.lastIndexOf('\n', start - 1) + 1;
|
|
21
|
+
const newlineIndex = text.indexOf('\n', end);
|
|
22
|
+
const lineEnd = newlineIndex === -1 ? text.length : newlineIndex;
|
|
23
|
+
const before = text.slice(lineStart, start);
|
|
24
|
+
const after = text.slice(end, lineEnd);
|
|
25
|
+
if (/^\s*$/.test(before) && /^\s*$/.test(after)) {
|
|
26
|
+
return {
|
|
27
|
+
start: lineStart,
|
|
28
|
+
end: Math.min(lineEnd + 1, text.length),
|
|
29
|
+
text: '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (/^\s*$/.test(before)) {
|
|
33
|
+
// Code follows on this line, so the retired declaration hands over the
|
|
34
|
+
// indentation it was occupying rather than leaving the successor adrift.
|
|
35
|
+
let cursor = end;
|
|
36
|
+
while (isHorizontalWhitespace(text[cursor])) {
|
|
37
|
+
cursor++;
|
|
38
|
+
}
|
|
39
|
+
return { start, end: cursor, text: '' };
|
|
40
|
+
}
|
|
41
|
+
// Code precedes on this line; absorb the gap so no trailing space is left.
|
|
42
|
+
let cursor = start;
|
|
43
|
+
while (cursor > lineStart && isHorizontalWhitespace(text[cursor - 1])) {
|
|
44
|
+
cursor--;
|
|
45
|
+
}
|
|
46
|
+
return { start: cursor, end, text: '' };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Node types whose children are free-standing statements. A declaration
|
|
50
|
+
* anywhere else — a `for (const mockFirestore of …)` head, say — cannot be
|
|
51
|
+
* excised without leaving the enclosing construct malformed.
|
|
52
|
+
*/
|
|
53
|
+
const STATEMENT_CONTAINERS = new Set([
|
|
54
|
+
utils_1.AST_NODE_TYPES.Program,
|
|
55
|
+
utils_1.AST_NODE_TYPES.BlockStatement,
|
|
56
|
+
utils_1.AST_NODE_TYPES.StaticBlock,
|
|
57
|
+
utils_1.AST_NODE_TYPES.SwitchCase,
|
|
58
|
+
utils_1.AST_NODE_TYPES.TSModuleBlock,
|
|
59
|
+
]);
|
|
60
|
+
/**
|
|
61
|
+
* Widens a declaration to the `export` that fronts it, whose keyword lives
|
|
62
|
+
* outside the declaration's own range and would otherwise be stranded.
|
|
63
|
+
*/
|
|
64
|
+
function retirableStatement(declaration) {
|
|
65
|
+
const statement = declaration.parent?.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration
|
|
66
|
+
? declaration.parent
|
|
67
|
+
: declaration;
|
|
68
|
+
return statement.parent &&
|
|
69
|
+
STATEMENT_CONTAINERS.has(statement.parent.type)
|
|
70
|
+
? statement
|
|
71
|
+
: undefined;
|
|
72
|
+
}
|
|
73
|
+
function retiredSpan(node) {
|
|
74
|
+
const parent = node.parent;
|
|
75
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
76
|
+
const { declarations } = parent;
|
|
77
|
+
if (declarations.length === 1) {
|
|
78
|
+
const statement = retirableStatement(parent);
|
|
79
|
+
if (!statement) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
start: statement.range[0],
|
|
84
|
+
end: statement.range[1],
|
|
85
|
+
whole: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const index = declarations.indexOf(node);
|
|
89
|
+
if (index > 0) {
|
|
90
|
+
return {
|
|
91
|
+
start: declarations[index - 1].range[1],
|
|
92
|
+
end: node.range[1],
|
|
93
|
+
whole: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (index === 0) {
|
|
97
|
+
// Reach forward to the next declarator instead of backward past the
|
|
98
|
+
// `const`, whose trailing space still belongs to the survivors.
|
|
99
|
+
return {
|
|
100
|
+
start: node.range[0],
|
|
101
|
+
end: declarations[1].range[0],
|
|
102
|
+
whole: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
108
|
+
return { start: node.range[0], end: node.range[1], whole: true };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Collapses edits that touch, so an overlap can never rewrite a range twice.
|
|
114
|
+
*/
|
|
115
|
+
function mergeEdits(edits) {
|
|
116
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
117
|
+
const merged = [];
|
|
118
|
+
for (const edit of sorted) {
|
|
119
|
+
const last = merged[merged.length - 1];
|
|
120
|
+
if (last && edit.start < last.end) {
|
|
121
|
+
last.end = Math.max(last.end, edit.end);
|
|
122
|
+
last.text += edit.text;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
merged.push({ ...edit });
|
|
126
|
+
}
|
|
127
|
+
return merged;
|
|
128
|
+
}
|
|
129
|
+
function editOffset(anchor) {
|
|
130
|
+
return anchor.kind === 'before' ? anchor.target.range[0] : anchor.index;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Where the import may be spliced in given the edits retiring the local mock.
|
|
134
|
+
*
|
|
135
|
+
* Widening to the anchor's line start is what lets the emitted
|
|
136
|
+
* `${indent}import …\n` leave the displaced statement on the indentation it
|
|
137
|
+
* already had, but that is sound only while whitespace is all that precedes
|
|
138
|
+
* the anchor: a `'use client';` sharing the anchor's line would be demoted by
|
|
139
|
+
* an insertion at column 0. When the resulting position still falls inside a
|
|
140
|
+
* retirement — the anchor statement is itself the declaration being retired,
|
|
141
|
+
* and the retirement claims the indentation ahead of it — the insertion moves
|
|
142
|
+
* to that edit's start, since ESLint rejects a fix nested inside another.
|
|
143
|
+
*/
|
|
144
|
+
function importPlacement(sourceCode, anchor, edits) {
|
|
145
|
+
const anchorStart = editOffset(anchor);
|
|
146
|
+
const lineStart = sourceCode.text.lastIndexOf('\n', anchorStart - 1) + 1;
|
|
147
|
+
const placement = /^[ \t]*$/.test(sourceCode.text.slice(lineStart, anchorStart))
|
|
148
|
+
? (0, importInsertion_1.importAnchorLineStart)(sourceCode, anchor)
|
|
149
|
+
: anchor;
|
|
150
|
+
const offset = editOffset(placement);
|
|
151
|
+
const enclosing = edits.find((edit) => edit.start < offset && offset < edit.end);
|
|
152
|
+
return enclosing ? { kind: 'index', index: enclosing.start } : placement;
|
|
153
|
+
}
|
|
7
154
|
exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
8
155
|
name: 'enforce-centralized-mock-firestore',
|
|
9
156
|
meta: {
|
|
@@ -149,85 +296,66 @@ exports.enforceCentralizedMockFirestore = (0, createRule_1.createRule)({
|
|
|
149
296
|
requiredPath: MOCK_FIRESTORE_PATH,
|
|
150
297
|
},
|
|
151
298
|
fix(fixer) {
|
|
152
|
-
// Instead of trying to modify the code incrementally, we'll generate the entire fixed code
|
|
153
299
|
const originalText = sourceCode.getText();
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (node.parent?.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
167
|
-
// If it's the only declarator, remove the entire declaration
|
|
168
|
-
if (node.parent.declarations.length === 1) {
|
|
169
|
-
const declStartLine = sourceCode.getLocFromIndex(node.parent.range[0]).line - 1;
|
|
170
|
-
const declEndLine = sourceCode.getLocFromIndex(node.parent.range[1]).line - 1;
|
|
171
|
-
for (let i = declStartLine; i <= declEndLine; i++) {
|
|
172
|
-
linesToRemove.add(i);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
// Otherwise, just remove this declarator
|
|
177
|
-
for (let i = startLine; i <= endLine; i++) {
|
|
178
|
-
linesToRemove.add(i);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
else if (node.type === utils_1.AST_NODE_TYPES.PropertyDefinition) {
|
|
183
|
-
// Remove class property
|
|
184
|
-
for (let i = startLine; i <= endLine; i++) {
|
|
185
|
-
linesToRemove.add(i);
|
|
186
|
-
}
|
|
300
|
+
// Retire each declaration by its own character range. Line
|
|
301
|
+
// indices would take every other occupant of those lines with
|
|
302
|
+
// them.
|
|
303
|
+
const removals = [];
|
|
304
|
+
for (const node of mockFirestoreNodes) {
|
|
305
|
+
const span = retiredSpan(node);
|
|
306
|
+
if (!span) {
|
|
307
|
+
// A declaration that cannot be excised cleanly gets no
|
|
308
|
+
// autofix at all: leaving it behind while adding the import
|
|
309
|
+
// would report forever, and cutting it anyway would emit
|
|
310
|
+
// code that no longer parses.
|
|
311
|
+
return null;
|
|
187
312
|
}
|
|
188
|
-
|
|
313
|
+
removals.push(span.whole
|
|
314
|
+
? retirementEdit(originalText, span.start, span.end)
|
|
315
|
+
: { start: span.start, end: span.end, text: '' });
|
|
316
|
+
}
|
|
189
317
|
// Replace custom mockFirestore references with the standard one
|
|
190
318
|
const replacements = [];
|
|
191
319
|
// Add replacements for custom mockFirestore names
|
|
192
320
|
customMockFirestoreCallExpressions.forEach((node) => {
|
|
193
321
|
if (node.callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
194
|
-
replacements.push(
|
|
195
|
-
node.callee.range[0],
|
|
196
|
-
node.callee.
|
|
197
|
-
'mockFirestore',
|
|
198
|
-
|
|
322
|
+
replacements.push({
|
|
323
|
+
start: node.callee.range[0],
|
|
324
|
+
end: node.callee.range[1],
|
|
325
|
+
text: 'mockFirestore',
|
|
326
|
+
});
|
|
199
327
|
}
|
|
200
328
|
});
|
|
201
329
|
// Add replacements for this.mockFirestore
|
|
202
330
|
thisExpressions.forEach((expr) => {
|
|
203
|
-
replacements.push(
|
|
204
|
-
expr.range[0],
|
|
205
|
-
|
|
206
|
-
'mockFirestore',
|
|
207
|
-
|
|
331
|
+
replacements.push({
|
|
332
|
+
start: expr.range[0],
|
|
333
|
+
end: expr.range[1],
|
|
334
|
+
text: 'mockFirestore',
|
|
335
|
+
});
|
|
208
336
|
});
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
//
|
|
220
|
-
const
|
|
221
|
-
.split('\n')
|
|
222
|
-
.filter((_, i) => !linesToRemove.has(i));
|
|
223
|
-
// Add the import statement at the beginning
|
|
337
|
+
// A reference inside a retired declaration goes away with it, so
|
|
338
|
+
// rewriting it would only fight the removal for the same range.
|
|
339
|
+
const survivingReplacements = replacements.filter((replacement) => !removals.some((removal) => replacement.start >= removal.start &&
|
|
340
|
+
replacement.end <= removal.end));
|
|
341
|
+
const edits = mergeEdits([...removals, ...survivingReplacements]);
|
|
342
|
+
// Every edit is bounded to the characters it owns. Rebuilding
|
|
343
|
+
// the file and writing it over the `Program` node instead would
|
|
344
|
+
// emit whatever precedes `Program.range[0]` — a header comment,
|
|
345
|
+
// a `@ts-nocheck`, a license block — a second time, and would
|
|
346
|
+
// claim a fix range spanning the file, which wins every pass-1
|
|
347
|
+
// fixer race and suppresses sibling rules' fixes.
|
|
348
|
+
const fixes = edits.map(({ start, end, text }) => fixer.replaceTextRange([start, end], text));
|
|
224
349
|
if (!hasCentralizedImport) {
|
|
225
|
-
|
|
350
|
+
// The shared anchor keeps the import below the file's
|
|
351
|
+
// prologue: spliced above a `'use client'` / `'use server'`
|
|
352
|
+
// directive it demotes the directive to a plain expression,
|
|
353
|
+
// and above a `#!` shebang it leaves the file unparseable.
|
|
354
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
355
|
+
const indent = (0, importInsertion_1.importAnchorIndent)(sourceCode, anchor);
|
|
356
|
+
fixes.push((0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, importPlacement(sourceCode, anchor, edits), `${indent}import { mockFirestore } from '${MOCK_FIRESTORE_PATH}';\n`));
|
|
226
357
|
}
|
|
227
|
-
|
|
228
|
-
const result = fixedLines.join('\n');
|
|
229
|
-
// Return the fixed text
|
|
230
|
-
return fixer.replaceText(sourceCode.ast, result);
|
|
358
|
+
return fixes;
|
|
231
359
|
},
|
|
232
360
|
});
|
|
233
361
|
}
|
|
@@ -207,6 +207,98 @@ function isRewrittenCallee(identifier) {
|
|
|
207
207
|
parent.callee === identifier &&
|
|
208
208
|
hasRewritableArity(parent));
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* The node types that open a scope of their own. The body walk stops at them
|
|
212
|
+
* because the emitted `diff` is only checked against the reported function's
|
|
213
|
+
* scope: a comparison inside a callback whose parameter is named `diff` would
|
|
214
|
+
* be rewritten into a call on that parameter, with no diagnostic to show for
|
|
215
|
+
* it.
|
|
216
|
+
*/
|
|
217
|
+
const FUNCTION_NODE_TYPES = new Set([
|
|
218
|
+
utils_1.AST_NODE_TYPES.ArrowFunctionExpression,
|
|
219
|
+
utils_1.AST_NODE_TYPES.FunctionDeclaration,
|
|
220
|
+
utils_1.AST_NODE_TYPES.FunctionExpression,
|
|
221
|
+
]);
|
|
222
|
+
/**
|
|
223
|
+
* Whether an expression is a call of `JSON.stringify`. The callee's shape
|
|
224
|
+
* decides rather than the source text, so a `stringify` read off anything but
|
|
225
|
+
* `JSON` — a local serializer bound under the same property name — keeps its
|
|
226
|
+
* call site.
|
|
227
|
+
*/
|
|
228
|
+
function isJsonStringify(node) {
|
|
229
|
+
return (node.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
230
|
+
node.callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
231
|
+
node.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
232
|
+
node.callee.object.name === 'JSON' &&
|
|
233
|
+
node.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
234
|
+
node.callee.property.name === 'stringify');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The stringify comparison an expression makes, or null for every other
|
|
238
|
+
* expression.
|
|
239
|
+
*
|
|
240
|
+
* A zero-argument `JSON.stringify()` yields null. The indexed argument read is
|
|
241
|
+
* typed non-optional, so nothing else forces the check, and there is no operand
|
|
242
|
+
* to hand `diff`.
|
|
243
|
+
*/
|
|
244
|
+
function toStringifyComparison(node) {
|
|
245
|
+
if (node.type !== utils_1.AST_NODE_TYPES.BinaryExpression ||
|
|
246
|
+
(node.operator !== '===' && node.operator !== '!==')) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!isJsonStringify(node.left) || !isJsonStringify(node.right)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const left = node.left.arguments[0];
|
|
253
|
+
const right = node.right.arguments[0];
|
|
254
|
+
if (!left || !right) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return { node, left, right, isEqual: node.operator === '===' };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Every stringify comparison `body` makes in its own scope, nested functions
|
|
261
|
+
* excluded.
|
|
262
|
+
*
|
|
263
|
+
* A rewrite driven by the enclosing function's parameter list answers a
|
|
264
|
+
* question the source never asked: `JSON.stringify(a.settings) !==
|
|
265
|
+
* JSON.stringify(b.settings)` compares two properties, and the signature's
|
|
266
|
+
* operands widen that to the whole objects. Reading the comparison itself is
|
|
267
|
+
* also what makes a destructuring parameter a non-issue, since the operands
|
|
268
|
+
* name what to diff whatever the signature binds.
|
|
269
|
+
*/
|
|
270
|
+
function collectStringifyComparisons(body) {
|
|
271
|
+
const comparisons = [];
|
|
272
|
+
const pending = [body];
|
|
273
|
+
while (pending.length > 0) {
|
|
274
|
+
const current = pending.pop();
|
|
275
|
+
if (current !== body && FUNCTION_NODE_TYPES.has(current.type)) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const comparison = toStringifyComparison(current);
|
|
279
|
+
if (comparison) {
|
|
280
|
+
comparisons.push(comparison);
|
|
281
|
+
}
|
|
282
|
+
for (const key in current) {
|
|
283
|
+
// The parent link closes a cycle over every node in the file.
|
|
284
|
+
if (key === 'parent') {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const value = current[key];
|
|
288
|
+
if (Array.isArray(value)) {
|
|
289
|
+
value.forEach((child) => {
|
|
290
|
+
if (ASTHelpers_1.ASTHelpers.isNode(child)) {
|
|
291
|
+
pending.push(child);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else if (ASTHelpers_1.ASTHelpers.isNode(value)) {
|
|
296
|
+
pending.push(value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return comparisons;
|
|
301
|
+
}
|
|
210
302
|
/**
|
|
211
303
|
* Whether a bare `diff` written at `scope` reaches microdiff's function.
|
|
212
304
|
* Resolving through the scope chain catches both failure modes: a module-scope
|
|
@@ -354,6 +446,17 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
354
446
|
: '\n\n';
|
|
355
447
|
return (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${MICRODIFF_IMPORT}${separator}`);
|
|
356
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The microdiff form of a stringify comparison: a change list whose
|
|
451
|
+
* emptiness carries the sense of the operator it replaces, so `===` becomes
|
|
452
|
+
* `.length === 0` and `!==` becomes `.length > 0`.
|
|
453
|
+
*/
|
|
454
|
+
function buildDiffComparison(comparison) {
|
|
455
|
+
const left = sourceCode.getText(comparison.left);
|
|
456
|
+
const right = sourceCode.getText(comparison.right);
|
|
457
|
+
const emptiness = comparison.isEqual ? '.length === 0' : '.length > 0';
|
|
458
|
+
return `${DIFF_NAME}(${left}, ${right})${emptiness}`;
|
|
459
|
+
}
|
|
357
460
|
// Add a specific set to track which import names are used
|
|
358
461
|
const usedImportNames = new Set();
|
|
359
462
|
// Check if a node is an object or array type
|
|
@@ -559,46 +662,29 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
559
662
|
return;
|
|
560
663
|
}
|
|
561
664
|
// Check for JSON.stringify comparison pattern
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
expr.callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
568
|
-
expr.callee.object.name === 'JSON' &&
|
|
569
|
-
expr.callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
570
|
-
expr.callee.property.name === 'stringify');
|
|
571
|
-
};
|
|
572
|
-
if (isJsonStringify(node.left) && isJsonStringify(node.right)) {
|
|
573
|
-
const leftArg = node.left.arguments[0];
|
|
574
|
-
const rightArg = node.right.arguments[0];
|
|
575
|
-
// A zero-argument JSON.stringify() leaves these undefined. The
|
|
576
|
-
// indexed read is typed non-optional, so nothing forces the check.
|
|
577
|
-
if (!leftArg || !rightArg) {
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
if (isObjectOrArrayType(leftArg) && isObjectOrArrayType(rightArg)) {
|
|
581
|
-
reportedNodes.add(node);
|
|
582
|
-
const isEqual = node.operator === '===';
|
|
583
|
-
context.report({
|
|
584
|
-
node,
|
|
585
|
-
messageId: 'enforceMicrodiff',
|
|
586
|
-
fix(fixer) {
|
|
587
|
-
if (!canEmitDiffAt(node)) {
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
const compareFix = fixer.replaceText(node, `${DIFF_NAME}(${sourceCode.getText(leftArg)}, ${sourceCode.getText(rightArg)})${isEqual ? '.length === 0' : '.length > 0'}`);
|
|
591
|
-
// The comparison this rewrites almost always sits inside a
|
|
592
|
-
// function, and the `diff` it emits needs an import whatever
|
|
593
|
-
// encloses it. Deciding on the enclosing node left every
|
|
594
|
-
// nested comparison calling a `diff` nothing bound.
|
|
595
|
-
const importFix = buildMicrodiffImportFix(fixer);
|
|
596
|
-
return importFix ? [importFix, compareFix] : compareFix;
|
|
597
|
-
},
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
}
|
|
665
|
+
const comparison = toStringifyComparison(node);
|
|
666
|
+
if (!comparison ||
|
|
667
|
+
!isObjectOrArrayType(comparison.left) ||
|
|
668
|
+
!isObjectOrArrayType(comparison.right)) {
|
|
669
|
+
return;
|
|
601
670
|
}
|
|
671
|
+
reportedNodes.add(node);
|
|
672
|
+
context.report({
|
|
673
|
+
node,
|
|
674
|
+
messageId: 'enforceMicrodiff',
|
|
675
|
+
fix(fixer) {
|
|
676
|
+
if (!canEmitDiffAt(node)) {
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
const compareFix = fixer.replaceText(node, buildDiffComparison(comparison));
|
|
680
|
+
// The comparison this rewrites almost always sits inside a
|
|
681
|
+
// function, and the `diff` it emits needs an import whatever
|
|
682
|
+
// encloses it. Deciding on the enclosing node left every nested
|
|
683
|
+
// comparison calling a `diff` nothing bound.
|
|
684
|
+
const importFix = buildMicrodiffImportFix(fixer);
|
|
685
|
+
return importFix ? [importFix, compareFix] : compareFix;
|
|
686
|
+
},
|
|
687
|
+
});
|
|
602
688
|
},
|
|
603
689
|
// Check for custom deep comparison functions
|
|
604
690
|
FunctionDeclaration(node) {
|
|
@@ -629,29 +715,27 @@ exports.enforceMicrodiff = (0, createRule_1.createRule)({
|
|
|
629
715
|
bodyText.includes('JSON.stringify') &&
|
|
630
716
|
bodyText.includes('!==')) {
|
|
631
717
|
reportedNodes.add(node);
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
const [
|
|
638
|
-
const operands = firstParam.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
639
|
-
secondParam.type === utils_1.AST_NODE_TYPES.Identifier
|
|
640
|
-
? [firstParam.name, secondParam.name]
|
|
641
|
-
: null;
|
|
718
|
+
// Exactly one comparison is the condition for a fix. With none
|
|
719
|
+
// there is no expression to rewrite, and with several the rule
|
|
720
|
+
// cannot tell which one the function's answer turns on, so the
|
|
721
|
+
// report stands on its own.
|
|
722
|
+
const comparisons = collectStringifyComparisons(body);
|
|
723
|
+
const comparison = comparisons.length === 1 ? comparisons[0] : null;
|
|
642
724
|
context.report({
|
|
643
725
|
node,
|
|
644
726
|
messageId: 'enforceMicrodiff',
|
|
645
727
|
fix(fixer) {
|
|
646
|
-
if (!
|
|
728
|
+
if (!comparison || !canEmitDiffAt(node)) {
|
|
647
729
|
return null;
|
|
648
730
|
}
|
|
649
|
-
// Only the
|
|
650
|
-
// annotations, its modifiers
|
|
651
|
-
// it
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
|
|
731
|
+
// Only the comparison's own range is rewritten. The signature
|
|
732
|
+
// keeps its type annotations, its modifiers and any `export`
|
|
733
|
+
// in front of it, and the body keeps everything the
|
|
734
|
+
// comparison shares it with: side effects, guard clauses,
|
|
735
|
+
// locals, and the comments around them. Re-emitting the body
|
|
736
|
+
// as a single return drops all of that silently — the fix
|
|
737
|
+
// compiles, so nothing downstream flags the loss.
|
|
738
|
+
const bodyFix = fixer.replaceText(comparison.node, buildDiffComparison(comparison));
|
|
655
739
|
const importFix = buildMicrodiffImportFix(fixer);
|
|
656
740
|
return importFix ? [importFix, bodyFix] : bodyFix;
|
|
657
741
|
},
|
|
@@ -973,6 +973,78 @@ function isDefinitelyNonBooleanExpression(node) {
|
|
|
973
973
|
return false;
|
|
974
974
|
}
|
|
975
975
|
}
|
|
976
|
+
// Operators whose result is always a boolean, regardless of operand types.
|
|
977
|
+
const BOOLEAN_BINARY_OPERATORS = new Set([
|
|
978
|
+
'==',
|
|
979
|
+
'!=',
|
|
980
|
+
'===',
|
|
981
|
+
'!==',
|
|
982
|
+
'<',
|
|
983
|
+
'<=',
|
|
984
|
+
'>',
|
|
985
|
+
'>=',
|
|
986
|
+
'in',
|
|
987
|
+
'instanceof',
|
|
988
|
+
]);
|
|
989
|
+
/**
|
|
990
|
+
* Detects an expression that is definitively a boolean — a boolean literal, a
|
|
991
|
+
* negation, a comparison, or a branch/`Boolean()` call built from those. Opaque
|
|
992
|
+
* expressions (calls, identifiers, member accesses) yield no verdict here: they
|
|
993
|
+
* are the shapes a validator's body takes once its return annotation is gone,
|
|
994
|
+
* and assuming boolean for them is what produced the false positive in #1692.
|
|
995
|
+
*/
|
|
996
|
+
function isDefinitelyBooleanExpression(node) {
|
|
997
|
+
switch (node.type) {
|
|
998
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
999
|
+
return typeof node.value === 'boolean';
|
|
1000
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
1001
|
+
return node.operator === '!' || node.operator === 'delete';
|
|
1002
|
+
case utils_1.AST_NODE_TYPES.BinaryExpression:
|
|
1003
|
+
return BOOLEAN_BINARY_OPERATORS.has(node.operator);
|
|
1004
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
1005
|
+
return (isDefinitelyBooleanExpression(node.left) &&
|
|
1006
|
+
isDefinitelyBooleanExpression(node.right));
|
|
1007
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
1008
|
+
return (isDefinitelyBooleanExpression(node.consequent) &&
|
|
1009
|
+
isDefinitelyBooleanExpression(node.alternate));
|
|
1010
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
1011
|
+
// `Boolean(x)` is the one call whose result is boolean by construction.
|
|
1012
|
+
return (node.callee.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
1013
|
+
node.callee.name === 'Boolean');
|
|
1014
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
1015
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
1016
|
+
// `x as boolean` asserts booleanness; `x as const` and other assertions
|
|
1017
|
+
// say nothing, so the asserted expression decides.
|
|
1018
|
+
return (isBooleanOnlyType(node.typeAnnotation) ||
|
|
1019
|
+
isDefinitelyBooleanExpression(node.expression));
|
|
1020
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
1021
|
+
return isDefinitelyBooleanExpression(node.expression);
|
|
1022
|
+
default:
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
function classifyExpression(node) {
|
|
1027
|
+
// A definitively non-boolean shape wins over a boolean one: a validator's
|
|
1028
|
+
// `return 'Must not be blank'` proves the function is not a predicate even
|
|
1029
|
+
// though its success path returns `true`.
|
|
1030
|
+
if (isDefinitelyNonBooleanExpression(node))
|
|
1031
|
+
return 'nonBoolean';
|
|
1032
|
+
if (isDefinitelyBooleanExpression(node))
|
|
1033
|
+
return 'boolean';
|
|
1034
|
+
return 'indeterminate';
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Combines the verdicts of a body's returns under the same precedence:
|
|
1038
|
+
* non-boolean beats boolean, and boolean beats no verdict at all. An empty list
|
|
1039
|
+
* (a body with no returns) is `indeterminate`.
|
|
1040
|
+
*/
|
|
1041
|
+
function combineReturnKinds(kinds) {
|
|
1042
|
+
if (kinds.includes('nonBoolean'))
|
|
1043
|
+
return 'nonBoolean';
|
|
1044
|
+
if (kinds.includes('boolean'))
|
|
1045
|
+
return 'boolean';
|
|
1046
|
+
return 'indeterminate';
|
|
1047
|
+
}
|
|
976
1048
|
/**
|
|
977
1049
|
* Yields the immediate AST-node children of `node`, skipping the `parent`
|
|
978
1050
|
* back-reference so traversal only walks downward.
|
|
@@ -995,10 +1067,12 @@ function childNodesOf(node) {
|
|
|
995
1067
|
return children;
|
|
996
1068
|
}
|
|
997
1069
|
/**
|
|
998
|
-
*
|
|
999
|
-
* function's)
|
|
1070
|
+
* Classifies the `return` statements belonging to `fn`'s own body (not a nested
|
|
1071
|
+
* function's). A bare `return;` carries no verdict, so it neither exempts the
|
|
1072
|
+
* function nor keeps a sibling boolean return from deciding.
|
|
1000
1073
|
*/
|
|
1001
|
-
function
|
|
1074
|
+
function classifyBlockReturns(block) {
|
|
1075
|
+
const kinds = [];
|
|
1002
1076
|
const stack = [block];
|
|
1003
1077
|
while (stack.length > 0) {
|
|
1004
1078
|
const current = stack.pop();
|
|
@@ -1009,41 +1083,54 @@ function blockReturnsNonBoolean(block) {
|
|
|
1009
1083
|
current.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression)) {
|
|
1010
1084
|
continue;
|
|
1011
1085
|
}
|
|
1012
|
-
if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement &&
|
|
1013
|
-
current.argument
|
|
1014
|
-
isDefinitelyNonBooleanExpression(current.argument)) {
|
|
1015
|
-
return true;
|
|
1086
|
+
if (current.type === utils_1.AST_NODE_TYPES.ReturnStatement && current.argument) {
|
|
1087
|
+
kinds.push(classifyExpression(current.argument));
|
|
1016
1088
|
}
|
|
1017
1089
|
for (const child of childNodesOf(current)) {
|
|
1018
1090
|
stack.push(child);
|
|
1019
1091
|
}
|
|
1020
1092
|
}
|
|
1021
|
-
return
|
|
1093
|
+
return combineReturnKinds(kinds);
|
|
1022
1094
|
}
|
|
1023
1095
|
/**
|
|
1024
|
-
*
|
|
1025
|
-
* non-boolean value — e.g. a validator predicate returning `string | true`. An
|
|
1096
|
+
* The booleanness of a function backing an `is`/`has`-prefixed name. An
|
|
1026
1097
|
* explicit return-type annotation is authoritative; otherwise the body's own
|
|
1027
|
-
* `return` statements (or the concise-arrow expression)
|
|
1098
|
+
* `return` statements (or the concise-arrow expression) decide.
|
|
1028
1099
|
*/
|
|
1029
|
-
function
|
|
1100
|
+
function classifyFunctionReturn(fn) {
|
|
1030
1101
|
if (fn.returnType) {
|
|
1031
|
-
return
|
|
1102
|
+
return isBooleanOnlyType(fn.returnType.typeAnnotation)
|
|
1103
|
+
? 'boolean'
|
|
1104
|
+
: 'nonBoolean';
|
|
1032
1105
|
}
|
|
1033
1106
|
if (fn.body.type !== utils_1.AST_NODE_TYPES.BlockStatement) {
|
|
1034
|
-
return
|
|
1107
|
+
return classifyExpression(fn.body);
|
|
1035
1108
|
}
|
|
1036
|
-
return
|
|
1109
|
+
return classifyBlockReturns(fn.body);
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Whether a function backing an `is`/`has`-prefixed name must be exempt from
|
|
1113
|
+
* boolean negative-naming. Only a function proven to return a boolean is
|
|
1114
|
+
* flagged: a validator predicate returning `string | true` is exempt, and so is
|
|
1115
|
+
* one whose returns are syntactically opaque (`=> validate(value)`). That
|
|
1116
|
+
* matters because `no-explicit-return-type` deletes the very annotation that
|
|
1117
|
+
* spells the validator's non-boolean return, leaving nothing but the name to go
|
|
1118
|
+
* on — and guessing "boolean" from the name alone reports a rename that inverts
|
|
1119
|
+
* the predicate's meaning (#1692). Preferring a false negative here is the
|
|
1120
|
+
* repository's stated trade-off.
|
|
1121
|
+
*/
|
|
1122
|
+
function isExemptFromBooleanNaming(fn) {
|
|
1123
|
+
return classifyFunctionReturn(fn) !== 'boolean';
|
|
1037
1124
|
}
|
|
1038
1125
|
/**
|
|
1039
|
-
* When a declarator/property value is a function, whether that function is
|
|
1040
|
-
*
|
|
1126
|
+
* When a declarator/property value is a function, whether that function is
|
|
1127
|
+
* exempt from boolean negative-naming.
|
|
1041
1128
|
*/
|
|
1042
|
-
function
|
|
1129
|
+
function isExemptFunctionValue(node) {
|
|
1043
1130
|
return (!!node &&
|
|
1044
1131
|
(node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
1045
1132
|
node.type === utils_1.AST_NODE_TYPES.FunctionExpression) &&
|
|
1046
|
-
|
|
1133
|
+
isExemptFromBooleanNaming(node));
|
|
1047
1134
|
}
|
|
1048
1135
|
exports.enforcePositiveNaming = (0, createRule_1.createRule)({
|
|
1049
1136
|
name: 'enforce-positive-naming',
|
|
@@ -1280,7 +1367,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
|
|
|
1280
1367
|
// with `is`/`has` but is not a boolean, so its domain-correct negation
|
|
1281
1368
|
// ("isNotBlank") must not be flagged. The name heuristic alone cannot
|
|
1282
1369
|
// tell them apart; the initializer's return shape can.
|
|
1283
|
-
if (
|
|
1370
|
+
if (isExemptFunctionValue(node.init))
|
|
1284
1371
|
return;
|
|
1285
1372
|
const variableName = node.id.name;
|
|
1286
1373
|
const { isNegative, alternatives } = hasBooleanNegativeNaming(variableName);
|
|
@@ -1323,8 +1410,9 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
|
|
|
1323
1410
|
if (!isBooleanLike(node.id || node))
|
|
1324
1411
|
return;
|
|
1325
1412
|
// Skip validator predicates that return a non-boolean value (e.g.
|
|
1326
|
-
// `string | true`), whose negation is the domain-correct term
|
|
1327
|
-
|
|
1413
|
+
// `string | true`), whose negation is the domain-correct term, and any
|
|
1414
|
+
// function whose returns give no syntactic verdict.
|
|
1415
|
+
if (isExemptFromBooleanNaming(node))
|
|
1328
1416
|
return;
|
|
1329
1417
|
const { isNegative, alternatives } = hasBooleanNegativeNaming(functionName);
|
|
1330
1418
|
if (isNegative) {
|
|
@@ -1348,7 +1436,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
|
|
|
1348
1436
|
if (!isBooleanLike(node.key))
|
|
1349
1437
|
return;
|
|
1350
1438
|
// Skip validator predicates returning a non-boolean value.
|
|
1351
|
-
if (
|
|
1439
|
+
if (isExemptFunctionValue(node.value))
|
|
1352
1440
|
return;
|
|
1353
1441
|
const methodName = node.key.name;
|
|
1354
1442
|
const { isNegative, alternatives } = hasBooleanNegativeNaming(methodName);
|
|
@@ -1373,7 +1461,7 @@ exports.enforcePositiveNaming = (0, createRule_1.createRule)({
|
|
|
1373
1461
|
if (!isBooleanLike(node.key))
|
|
1374
1462
|
return;
|
|
1375
1463
|
// Skip validator predicates returning a non-boolean value.
|
|
1376
|
-
if (
|
|
1464
|
+
if (isExemptFunctionValue(node.value))
|
|
1377
1465
|
return;
|
|
1378
1466
|
const propertyName = node.key.name;
|
|
1379
1467
|
const { isNegative, alternatives } = hasBooleanNegativeNaming(propertyName);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collects every `RuleTester` case the suite declares WITHOUT executing any of
|
|
3
|
+
* them, by shadowing `run` on the shared tester instances and then loading each
|
|
4
|
+
* suite for its declarations alone.
|
|
5
|
+
*
|
|
6
|
+
* A guard that wants to exercise fixtures rather than documented snippets has no
|
|
7
|
+
* other way in. `src/tests/*.test.ts` call `RuleTester.run` at module scope, so
|
|
8
|
+
* importing one normally re-executes it — measured at 2350 tests, 2 minutes and
|
|
9
|
+
* 48 cross-file side-effect failures, which is why
|
|
10
|
+
* `recommended-config-fix-closure.test.ts` reads docs fenced blocks instead.
|
|
11
|
+
* Shadowing `run` before the load turns each of those calls into a declaration
|
|
12
|
+
* capture, so the cases are collected at the cost of loading the module and
|
|
13
|
+
* nothing more.
|
|
14
|
+
*
|
|
15
|
+
* The fixtures are worth reaching precisely because they are not the docs: a
|
|
16
|
+
* rule's `valid` list is written to sit on its carve-out boundaries, which is
|
|
17
|
+
* where a sibling fixer destroys an exemption. Every finding of that class
|
|
18
|
+
* (#1595-#1599, #1603, #1677-#1682) came from this corpus; the docs corpus
|
|
19
|
+
* caught none of them.
|
|
20
|
+
*/
|
|
21
|
+
/** A single `ruleTester.run(name, rule, tests)` call, captured but not run. */
|
|
22
|
+
export type HarvestedSuite = {
|
|
23
|
+
/** The display name the suite passed to `run`. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Which shared tester export declared it, which fixes the parser. */
|
|
26
|
+
tester: string;
|
|
27
|
+
/** Basename of the declaring file, so a finding is reproducible by hand. */
|
|
28
|
+
file: string;
|
|
29
|
+
/**
|
|
30
|
+
* The rule object itself. Callers resolve a rule NAME from this by identity
|
|
31
|
+
* against the plugin's own map rather than from `name`: 100 of the suites
|
|
32
|
+
* pass a display name that is not a rule name (`requireMemo`,
|
|
33
|
+
* `prefer-next-dynamic (JSX scenarios)`, `no-hungarian-phone-number-test`),
|
|
34
|
+
* and name-keyed matching silently drops every one of them.
|
|
35
|
+
*/
|
|
36
|
+
rule: unknown;
|
|
37
|
+
valid: readonly unknown[];
|
|
38
|
+
invalid: readonly unknown[];
|
|
39
|
+
};
|
|
40
|
+
export type HarvestResult = {
|
|
41
|
+
suites: HarvestedSuite[];
|
|
42
|
+
/** Files that threw while loading, `basename: message`. */
|
|
43
|
+
failures: string[];
|
|
44
|
+
/** Non-vacuity accounting: a silent drop here would fake a clean sweep. */
|
|
45
|
+
filesLoaded: number;
|
|
46
|
+
filesSkipped: number;
|
|
47
|
+
};
|
|
48
|
+
export declare function harvestRuleTesterCases(): HarvestResult;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.harvestRuleTesterCases = void 0;
|
|
27
|
+
const fs = __importStar(require("fs"));
|
|
28
|
+
const os = __importStar(require("os"));
|
|
29
|
+
const path = __importStar(require("path"));
|
|
30
|
+
const sharedTesters = __importStar(require("./ruleTester"));
|
|
31
|
+
const TESTS_DIR = path.join(__dirname, '..', 'tests');
|
|
32
|
+
/**
|
|
33
|
+
* Only suites that import the shared tester module can declare a case, since
|
|
34
|
+
* `src/tests/no-local-rule-tester.test.ts` forbids a locally-built tester. That
|
|
35
|
+
* makes the import a sound admission test rather than a heuristic, and it is
|
|
36
|
+
* what keeps this affordable: the ~28 files it excludes are the meta-suites
|
|
37
|
+
* (`fixer-type-safety`, `docs-examples-conformance`, `rule-crash-robustness`,
|
|
38
|
+
* this guard's own siblings) which run full corpus sweeps at module scope and
|
|
39
|
+
* cost more to load than every rule suite combined.
|
|
40
|
+
*
|
|
41
|
+
* Matching the import rather than a `ruleTesterTs.run(` call site is
|
|
42
|
+
* deliberate: `prefer-next-dynamic.test.ts` aliases the tester
|
|
43
|
+
* (`const jsx = ruleTesterJsx`) before calling `run`, so a call-site pattern
|
|
44
|
+
* drops it.
|
|
45
|
+
*/
|
|
46
|
+
const IMPORTS_SHARED_TESTER = /from\s+'\.\.\/utils\/ruleTester'/;
|
|
47
|
+
/**
|
|
48
|
+
* Jest registers a test for every `describe`/`it` a loaded module calls, so
|
|
49
|
+
* loading 271 suites inside a suite would graft their entire test list onto
|
|
50
|
+
* this one. Neutralizing the registrars for the duration of the load keeps the
|
|
51
|
+
* captured declarations and discards the registrations.
|
|
52
|
+
*
|
|
53
|
+
* `describe` bodies still execute — several suites call `run` inside one, and
|
|
54
|
+
* skipping the body would drop those cases — but everything that would register
|
|
55
|
+
* or assert becomes a no-op.
|
|
56
|
+
*/
|
|
57
|
+
const REGISTRAR_GLOBALS = [
|
|
58
|
+
'it',
|
|
59
|
+
'test',
|
|
60
|
+
'beforeEach',
|
|
61
|
+
'afterEach',
|
|
62
|
+
'beforeAll',
|
|
63
|
+
'afterAll',
|
|
64
|
+
];
|
|
65
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
66
|
+
const asAny = (value) => value;
|
|
67
|
+
const noop = () => undefined;
|
|
68
|
+
/** `it.each(rows)(name, fn)` is a call chain, so the stub needs the same shape. */
|
|
69
|
+
const withEach = (fn) => {
|
|
70
|
+
fn.each = () => () => undefined;
|
|
71
|
+
fn.only = fn;
|
|
72
|
+
fn.skip = fn;
|
|
73
|
+
fn.todo = noop;
|
|
74
|
+
fn.failing = fn;
|
|
75
|
+
fn.concurrent = fn;
|
|
76
|
+
return fn;
|
|
77
|
+
};
|
|
78
|
+
function harvestRuleTesterCases() {
|
|
79
|
+
const suites = [];
|
|
80
|
+
const failures = [];
|
|
81
|
+
let currentFile = '';
|
|
82
|
+
let filesLoaded = 0;
|
|
83
|
+
let filesSkipped = 0;
|
|
84
|
+
const testerEntries = Object.entries(sharedTesters).filter(([, value]) => typeof asAny(value)?.run === 'function');
|
|
85
|
+
const originalRun = new Map();
|
|
86
|
+
for (const [key, tester] of testerEntries) {
|
|
87
|
+
originalRun.set(key, tester.run);
|
|
88
|
+
tester.run = (name, rule, tests) => {
|
|
89
|
+
const bag = asAny(tests) || {};
|
|
90
|
+
suites.push({
|
|
91
|
+
name,
|
|
92
|
+
tester: key,
|
|
93
|
+
file: currentFile,
|
|
94
|
+
rule,
|
|
95
|
+
valid: bag.valid || [],
|
|
96
|
+
invalid: bag.invalid || [],
|
|
97
|
+
});
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const globalScope = global;
|
|
102
|
+
const savedGlobals = new Map();
|
|
103
|
+
const stub = (key, value) => {
|
|
104
|
+
savedGlobals.set(key, globalScope[key]);
|
|
105
|
+
globalScope[key] = value;
|
|
106
|
+
};
|
|
107
|
+
stub('describe', withEach((_name, body) => {
|
|
108
|
+
if (typeof body === 'function')
|
|
109
|
+
body();
|
|
110
|
+
}));
|
|
111
|
+
for (const key of REGISTRAR_GLOBALS)
|
|
112
|
+
stub(key, withEach(noop));
|
|
113
|
+
/**
|
|
114
|
+
* A handful of suites write fixture files at module scope, and
|
|
115
|
+
* `test-file-location-enforcement` writes them under
|
|
116
|
+
* `path.join(process.cwd(), '.cursor/tmp/...')` — a path it also `rmSync`s in
|
|
117
|
+
* an `afterAll` that the stubs above turn into a no-op. Loading it from the
|
|
118
|
+
* real working directory would therefore race that suite when it runs
|
|
119
|
+
* concurrently in another worker (its cleanup landing between this harvest's
|
|
120
|
+
* `mkdirSync` and `writeFileSync` throws ENOENT) and would strand fixtures in
|
|
121
|
+
* the repo when it does not. Pointing the working directory at a private
|
|
122
|
+
* scratch root for the duration of the load sends every cwd-derived write
|
|
123
|
+
* somewhere no other worker can see, and it is removed below.
|
|
124
|
+
*
|
|
125
|
+
* The paths the suites *record* are unaffected: they are made relative to the
|
|
126
|
+
* same cwd they were built from, so the harvested filenames come out
|
|
127
|
+
* identical either way.
|
|
128
|
+
*/
|
|
129
|
+
const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'blumint-harvest-'));
|
|
130
|
+
const realCwd = process.cwd();
|
|
131
|
+
process.chdir(scratchRoot);
|
|
132
|
+
try {
|
|
133
|
+
const files = fs
|
|
134
|
+
.readdirSync(TESTS_DIR)
|
|
135
|
+
.filter((file) => file.endsWith('.test.ts'))
|
|
136
|
+
.sort();
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const fullPath = path.join(TESTS_DIR, file);
|
|
139
|
+
if (!IMPORTS_SHARED_TESTER.test(fs.readFileSync(fullPath, 'utf8'))) {
|
|
140
|
+
filesSkipped++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
currentFile = file;
|
|
144
|
+
try {
|
|
145
|
+
require(fullPath);
|
|
146
|
+
filesLoaded++;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
failures.push(`${file}: ${error?.message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
process.chdir(realCwd);
|
|
155
|
+
fs.rmSync(scratchRoot, { recursive: true, force: true });
|
|
156
|
+
for (const [key, tester] of testerEntries) {
|
|
157
|
+
const original = originalRun.get(key);
|
|
158
|
+
if (original)
|
|
159
|
+
tester.run = original;
|
|
160
|
+
}
|
|
161
|
+
for (const [key, value] of savedGlobals)
|
|
162
|
+
globalScope[key] = value;
|
|
163
|
+
}
|
|
164
|
+
return { suites, failures, filesLoaded, filesSkipped };
|
|
165
|
+
}
|
|
166
|
+
exports.harvestRuleTesterCases = harvestRuleTesterCases;
|
|
167
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
168
|
+
//# sourceMappingURL=harvestRuleTesterCases.js.map
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.20.98",
|
|
4
|
+
"date": "2026-08-04T10:58:56.821Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "enforce-centralized-mock-firestore",
|
|
8
|
+
"changeType": "fix",
|
|
9
|
+
"issues": [
|
|
10
|
+
1694,
|
|
11
|
+
1695
|
|
12
|
+
],
|
|
13
|
+
"summary": "place the import by anchor instead of rewriting the Program (closes #1695); retire declarations by range, not by line index (closes #1694)"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "enforce-microdiff",
|
|
17
|
+
"changeType": "fix",
|
|
18
|
+
"issues": [
|
|
19
|
+
1693
|
|
20
|
+
],
|
|
21
|
+
"summary": "rewrite the comparison in place instead of the whole body (closes #1693)"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"version": "1.20.97",
|
|
27
|
+
"date": "2026-08-04T08:50:14.394Z",
|
|
28
|
+
"rules": [
|
|
29
|
+
{
|
|
30
|
+
"name": "enforce-boolean-naming-prefixes",
|
|
31
|
+
"changeType": "fix",
|
|
32
|
+
"issues": [
|
|
33
|
+
1690,
|
|
34
|
+
1691
|
|
35
|
+
],
|
|
36
|
+
"summary": "infer a callee's return from its body when the annotation is absent (closes #1691); accept a digit or $ fused onto an UPPER_SNAKE prefix (closes #1690)"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "enforce-positive-naming",
|
|
40
|
+
"changeType": "fix",
|
|
41
|
+
"issues": [
|
|
42
|
+
1692
|
|
43
|
+
],
|
|
44
|
+
"summary": "decline when a function's returns yield no verdict (closes #1692)"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
2
48
|
{
|
|
3
49
|
"version": "1.20.96",
|
|
4
50
|
"date": "2026-08-04T07:23:58.552Z",
|