@coryrylan/tools 0.1.0
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/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/eslint/configs/browser.d.ts +12 -0
- package/dist/eslint/configs/browser.js +40 -0
- package/dist/eslint/configs/html.d.ts +10 -0
- package/dist/eslint/configs/html.js +38 -0
- package/dist/eslint/configs/json.d.ts +13 -0
- package/dist/eslint/configs/json.js +39 -0
- package/dist/eslint/configs/shared.d.ts +11 -0
- package/dist/eslint/configs/shared.js +19 -0
- package/dist/eslint/configs/tests.d.ts +8 -0
- package/dist/eslint/configs/tests.js +20 -0
- package/dist/eslint/configs/typescript.d.ts +18 -0
- package/dist/eslint/configs/typescript.js +104 -0
- package/dist/eslint/index.d.ts +27 -0
- package/dist/eslint/index.js +19 -0
- package/dist/eslint/plugin.d.ts +8 -0
- package/dist/eslint/plugin.js +36 -0
- package/dist/eslint/rules/consistent-error-messages.d.ts +10 -0
- package/dist/eslint/rules/consistent-error-messages.js +127 -0
- package/dist/eslint/rules/no-dead-code.d.ts +16 -0
- package/dist/eslint/rules/no-dead-code.js +110 -0
- package/dist/eslint/rules/no-deep-class-inheritance.d.ts +19 -0
- package/dist/eslint/rules/no-deep-class-inheritance.js +132 -0
- package/dist/eslint/rules/no-reexport-barrels.d.ts +10 -0
- package/dist/eslint/rules/no-reexport-barrels.js +55 -0
- package/dist/eslint/rules/no-single-consumer-abstraction.d.ts +3 -0
- package/dist/eslint/rules/no-single-consumer-abstraction.js +364 -0
- package/dist/eslint/rules/no-unjustified-disable.d.ts +11 -0
- package/dist/eslint/rules/no-unjustified-disable.js +75 -0
- package/dist/eslint/rules/no-unpinned-dependency-ranges.d.ts +46 -0
- package/dist/eslint/rules/no-unpinned-dependency-ranges.js +79 -0
- package/dist/eslint/rules/require-listener-cleanup.d.ts +8 -0
- package/dist/eslint/rules/require-listener-cleanup.js +202 -0
- package/dist/eslint/rules/require-observer-cleanup.d.ts +8 -0
- package/dist/eslint/rules/require-observer-cleanup.js +69 -0
- package/dist/eslint/rules/require-timer-cleanup.d.ts +10 -0
- package/dist/eslint/rules/require-timer-cleanup.js +142 -0
- package/dist/eslint/rules/utils.d.ts +45 -0
- package/dist/eslint/rules/utils.js +109 -0
- package/dist/prettier/index.d.ts +15 -0
- package/dist/prettier/index.js +26 -0
- package/dist/stylelint/index.d.ts +10 -0
- package/dist/stylelint/index.js +50 -0
- package/dist/vale/styles/config/vocabularies/Tools/accept.txt +80 -0
- package/dist/vale/styles/config/vocabularies/Tools/reject.txt +5 -0
- package/dist/vale/vale.ini +27 -0
- package/dist/vite/index.d.ts +37 -0
- package/dist/vite/index.js +61 -0
- package/dist/vite/plugins/dts.d.ts +26 -0
- package/dist/vite/plugins/dts.js +106 -0
- package/dist/vite/plugins/write-if-changed.d.ts +20 -0
- package/dist/vite/plugins/write-if-changed.js +33 -0
- package/dist/vitest/browser.d.ts +31 -0
- package/dist/vitest/browser.js +105 -0
- package/dist/vitest/index.d.ts +28 -0
- package/dist/vitest/index.js +34 -0
- package/package.json +200 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { normalize, walk } from "./utils.js";
|
|
2
|
+
//#region src/eslint/rules/require-listener-cleanup.ts
|
|
3
|
+
var DEFAULT_LIFECYCLE_PAIRS = [["connectedCallback", "disconnectedCallback"]];
|
|
4
|
+
var rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: {
|
|
8
|
+
description: "Ensures addEventListener calls in a setup lifecycle method have a matching removeEventListener in the paired teardown method, and forbids addEventListener in the constructor.",
|
|
9
|
+
recommended: true
|
|
10
|
+
},
|
|
11
|
+
schema: [{
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: { lifecyclePairs: {
|
|
14
|
+
type: "array",
|
|
15
|
+
items: {
|
|
16
|
+
type: "array",
|
|
17
|
+
items: [{ type: "string" }, { type: "string" }],
|
|
18
|
+
minItems: 2,
|
|
19
|
+
maxItems: 2
|
|
20
|
+
}
|
|
21
|
+
} },
|
|
22
|
+
additionalProperties: false
|
|
23
|
+
}],
|
|
24
|
+
messages: {
|
|
25
|
+
"missing-cleanup": "`addEventListener` on `{{target}}` for `{{event}}` is called in {{setup}}() but no matching `removeEventListener` is called in {{teardown}}(). Attach the listener via a class field and remove the same reference in {{teardown}}() to prevent a listener leak when {{setup}} runs again.",
|
|
26
|
+
"missing-teardown": "{{setup}}() attaches event listeners but the class has no {{teardown}}(). Add a {{teardown}}() that removes each listener with its original reference to prevent a leak when {{setup}} runs again.",
|
|
27
|
+
"listener-in-constructor": "`addEventListener` on `{{target}}` for `{{event}}` is called in the constructor. The constructor runs once per instance, so this listener cannot be paired with a teardown call and leaks if the instance is torn down and recreated. Move the call into a setup lifecycle method and pair it with a matching removeEventListener in the corresponding teardown method."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
const pairs = getLifecyclePairs(context);
|
|
32
|
+
const handleClass = (node) => {
|
|
33
|
+
checkClass({
|
|
34
|
+
context,
|
|
35
|
+
classNode: node
|
|
36
|
+
}, pairs);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
ClassDeclaration: handleClass,
|
|
40
|
+
ClassExpression: handleClass
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function checkClass(env, pairs) {
|
|
45
|
+
checkConstructor(env);
|
|
46
|
+
for (const pair of pairs) checkPair(env, pair);
|
|
47
|
+
}
|
|
48
|
+
function checkConstructor(env) {
|
|
49
|
+
const ctor = findConstructor(env.classNode);
|
|
50
|
+
if (!ctor) return;
|
|
51
|
+
for (const add of collectListenerCalls(env, ctor, "addEventListener")) env.context.report({
|
|
52
|
+
node: add.node,
|
|
53
|
+
messageId: "listener-in-constructor",
|
|
54
|
+
data: {
|
|
55
|
+
target: add.target,
|
|
56
|
+
event: add.event
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function checkPair(env, pair) {
|
|
61
|
+
const [setupName, teardownName] = pair;
|
|
62
|
+
const setup = findMethod(env.classNode, setupName);
|
|
63
|
+
if (!setup) return;
|
|
64
|
+
const addCalls = collectListenerCalls(env, setup, "addEventListener").filter((add) => !isSelfCleaningListenerCall(add.node));
|
|
65
|
+
if (addCalls.length === 0) return;
|
|
66
|
+
const teardown = findMethod(env.classNode, teardownName);
|
|
67
|
+
if (!teardown) {
|
|
68
|
+
env.context.report({
|
|
69
|
+
node: setup,
|
|
70
|
+
messageId: "missing-teardown",
|
|
71
|
+
data: {
|
|
72
|
+
setup: setupName,
|
|
73
|
+
teardown: teardownName
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
reportUnmatched(env, {
|
|
79
|
+
addCalls,
|
|
80
|
+
removeCalls: collectListenerCalls(env, teardown, "removeEventListener"),
|
|
81
|
+
setupName,
|
|
82
|
+
teardownName
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* An `addEventListener` call whose options argument statically guarantees the
|
|
87
|
+
* listener detaches itself needs no paired `removeEventListener`:
|
|
88
|
+
* `{ once: true }` self-removes after the first invocation, and `{ signal }`
|
|
89
|
+
* delegates teardown to whatever aborts that `AbortSignal` - which commonly
|
|
90
|
+
* lives outside the setup/teardown pair this rule pairs up, e.g. a shared
|
|
91
|
+
* `AbortController` aborted from a different lifecycle hook entirely.
|
|
92
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options
|
|
93
|
+
*
|
|
94
|
+
* Only a statically-analyzable options object exempts the call: an options
|
|
95
|
+
* value that isn't an object literal (a boolean, an identifier, a spread-only
|
|
96
|
+
* object) can't be proven self-cleaning here, so it keeps requiring a paired
|
|
97
|
+
* `removeEventListener` rather than risk a false negative.
|
|
98
|
+
*/
|
|
99
|
+
function isSelfCleaningListenerCall(node) {
|
|
100
|
+
const optionsArg = node.arguments[2];
|
|
101
|
+
if (!optionsArg || optionsArg.type !== "ObjectExpression") return false;
|
|
102
|
+
return optionsArg.properties.some((property) => isAbortSignalProperty(property) || isOnceTrueProperty(property));
|
|
103
|
+
}
|
|
104
|
+
function isAbortSignalProperty(property) {
|
|
105
|
+
return getStaticPropertyName(property) === "signal";
|
|
106
|
+
}
|
|
107
|
+
function isOnceTrueProperty(property) {
|
|
108
|
+
if (property.type !== "Property" || getStaticPropertyName(property) !== "once") return false;
|
|
109
|
+
return property.value.type === "Literal" && property.value.value === true;
|
|
110
|
+
}
|
|
111
|
+
function getStaticPropertyName(property) {
|
|
112
|
+
if (property.type !== "Property" || property.computed) return null;
|
|
113
|
+
if (property.key.type === "Identifier") return property.key.name;
|
|
114
|
+
return property.key.type === "Literal" && typeof property.key.value === "string" ? property.key.value : null;
|
|
115
|
+
}
|
|
116
|
+
function reportUnmatched(env, check) {
|
|
117
|
+
for (const add of check.addCalls) if (!check.removeCalls.some((remove) => remove.target === add.target && remove.event === add.event)) env.context.report({
|
|
118
|
+
node: add.node,
|
|
119
|
+
messageId: "missing-cleanup",
|
|
120
|
+
data: {
|
|
121
|
+
target: add.target,
|
|
122
|
+
event: add.event,
|
|
123
|
+
setup: check.setupName,
|
|
124
|
+
teardown: check.teardownName
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function findConstructor(classNode) {
|
|
129
|
+
for (const member of classNode.body.body) if (member.type === "MethodDefinition" && member.kind === "constructor") return member;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function findMethod(classNode, name) {
|
|
133
|
+
for (const member of classNode.body.body) if (isNamedMethod(member, name)) return member;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function isNamedMethod(member, name) {
|
|
137
|
+
return member.type === "MethodDefinition" && !member.static && member.key.type === "Identifier" && member.key.name === name;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Collects `methodName` calls in `methodNode`'s own body, plus (depth-1 only)
|
|
141
|
+
* calls made from any private helper method that `methodNode` calls directly
|
|
142
|
+
* - the common pattern of a lifecycle method delegating to `#setup()`/`#teardown()`.
|
|
143
|
+
*/
|
|
144
|
+
function collectListenerCalls(env, methodNode, methodName) {
|
|
145
|
+
const results = collectInBody(env.context, methodNode.value.body, methodName);
|
|
146
|
+
const visited = /* @__PURE__ */ new Set([methodNode]);
|
|
147
|
+
walk(methodNode.value.body, (node) => {
|
|
148
|
+
if (node.type !== "CallExpression") return;
|
|
149
|
+
const resolved = resolvePrivateMethodCall(node, env.classNode);
|
|
150
|
+
if (!resolved || visited.has(resolved)) return;
|
|
151
|
+
visited.add(resolved);
|
|
152
|
+
results.push(...collectInBody(env.context, resolved.value.body, methodName));
|
|
153
|
+
});
|
|
154
|
+
return results;
|
|
155
|
+
}
|
|
156
|
+
function collectInBody(context, body, methodName) {
|
|
157
|
+
const results = [];
|
|
158
|
+
walk(body, (node) => {
|
|
159
|
+
if (node.type !== "CallExpression") return;
|
|
160
|
+
const call = matchListenerCall(context, node, methodName);
|
|
161
|
+
if (call) results.push(call);
|
|
162
|
+
});
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
165
|
+
function matchListenerCall(context, node, methodName) {
|
|
166
|
+
const callee = node.callee;
|
|
167
|
+
if (callee.type !== "MemberExpression") return null;
|
|
168
|
+
const property = callee.property;
|
|
169
|
+
if (property.type !== "Identifier" || property.name !== methodName) return null;
|
|
170
|
+
const eventArg = node.arguments[0];
|
|
171
|
+
if (!eventArg) return null;
|
|
172
|
+
return {
|
|
173
|
+
node,
|
|
174
|
+
target: normalize(context.sourceCode.getText(callee.object)),
|
|
175
|
+
event: normalize(context.sourceCode.getText(eventArg))
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function resolvePrivateMethodCall(callNode, classNode) {
|
|
179
|
+
const callee = callNode.callee;
|
|
180
|
+
if (callee.type !== "MemberExpression") return null;
|
|
181
|
+
if (callee.object.type !== "ThisExpression" || callee.property.type !== "PrivateIdentifier") return null;
|
|
182
|
+
const name = callee.property.name;
|
|
183
|
+
for (const member of classNode.body.body) if (isPrivateMethod(member, name)) return member;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
function isPrivateMethod(member, name) {
|
|
187
|
+
return member.type === "MethodDefinition" && !member.static && member.kind === "method" && member.key.type === "PrivateIdentifier" && member.key.name === name;
|
|
188
|
+
}
|
|
189
|
+
function getLifecyclePairs(context) {
|
|
190
|
+
const raw = context.options[0];
|
|
191
|
+
if (!hasLifecyclePairsField(raw) || !Array.isArray(raw.lifecyclePairs)) return DEFAULT_LIFECYCLE_PAIRS;
|
|
192
|
+
const pairs = raw.lifecyclePairs.filter(isLifecyclePair);
|
|
193
|
+
return pairs.length > 0 ? pairs : DEFAULT_LIFECYCLE_PAIRS;
|
|
194
|
+
}
|
|
195
|
+
function hasLifecyclePairsField(value) {
|
|
196
|
+
return typeof value === "object" && value !== null;
|
|
197
|
+
}
|
|
198
|
+
function isLifecyclePair(value) {
|
|
199
|
+
return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "string";
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
export { rule as default };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that flags observer instances (ResizeObserver, MutationObserver,
|
|
3
|
+
* IntersectionObserver, PerformanceObserver) whose reference is discarded,
|
|
4
|
+
* making disposal impossible.
|
|
5
|
+
*/
|
|
6
|
+
import type { Rule } from 'eslint';
|
|
7
|
+
declare const rule: Rule.RuleModule;
|
|
8
|
+
export default rule;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { findEnclosingClass } from "./utils.js";
|
|
2
|
+
//#region src/eslint/rules/require-observer-cleanup.ts
|
|
3
|
+
var OBSERVER_NAMES = /* @__PURE__ */ new Set([
|
|
4
|
+
"ResizeObserver",
|
|
5
|
+
"MutationObserver",
|
|
6
|
+
"IntersectionObserver",
|
|
7
|
+
"PerformanceObserver"
|
|
8
|
+
]);
|
|
9
|
+
var RETAINED_CONTAINER_TYPES = /* @__PURE__ */ new Set([
|
|
10
|
+
"ReturnStatement",
|
|
11
|
+
"ArrowFunctionExpression",
|
|
12
|
+
"ArrayExpression",
|
|
13
|
+
"SpreadElement"
|
|
14
|
+
]);
|
|
15
|
+
var rule = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: {
|
|
19
|
+
description: "Flags observer instances created without storing a reference, which prevents them from being disconnected.",
|
|
20
|
+
recommended: true
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: { "inline-observer-leak": "The `{{kind}}` is created but its reference is discarded, so it can never be `.disconnect()`-ed. Assign it to a class field (e.g., `this.#observer = new {{kind}}(...)`) and call `this.#observer.disconnect()` in the teardown lifecycle method to prevent the callback from firing on a detached instance." }
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
return { NewExpression(node) {
|
|
27
|
+
if (node.callee.type !== "Identifier" || !OBSERVER_NAMES.has(node.callee.name)) return;
|
|
28
|
+
if (!findEnclosingClass(node)) return;
|
|
29
|
+
if (isReferenceRetained(node)) return;
|
|
30
|
+
context.report({
|
|
31
|
+
node,
|
|
32
|
+
messageId: "inline-observer-leak",
|
|
33
|
+
data: { kind: node.callee.name }
|
|
34
|
+
});
|
|
35
|
+
} };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
function isReferenceRetained(node) {
|
|
39
|
+
const parent = node.parent;
|
|
40
|
+
if (isTransparentWrapper(parent, node)) return isReferenceRetained(parent);
|
|
41
|
+
if (RETAINED_CONTAINER_TYPES.has(parent.type)) return true;
|
|
42
|
+
if (isRetainedAssignmentTarget(parent, node)) return true;
|
|
43
|
+
return isRetainedCallArgument(parent, node);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `?:` and `??`/`||` forward one operand through as their own result, so an
|
|
47
|
+
* observer sitting in a forwarded branch is exactly as retained as the
|
|
48
|
+
* wrapper expression - the caller re-runs the same retention check one level
|
|
49
|
+
* up. A `ConditionalExpression` `test` is consumed for its boolean-ness, not
|
|
50
|
+
* forwarded, so an observer there is still a discarded leak.
|
|
51
|
+
*/
|
|
52
|
+
function isTransparentWrapper(parent, node) {
|
|
53
|
+
if (parent.type === "ConditionalExpression") return parent.consequent === node || parent.alternate === node;
|
|
54
|
+
if (parent.type === "LogicalExpression") return parent.left === node || parent.right === node;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function isRetainedAssignmentTarget(parent, node) {
|
|
58
|
+
if (parent.type === "VariableDeclarator") return parent.init === node;
|
|
59
|
+
if (parent.type === "AssignmentExpression") return parent.right === node;
|
|
60
|
+
if (parent.type === "PropertyDefinition") return parent.value === node;
|
|
61
|
+
if (parent.type === "Property") return parent.value === node;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
function isRetainedCallArgument(parent, node) {
|
|
65
|
+
if (parent.type === "CallExpression" || parent.type === "NewExpression") return parent.arguments.includes(node);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { rule as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that flags timer leaks in class bodies: an unstored
|
|
3
|
+
* `setInterval` (which can never be cleared), a `setInterval` handle stored in
|
|
4
|
+
* a local variable that is never cleared or handed off, and stored
|
|
5
|
+
* `setInterval`/`setTimeout` handles that are never passed to a matching
|
|
6
|
+
* `clearInterval`/`clearTimeout` anywhere in the class.
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from 'eslint';
|
|
9
|
+
declare const rule: Rule.RuleModule;
|
|
10
|
+
export default rule;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { findEnclosingClass, normalize, walk } from "./utils.js";
|
|
2
|
+
//#region src/eslint/rules/require-timer-cleanup.ts
|
|
3
|
+
var SET_FNS = /* @__PURE__ */ new Set(["setInterval", "setTimeout"]);
|
|
4
|
+
var CLEAR_FNS = /* @__PURE__ */ new Set(["clearInterval", "clearTimeout"]);
|
|
5
|
+
var rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
type: "problem",
|
|
8
|
+
docs: {
|
|
9
|
+
description: "Flags setInterval without a stored handle, and stored setInterval/setTimeout calls whose handle is never cleared, in class bodies.",
|
|
10
|
+
recommended: true
|
|
11
|
+
},
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
"unstoppable-interval": "`setInterval(...)` result is not stored, so the interval cannot be cleared. Assign it to a class field (e.g., `this.#intervalId = setInterval(...)`) and call `clearInterval(this.#intervalId)` in the teardown lifecycle method to stop the interval when the instance is torn down.",
|
|
15
|
+
"missing-timer-cleanup": "`{{fn}}(...)` result is stored on `{{target}}` but no matching `{{clear}}({{target}})` is called anywhere in the class. Clear the timer in the teardown lifecycle method so it does not fire after the instance is gone."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
create(context) {
|
|
19
|
+
return { CallExpression(node) {
|
|
20
|
+
const fnName = getTimerFnName(node.callee);
|
|
21
|
+
if (!fnName) return;
|
|
22
|
+
const classNode = findEnclosingClass(node);
|
|
23
|
+
if (!classNode) return;
|
|
24
|
+
classifyAndReport(context, node, {
|
|
25
|
+
fnName,
|
|
26
|
+
classNode
|
|
27
|
+
});
|
|
28
|
+
} };
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function getTimerFnName(callee) {
|
|
32
|
+
if (callee.type === "Identifier" && SET_FNS.has(callee.name)) return callee.name;
|
|
33
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && SET_FNS.has(callee.property.name)) return callee.property.name;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function classifyAndReport(context, callNode, info) {
|
|
37
|
+
const parent = callNode.parent;
|
|
38
|
+
const stored = getStoredMemberText(parent, callNode, context);
|
|
39
|
+
if (stored) {
|
|
40
|
+
reportIfNotCleared(context, callNode, {
|
|
41
|
+
...info,
|
|
42
|
+
stored
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (info.fnName !== "setInterval") return;
|
|
47
|
+
if (parent.type === "ExpressionStatement") {
|
|
48
|
+
context.report({
|
|
49
|
+
node: callNode,
|
|
50
|
+
messageId: "unstoppable-interval"
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const localHandle = getLocalIntervalHandleDeclarator(parent, callNode);
|
|
55
|
+
if (localHandle && !localIntervalHandleIsClearedOrEscapes(context, localHandle)) context.report({
|
|
56
|
+
node: callNode,
|
|
57
|
+
messageId: "unstoppable-interval"
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function reportIfNotCleared(context, callNode, info) {
|
|
61
|
+
const clearName = info.fnName === "setInterval" ? "clearInterval" : "clearTimeout";
|
|
62
|
+
if (classHasClearForTarget(info.classNode, {
|
|
63
|
+
clearName,
|
|
64
|
+
targetText: info.stored
|
|
65
|
+
}, context)) return;
|
|
66
|
+
context.report({
|
|
67
|
+
node: callNode,
|
|
68
|
+
messageId: "missing-timer-cleanup",
|
|
69
|
+
data: {
|
|
70
|
+
fn: info.fnName,
|
|
71
|
+
target: info.stored,
|
|
72
|
+
clear: clearName
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function getStoredMemberText(parent, callNode, context) {
|
|
77
|
+
if (parent.type === "AssignmentExpression" && parent.right === callNode) return thisMemberText(parent.left, context);
|
|
78
|
+
if (parent.type === "PropertyDefinition" && parent.value === callNode) return propertyDefinitionAsThisMember(parent, context);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function thisMemberText(node, context) {
|
|
82
|
+
if (node.type !== "MemberExpression") return null;
|
|
83
|
+
if (node.object.type !== "ThisExpression") return null;
|
|
84
|
+
return normalize(context.sourceCode.getText(node));
|
|
85
|
+
}
|
|
86
|
+
function propertyDefinitionAsThisMember(node, context) {
|
|
87
|
+
if (node.static) return null;
|
|
88
|
+
const key = node.key;
|
|
89
|
+
if (key.type === "PrivateIdentifier") return `this.#${key.name}`;
|
|
90
|
+
if (key.type === "Identifier") return `this.${key.name}`;
|
|
91
|
+
return `this[${context.sourceCode.getText(key)}]`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Matches `const id = setInterval(...)` (any of `const`/`let`/`var`), where
|
|
95
|
+
* the handle is bound to a plain identifier rather than destructured. A
|
|
96
|
+
* destructuring pattern can't be resolved back to a single traceable binding
|
|
97
|
+
* here, so it falls outside this rule's scope-analysis path.
|
|
98
|
+
*/
|
|
99
|
+
function getLocalIntervalHandleDeclarator(parent, callNode) {
|
|
100
|
+
if (parent.type !== "VariableDeclarator" || parent.init !== callNode || parent.id.type !== "Identifier") return null;
|
|
101
|
+
return parent;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A local interval handle is safe to leave unstored-on-`this` when every use
|
|
105
|
+
* of the binding shows the handle is either cleared or handed off before the
|
|
106
|
+
* enclosing function returns: passed to any call (`clearInterval(id)`, or an
|
|
107
|
+
* external owner like `registry.track(id)`), returned to the caller, or
|
|
108
|
+
* assigned onward to a longer-lived reference (`this.#timer = id`). Uses scope
|
|
109
|
+
* analysis (rather than walking the enclosing function's AST) so a reference
|
|
110
|
+
* inside the interval's own callback - a closure over the binding - still
|
|
111
|
+
* resolves back to the same variable.
|
|
112
|
+
*/
|
|
113
|
+
function localIntervalHandleIsClearedOrEscapes(context, declarator) {
|
|
114
|
+
const [handleVariable] = context.sourceCode.getDeclaredVariables(declarator);
|
|
115
|
+
if (!handleVariable) return true;
|
|
116
|
+
return handleVariable.references.some((reference) => referenceClearsOrReleasesHandle(reference));
|
|
117
|
+
}
|
|
118
|
+
function referenceClearsOrReleasesHandle(reference) {
|
|
119
|
+
const identifier = reference.identifier;
|
|
120
|
+
const parent = identifier.parent;
|
|
121
|
+
if (parent.type === "CallExpression") return parent.arguments.includes(identifier);
|
|
122
|
+
if (parent.type === "ReturnStatement") return parent.argument === identifier;
|
|
123
|
+
if (parent.type === "AssignmentExpression") return parent.right === identifier;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
function classHasClearForTarget(classNode, search, context) {
|
|
127
|
+
let found = false;
|
|
128
|
+
walk(classNode.body, (node) => {
|
|
129
|
+
if (found || node.type !== "CallExpression") return;
|
|
130
|
+
if (getClearFnName(node.callee) !== search.clearName) return;
|
|
131
|
+
const firstArg = node.arguments[0];
|
|
132
|
+
if (firstArg && normalize(context.sourceCode.getText(firstArg)) === search.targetText) found = true;
|
|
133
|
+
});
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
function getClearFnName(callee) {
|
|
137
|
+
if (callee.type === "Identifier" && CLEAR_FNS.has(callee.name)) return callee.name;
|
|
138
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier" && CLEAR_FNS.has(callee.property.name)) return callee.property.name;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
export { rule as default };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for rules that need to reason about AST shape generically
|
|
3
|
+
* (walking a subtree, matching normalized text), about lexical scope (which
|
|
4
|
+
* class a node lives in), or about the filesystem (listing a package's source
|
|
5
|
+
* files, reading them safely). Extracted so that logic stays consistent
|
|
6
|
+
* across the `tools/*` rules that build on top of them.
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from 'eslint';
|
|
9
|
+
/**
|
|
10
|
+
* Recursively visits every node reachable from `node`, in no particular
|
|
11
|
+
* guaranteed order beyond depth-first. Skips the `parent` back-pointer (every
|
|
12
|
+
* node points back to its parent, so following it would recurse forever) and
|
|
13
|
+
* descends into arrays of children and into single child nodes.
|
|
14
|
+
*/
|
|
15
|
+
export declare function walk(node: unknown, visit: (node: Rule.Node) => void): void;
|
|
16
|
+
/** Collapses internal whitespace so multi-line source text matches regardless of formatting. */
|
|
17
|
+
export declare function normalize(text: string): string;
|
|
18
|
+
type ClassNode = Extract<Rule.Node, {
|
|
19
|
+
type: 'ClassDeclaration' | 'ClassExpression';
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* Walks up `.parent` looking for the nearest enclosing class. Returns `null`
|
|
23
|
+
* when `node` lives at module scope (or otherwise outside any class body).
|
|
24
|
+
*/
|
|
25
|
+
export declare function findEnclosingClass(node: Rule.Node): ClassNode | null;
|
|
26
|
+
/** Directories skipped by every recursive package-file listing, regardless of rule config. */
|
|
27
|
+
export declare const IMPLICIT_EXCLUDED_DIRS: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Milliseconds a package's recursive file listing stays cached before
|
|
30
|
+
* {@link getPackageFiles} recomputes it. Long enough to amortize repeated
|
|
31
|
+
* lookups across every candidate class linted within one file and across one
|
|
32
|
+
* lint pass; short enough that a long-lived ESLint process (IDE language
|
|
33
|
+
* server, `eslint_d`, watch mode) reflects a file added or removed on disk
|
|
34
|
+
* within about one save-and-relint cycle instead of requiring a restart.
|
|
35
|
+
*/
|
|
36
|
+
export declare const FILE_CACHE_TTL_MS = 5000;
|
|
37
|
+
/** Every file under `root`, recursively, excluding {@link IMPLICIT_EXCLUDED_DIRS}. Cached per root. */
|
|
38
|
+
export declare function getPackageFiles(root: string): readonly string[];
|
|
39
|
+
/** Reads `file` as UTF-8, or `null` when it doesn't exist or isn't readable. */
|
|
40
|
+
export declare function readFileSafe(file: string): string | null;
|
|
41
|
+
/** Parses `text` as JSON, or `null` when it isn't valid JSON. */
|
|
42
|
+
export declare function tryParseJson(text: string): unknown;
|
|
43
|
+
/** Converts a `file://` URL or OS-native path to a forward-slash path for cross-platform text matching. */
|
|
44
|
+
export declare function normalizePath(filepath: string): string;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region src/eslint/rules/utils.ts
|
|
5
|
+
/**
|
|
6
|
+
* Shared helpers for rules that need to reason about AST shape generically
|
|
7
|
+
* (walking a subtree, matching normalized text), about lexical scope (which
|
|
8
|
+
* class a node lives in), or about the filesystem (listing a package's source
|
|
9
|
+
* files, reading them safely). Extracted so that logic stays consistent
|
|
10
|
+
* across the `tools/*` rules that build on top of them.
|
|
11
|
+
*/
|
|
12
|
+
/** Narrows an unknown value to `AstNode` without asserting anything about it. */
|
|
13
|
+
function isAstNode(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Recursively visits every node reachable from `node`, in no particular
|
|
18
|
+
* guaranteed order beyond depth-first. Skips the `parent` back-pointer (every
|
|
19
|
+
* node points back to its parent, so following it would recurse forever) and
|
|
20
|
+
* descends into arrays of children and into single child nodes.
|
|
21
|
+
*/
|
|
22
|
+
function walk(node, visit) {
|
|
23
|
+
if (!isAstNode(node)) return;
|
|
24
|
+
visit(node);
|
|
25
|
+
for (const key of Object.keys(node)) {
|
|
26
|
+
if (key === "parent") continue;
|
|
27
|
+
const child = node[key];
|
|
28
|
+
if (Array.isArray(child)) for (const item of child) walk(item, visit);
|
|
29
|
+
else if (isAstNode(child)) walk(child, visit);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Collapses internal whitespace so multi-line source text matches regardless of formatting. */
|
|
33
|
+
function normalize(text) {
|
|
34
|
+
return text.replace(/\s+/g, " ").trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walks up `.parent` looking for the nearest enclosing class. Returns `null`
|
|
38
|
+
* when `node` lives at module scope (or otherwise outside any class body).
|
|
39
|
+
*/
|
|
40
|
+
function findEnclosingClass(node) {
|
|
41
|
+
let current = node.parent;
|
|
42
|
+
while (current) {
|
|
43
|
+
if (current.type === "ClassDeclaration" || current.type === "ClassExpression") return current;
|
|
44
|
+
current = current.parent;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** Directories skipped by every recursive package-file listing, regardless of rule config. */
|
|
49
|
+
var IMPLICIT_EXCLUDED_DIRS = ["node_modules", "dist"];
|
|
50
|
+
/**
|
|
51
|
+
* Milliseconds a package's recursive file listing stays cached before
|
|
52
|
+
* {@link getPackageFiles} recomputes it. Long enough to amortize repeated
|
|
53
|
+
* lookups across every candidate class linted within one file and across one
|
|
54
|
+
* lint pass; short enough that a long-lived ESLint process (IDE language
|
|
55
|
+
* server, `eslint_d`, watch mode) reflects a file added or removed on disk
|
|
56
|
+
* within about one save-and-relint cycle instead of requiring a restart.
|
|
57
|
+
*/
|
|
58
|
+
var FILE_CACHE_TTL_MS = 5e3;
|
|
59
|
+
/** Recursive file listing per package root, cached for {@link FILE_CACHE_TTL_MS}. */
|
|
60
|
+
var FILE_CACHE = /* @__PURE__ */ new Map();
|
|
61
|
+
/** Every file under `root`, recursively, excluding {@link IMPLICIT_EXCLUDED_DIRS}. Cached per root. */
|
|
62
|
+
function getPackageFiles(root) {
|
|
63
|
+
const key = normalizePath(root);
|
|
64
|
+
const cached = FILE_CACHE.get(key);
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
if (cached !== void 0 && now - cached.cachedAtMs < 5e3) return cached.files;
|
|
67
|
+
const files = collectFilesSafe(key).map(normalizePath);
|
|
68
|
+
FILE_CACHE.set(key, {
|
|
69
|
+
files,
|
|
70
|
+
cachedAtMs: now
|
|
71
|
+
});
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
function collectFilesSafe(dir) {
|
|
75
|
+
return readDirSafe(dir).flatMap((entry) => collectEntry(dir, entry));
|
|
76
|
+
}
|
|
77
|
+
function collectEntry(dir, entry) {
|
|
78
|
+
if (entry.isDirectory()) return IMPLICIT_EXCLUDED_DIRS.includes(entry.name) ? [] : collectFilesSafe(join(dir, entry.name));
|
|
79
|
+
return entry.isFile() ? [join(dir, entry.name)] : [];
|
|
80
|
+
}
|
|
81
|
+
function readDirSafe(dir) {
|
|
82
|
+
try {
|
|
83
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Reads `file` as UTF-8, or `null` when it doesn't exist or isn't readable. */
|
|
89
|
+
function readFileSafe(file) {
|
|
90
|
+
try {
|
|
91
|
+
return readFileSync(file, "utf8");
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Parses `text` as JSON, or `null` when it isn't valid JSON. */
|
|
97
|
+
function tryParseJson(text) {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Converts a `file://` URL or OS-native path to a forward-slash path for cross-platform text matching. */
|
|
105
|
+
function normalizePath(filepath) {
|
|
106
|
+
return (filepath.startsWith("file://") ? fileURLToPath(filepath) : filepath).replaceAll(sep, "/");
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { FILE_CACHE_TTL_MS, IMPLICIT_EXCLUDED_DIRS, findEnclosingClass, getPackageFiles, normalize, normalizePath, readFileSafe, tryParseJson, walk };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Config } from 'prettier';
|
|
2
|
+
/**
|
|
3
|
+
* Personal Prettier preferences, shared across projects so formatting stops
|
|
4
|
+
* being a per-repo decision. Point `package.json`'s `"prettier"` field at
|
|
5
|
+
* `"@coryrylan/tools/prettier"` to consume it directly, or import this
|
|
6
|
+
* default export and spread it into a local `prettier.config.js` to layer
|
|
7
|
+
* project-specific overrides on top.
|
|
8
|
+
*
|
|
9
|
+
* `embeddedLanguageFormatting: 'off'` is deliberate: embedded template
|
|
10
|
+
* literals (Lit `html`/`css` tagged templates) are hand-formatted rather
|
|
11
|
+
* than reflowed by Prettier's embedded-language support, which tends to
|
|
12
|
+
* mangle multi-line markup readability.
|
|
13
|
+
*/
|
|
14
|
+
declare const config: Config;
|
|
15
|
+
export default config;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/prettier/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Personal Prettier preferences, shared across projects so formatting stops
|
|
4
|
+
* being a per-repo decision. Point `package.json`'s `"prettier"` field at
|
|
5
|
+
* `"@coryrylan/tools/prettier"` to consume it directly, or import this
|
|
6
|
+
* default export and spread it into a local `prettier.config.js` to layer
|
|
7
|
+
* project-specific overrides on top.
|
|
8
|
+
*
|
|
9
|
+
* `embeddedLanguageFormatting: 'off'` is deliberate: embedded template
|
|
10
|
+
* literals (Lit `html`/`css` tagged templates) are hand-formatted rather
|
|
11
|
+
* than reflowed by Prettier's embedded-language support, which tends to
|
|
12
|
+
* mangle multi-line markup readability.
|
|
13
|
+
*/
|
|
14
|
+
var config = {
|
|
15
|
+
trailingComma: "none",
|
|
16
|
+
tabWidth: 2,
|
|
17
|
+
printWidth: 120,
|
|
18
|
+
semi: true,
|
|
19
|
+
singleQuote: true,
|
|
20
|
+
arrowParens: "avoid",
|
|
21
|
+
bracketSameLine: true,
|
|
22
|
+
embeddedLanguageFormatting: "off",
|
|
23
|
+
singleAttributePerLine: false
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { config as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Config } from 'stylelint';
|
|
2
|
+
/**
|
|
3
|
+
* Personal Stylelint preferences layered on `stylelint-config-standard`.
|
|
4
|
+
* Point a `stylelint.config.js` at it with
|
|
5
|
+
* `export { default } from '@coryrylan/tools/stylelint';`, or import the
|
|
6
|
+
* default export and add `rules`/`overrides` on top for project-specific
|
|
7
|
+
* exceptions.
|
|
8
|
+
*/
|
|
9
|
+
declare const config: Config;
|
|
10
|
+
export default config;
|