@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,36 @@
|
|
|
1
|
+
import rule from "./rules/no-dead-code.js";
|
|
2
|
+
import rule$1 from "./rules/no-deep-class-inheritance.js";
|
|
3
|
+
import rule$2 from "./rules/no-single-consumer-abstraction.js";
|
|
4
|
+
import rule$3 from "./rules/no-unjustified-disable.js";
|
|
5
|
+
import rule$4 from "./rules/no-reexport-barrels.js";
|
|
6
|
+
import rule$5 from "./rules/consistent-error-messages.js";
|
|
7
|
+
import rule$6 from "./rules/require-listener-cleanup.js";
|
|
8
|
+
import rule$7 from "./rules/require-observer-cleanup.js";
|
|
9
|
+
import rule$8 from "./rules/require-timer-cleanup.js";
|
|
10
|
+
import rule$9 from "./rules/no-unpinned-dependency-ranges.js";
|
|
11
|
+
/**
|
|
12
|
+
* The `tools/*` rule registry, shared by every config in `./configs/*.ts`
|
|
13
|
+
* under the `tools` namespace. Lives in its own module (rather than
|
|
14
|
+
* `index.ts`, where it was previously declared empty) so the configs can
|
|
15
|
+
* import it directly without a circular import back through `index.ts`.
|
|
16
|
+
*/
|
|
17
|
+
var plugin = {
|
|
18
|
+
meta: {
|
|
19
|
+
name: "@coryrylan/tools",
|
|
20
|
+
version: "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
rules: {
|
|
23
|
+
"no-dead-code": rule,
|
|
24
|
+
"no-deep-class-inheritance": rule$1,
|
|
25
|
+
"no-single-consumer-abstraction": rule$2,
|
|
26
|
+
"no-unjustified-disable": rule$3,
|
|
27
|
+
"no-reexport-barrels": rule$4,
|
|
28
|
+
"consistent-error-messages": rule$5,
|
|
29
|
+
"require-listener-cleanup": rule$6,
|
|
30
|
+
"require-observer-cleanup": rule$7,
|
|
31
|
+
"require-timer-cleanup": rule$8,
|
|
32
|
+
"no-unpinned-dependency-ranges": rule$9
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { plugin };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that requires thrown/constructed `Error`s (and subclasses) to
|
|
3
|
+
* carry a non-empty, informative message. Agents routinely emit placeholder
|
|
4
|
+
* throw sites (`new Error('error')`, `new Error('')`) that make production
|
|
5
|
+
* debugging impossible; deterministic message hygiene catches this before
|
|
6
|
+
* review has to.
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from 'eslint';
|
|
9
|
+
declare const rule: Rule.RuleModule;
|
|
10
|
+
export default rule;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
//#region src/eslint/rules/consistent-error-messages.ts
|
|
2
|
+
var DEFAULT_DENYLIST = [
|
|
3
|
+
"error",
|
|
4
|
+
"err",
|
|
5
|
+
"failed",
|
|
6
|
+
"failure",
|
|
7
|
+
"oops",
|
|
8
|
+
"something went wrong",
|
|
9
|
+
"unknown error",
|
|
10
|
+
"invalid",
|
|
11
|
+
"bad"
|
|
12
|
+
];
|
|
13
|
+
function readOptions(context) {
|
|
14
|
+
const provided = context.options[0];
|
|
15
|
+
return {
|
|
16
|
+
disallow: provided?.disallow ?? DEFAULT_DENYLIST,
|
|
17
|
+
allowLowercase: provided?.allowLowercase ?? false
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Error-constructor-shaped identifier names this rule cares about (built-ins
|
|
22
|
+
* and custom `*Error` classes), excluding `AggregateError`. Requires a PascalCase
|
|
23
|
+
* (uppercase-first) name so ordinary helpers whose name merely ends in `Error`
|
|
24
|
+
* - `reportError`, `logError`, `handleError`, `onError` - are not mistaken for
|
|
25
|
+
* constructors; every built-in and conventional custom error class is PascalCase.
|
|
26
|
+
*/
|
|
27
|
+
function isErrorConstructorName(name) {
|
|
28
|
+
return name !== "AggregateError" && /^[A-Z]/.test(name) && /Error$/.test(name);
|
|
29
|
+
}
|
|
30
|
+
/** Extracts the callee's identifier name, or `null` if the callee isn't a plain identifier. */
|
|
31
|
+
function calleeName(node) {
|
|
32
|
+
return node.callee.type === "Identifier" ? node.callee.name : null;
|
|
33
|
+
}
|
|
34
|
+
/** Shared trim/noise/lowercase analysis for a message's literal text, used for both string literals and template-literal leading quasis. */
|
|
35
|
+
function analyzeText(text, options) {
|
|
36
|
+
const trimmed = text.trim();
|
|
37
|
+
if (trimmed === "") return { messageId: "emptyMessage" };
|
|
38
|
+
const lower = trimmed.toLowerCase();
|
|
39
|
+
if (options.disallow.some((entry) => entry.toLowerCase() === lower)) return {
|
|
40
|
+
messageId: "noiseMessage",
|
|
41
|
+
text: trimmed
|
|
42
|
+
};
|
|
43
|
+
if (!options.allowLowercase && /^[a-z]/.test(trimmed)) return { messageId: "lowercaseMessage" };
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A template literal starting with `${` (empty leading quasi plus at least
|
|
48
|
+
* one expression) is always treated as informative. Typed against the plain
|
|
49
|
+
* `estree` shape (not `Rule.Node`) because this is a call argument, not a
|
|
50
|
+
* traversed node, so it doesn't carry a `.parent` back-pointer.
|
|
51
|
+
*/
|
|
52
|
+
function analyzeTemplateLiteral(node, options) {
|
|
53
|
+
const cooked = node.quasis[0]?.value.cooked ?? "";
|
|
54
|
+
if (node.expressions.length > 0 && cooked === "") return null;
|
|
55
|
+
return analyzeText(cooked, options);
|
|
56
|
+
}
|
|
57
|
+
/** Analyzes the first argument to a flagged Error constructor/call, or `null` if it's not a message-shaped literal worth checking. */
|
|
58
|
+
function analyzeFirstArgument(firstArgument, options) {
|
|
59
|
+
if (firstArgument.type === "Literal" && typeof firstArgument.value === "string") return analyzeText(firstArgument.value, options);
|
|
60
|
+
if (firstArgument.type === "TemplateLiteral") return analyzeTemplateLiteral(firstArgument, options);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function checkNode(context, node, options) {
|
|
64
|
+
const name = calleeName(node);
|
|
65
|
+
if (name === null || !isErrorConstructorName(name)) return;
|
|
66
|
+
const [firstArgument] = node.arguments;
|
|
67
|
+
if (!firstArgument) {
|
|
68
|
+
context.report({
|
|
69
|
+
node,
|
|
70
|
+
messageId: "missingMessage"
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const verdict = analyzeFirstArgument(firstArgument, options);
|
|
75
|
+
if (!verdict) return;
|
|
76
|
+
if (verdict.messageId === "noiseMessage") {
|
|
77
|
+
context.report({
|
|
78
|
+
node,
|
|
79
|
+
messageId: "noiseMessage",
|
|
80
|
+
data: { text: verdict.text ?? "" }
|
|
81
|
+
});
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
context.report({
|
|
85
|
+
node,
|
|
86
|
+
messageId: verdict.messageId
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
var rule = {
|
|
90
|
+
meta: {
|
|
91
|
+
type: "problem",
|
|
92
|
+
docs: {
|
|
93
|
+
description: "Require Error (and subclass) constructor/call sites to carry a non-empty, informative message.",
|
|
94
|
+
recommended: true
|
|
95
|
+
},
|
|
96
|
+
schema: [{
|
|
97
|
+
type: "object",
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
properties: {
|
|
100
|
+
disallow: {
|
|
101
|
+
type: "array",
|
|
102
|
+
items: { type: "string" }
|
|
103
|
+
},
|
|
104
|
+
allowLowercase: { type: "boolean" }
|
|
105
|
+
}
|
|
106
|
+
}],
|
|
107
|
+
messages: {
|
|
108
|
+
missingMessage: "Construct errors with a message",
|
|
109
|
+
emptyMessage: "Error message must not be empty",
|
|
110
|
+
lowercaseMessage: "Error message should start with a capital letter or interpolated identifier",
|
|
111
|
+
noiseMessage: "Error message '{{text}}' is low-information noise; describe what failed and why"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
create(context) {
|
|
115
|
+
const options = readOptions(context);
|
|
116
|
+
return {
|
|
117
|
+
NewExpression(node) {
|
|
118
|
+
checkNode(context, node, options);
|
|
119
|
+
},
|
|
120
|
+
CallExpression(node) {
|
|
121
|
+
checkNode(context, node, options);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
export { rule as default };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing dead/commented-out code keeps an agent's context free of zombie
|
|
3
|
+
* snippets that look load-bearing but aren't. Version control already
|
|
4
|
+
* remembers deleted code, so a comment that only exists to preserve an old
|
|
5
|
+
* implementation is pure noise for the next agent that reads the file.
|
|
6
|
+
*
|
|
7
|
+
* @see https://kentcdodds.com/blog/please-dont-commit-commented-out-code
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from 'eslint';
|
|
10
|
+
/** Options for the `no-dead-code` rule. */
|
|
11
|
+
export interface NoDeadCodeOptions {
|
|
12
|
+
/** Regex source strings; a comment whose text matches any of these is never flagged. */
|
|
13
|
+
readonly allowPatterns: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
declare const rule: Rule.RuleModule;
|
|
16
|
+
export default rule;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//#region src/eslint/rules/no-dead-code.ts
|
|
2
|
+
var DEFAULT_ALLOW_PATTERNS = [];
|
|
3
|
+
/**
|
|
4
|
+
* Comments that exist to talk to tooling rather than to a reader - ESLint
|
|
5
|
+
* directive comments, TypeScript pragma comments, Prettier's ignore marker,
|
|
6
|
+
* and c8/istanbul coverage markers - are never dead code, no matter what
|
|
7
|
+
* text follows the marker. Checked against the trimmed comment text.
|
|
8
|
+
*/
|
|
9
|
+
var DIRECTIVE_COMMENT_PATTERN = /^(?:eslint-disable|eslint-enable|eslint-env|globals?\b|exported\b|@ts-|prettier-ignore|c8\b|istanbul\b)/;
|
|
10
|
+
/**
|
|
11
|
+
* Heuristics for "this comment contains commented-out source code", ported
|
|
12
|
+
* from the rule this replaces: each pattern targets one JS/TS construct that
|
|
13
|
+
* shows up in real code but essentially never in prose (see inline comments
|
|
14
|
+
* on patterns that need extra care to avoid false positives).
|
|
15
|
+
*/
|
|
16
|
+
var CODE_LIKE_PATTERNS = {
|
|
17
|
+
importStatement: /\s*import\s+.*from\s+['"].*['"];/,
|
|
18
|
+
exportStatement: /\s*export\s+.*from\s+['"].*['"];/,
|
|
19
|
+
functionKeyword: /\bfunction\b\s+(\w+)\s*\(/,
|
|
20
|
+
arrowFunction: /\([^()]*\)\s*=>|[(=]\s*\w+\s*=>|=>\s*[{(]/,
|
|
21
|
+
variableDeclaration: /(?:^|\s)(?:const|let|var)\s+(\w+|\[.*\])\s*(?:[=,;]|$)/,
|
|
22
|
+
controlFlowKeyword: /\b(?:if|else|try|catch|switch|while|for|do)\b\s*\(|^(?:if|else|try|catch|switch|while|for|do)$/,
|
|
23
|
+
returnStatement: /\breturn\b[^;]*;/,
|
|
24
|
+
consoleLog: /\bconsole\s*\.?\s*log\b\s*\(/,
|
|
25
|
+
debuggerStatement: /\bdebugger\b/,
|
|
26
|
+
describeBlock: /\bdescribe\(/,
|
|
27
|
+
itBlock: /\bit\(/
|
|
28
|
+
};
|
|
29
|
+
/** Whether `text` opens with a marker that tooling (not a human) reads. */
|
|
30
|
+
function isDirectiveComment(text) {
|
|
31
|
+
return DIRECTIVE_COMMENT_PATTERN.test(text);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Strips markdown-style inline code spans (`` `...` ``) before running the
|
|
35
|
+
* commented-out-code heuristics. Prose and JSDoc routinely reference a
|
|
36
|
+
* construct by name for illustration (`` `it(...)` ``, `` `export * from
|
|
37
|
+
* './mod.js';` ``) and real dead code is never itself wrapped in backticks,
|
|
38
|
+
* so anything that only "looks like code" inside a backtick span is a doc
|
|
39
|
+
* reference, not a commented-out statement.
|
|
40
|
+
*/
|
|
41
|
+
function stripInlineCodeSpans(text) {
|
|
42
|
+
return text.replace(/`[^`]*`/g, "");
|
|
43
|
+
}
|
|
44
|
+
/** Whether `text` matches any of the commented-out-code heuristics. */
|
|
45
|
+
function looksLikeCode(text) {
|
|
46
|
+
const withoutInlineCode = stripInlineCodeSpans(text);
|
|
47
|
+
return Object.values(CODE_LIKE_PATTERNS).some((pattern) => pattern.test(withoutInlineCode));
|
|
48
|
+
}
|
|
49
|
+
function matchesAnyPattern(text, patterns) {
|
|
50
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
51
|
+
}
|
|
52
|
+
function readOptions(context) {
|
|
53
|
+
return { allowPatterns: context.options[0]?.allowPatterns ?? DEFAULT_ALLOW_PATTERNS };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The JSON schema only validates `allowPatterns` entries as strings, so a
|
|
57
|
+
* malformed regex source (e.g. `'[test'`) reaches here uncompiled. Skipping a
|
|
58
|
+
* pattern that fails to compile - rather than letting `new RegExp` throw -
|
|
59
|
+
* keeps one bad option entry from crashing the entire lint run.
|
|
60
|
+
*/
|
|
61
|
+
function tryCompile(source) {
|
|
62
|
+
try {
|
|
63
|
+
return new RegExp(source);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function compileAllowPatterns(sources) {
|
|
69
|
+
const patterns = [];
|
|
70
|
+
for (const source of sources) {
|
|
71
|
+
const compiled = tryCompile(source);
|
|
72
|
+
if (compiled !== null) patterns.push(compiled);
|
|
73
|
+
}
|
|
74
|
+
return patterns;
|
|
75
|
+
}
|
|
76
|
+
function shouldSkipComment(text, allowPatterns) {
|
|
77
|
+
return text === "" || isDirectiveComment(text) || matchesAnyPattern(text, allowPatterns);
|
|
78
|
+
}
|
|
79
|
+
function checkComment(context, comment, allowPatterns) {
|
|
80
|
+
const text = comment.value.trim();
|
|
81
|
+
if (shouldSkipComment(text, allowPatterns) || !looksLikeCode(text)) return;
|
|
82
|
+
context.report({
|
|
83
|
+
node: comment,
|
|
84
|
+
messageId: "unexpected-dead-code",
|
|
85
|
+
data: { type: comment.type.toLowerCase() }
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
var rule = {
|
|
89
|
+
meta: {
|
|
90
|
+
type: "problem",
|
|
91
|
+
docs: { description: "Disallow dead code paths or dead source-code comment blocks." },
|
|
92
|
+
schema: [{
|
|
93
|
+
type: "object",
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
properties: { allowPatterns: {
|
|
96
|
+
type: "array",
|
|
97
|
+
items: { type: "string" }
|
|
98
|
+
} }
|
|
99
|
+
}],
|
|
100
|
+
messages: { "unexpected-dead-code": "Remove dead/commented {{type}} to avoid incorrect context." }
|
|
101
|
+
},
|
|
102
|
+
create(context) {
|
|
103
|
+
const allowPatterns = compileAllowPatterns(readOptions(context).allowPatterns);
|
|
104
|
+
return { Program() {
|
|
105
|
+
for (const comment of context.sourceCode.getAllComments()) checkComment(context, comment, allowPatterns);
|
|
106
|
+
} };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
//#endregion
|
|
110
|
+
export { rule as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents tend to solve "I need a slightly different X" by subclassing X
|
|
3
|
+
* instead of composing behavior, and the habit compounds: each new layer
|
|
4
|
+
* makes sense in isolation, but understanding any one class now requires
|
|
5
|
+
* reading the whole chain up to its root. This rule counts superclass hops
|
|
6
|
+
* and reports once a class's inheritance chain passes a configured maximum,
|
|
7
|
+
* so local wrappers around an allowed root stay possible without letting the
|
|
8
|
+
* hierarchy grow without bound.
|
|
9
|
+
*/
|
|
10
|
+
import type { Rule } from 'eslint';
|
|
11
|
+
/** Options for the `no-deep-class-inheritance` rule. */
|
|
12
|
+
export interface NoDeepClassInheritanceOptions {
|
|
13
|
+
/** Maximum number of superclass hops allowed before an allowed root is reached. */
|
|
14
|
+
readonly maxDepth: number;
|
|
15
|
+
/** Class names that end the depth count early once reached. */
|
|
16
|
+
readonly allowedRoots: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
declare const rule: Rule.RuleModule;
|
|
19
|
+
export default rule;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//#region src/eslint/rules/no-deep-class-inheritance.ts
|
|
2
|
+
var DEFAULT_MAX_DEPTH = 2;
|
|
3
|
+
var DEFAULT_ALLOWED_ROOTS = [];
|
|
4
|
+
var ANONYMOUS_CLASS_NAME = "<anonymous>";
|
|
5
|
+
function hasTypeAwareParserServices(services) {
|
|
6
|
+
if (typeof services !== "object" || services === null) return false;
|
|
7
|
+
const candidate = services;
|
|
8
|
+
return Boolean(candidate.program) && Boolean(candidate.esTreeNodeToTSNodeMap);
|
|
9
|
+
}
|
|
10
|
+
/** Returns `null` when the active parser doesn't provide type information (e.g. plain espree). */
|
|
11
|
+
function getTypeAwareParserServices(context) {
|
|
12
|
+
const services = context.sourceCode.parserServices;
|
|
13
|
+
return hasTypeAwareParserServices(services) ? services : null;
|
|
14
|
+
}
|
|
15
|
+
function readOptions(context) {
|
|
16
|
+
const provided = context.options[0];
|
|
17
|
+
return {
|
|
18
|
+
maxDepth: provided?.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
19
|
+
allowedRoots: provided?.allowedRoots ?? DEFAULT_ALLOWED_ROOTS
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function getClassName(node) {
|
|
23
|
+
return node.id?.name ?? ANONYMOUS_CLASS_NAME;
|
|
24
|
+
}
|
|
25
|
+
/** The `extends` heritage clause of a class-like declaration, if it has one (as opposed to `implements`). */
|
|
26
|
+
function getExtendsExpression(classDeclaration) {
|
|
27
|
+
return (classDeclaration.heritageClauses?.find((clause) => clause.getText().startsWith("extends")))?.types[0]?.expression ?? null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Structural stand-in for `ts.isClassLike`, again to keep typescript out of the
|
|
31
|
+
* runtime import graph. Declaration merging means this can also match an
|
|
32
|
+
* interface with the same name; interfaces use `extends` heritage clauses too,
|
|
33
|
+
* so chain walking still terminates correctly in that case.
|
|
34
|
+
*/
|
|
35
|
+
function isClassLikeDeclaration(declaration) {
|
|
36
|
+
const candidate = declaration;
|
|
37
|
+
return Array.isArray(candidate.members) && candidate.heritageClauses !== void 0;
|
|
38
|
+
}
|
|
39
|
+
/** The display name for one link in the inheritance chain: the resolved symbol's name, or the raw source text if it has none. */
|
|
40
|
+
function resolveClassName(symbol, expression) {
|
|
41
|
+
return symbol?.getName() ?? expression.getText();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The next superclass expression to follow from `symbol`'s declaration, or
|
|
45
|
+
* `null` once there's no further (or already-visited) class-like declaration
|
|
46
|
+
* to walk into. Marks the declaration visited as a side effect so `visited`
|
|
47
|
+
* stays accurate for the caller's next iteration.
|
|
48
|
+
*/
|
|
49
|
+
function resolveNextExpression(symbol, visited) {
|
|
50
|
+
const declaration = symbol?.declarations?.find(isClassLikeDeclaration);
|
|
51
|
+
if (!declaration || visited.has(declaration)) return null;
|
|
52
|
+
visited.add(declaration);
|
|
53
|
+
return getExtendsExpression(declaration);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Walks superclass hops starting from `superClass`, collecting each link's
|
|
57
|
+
* display name, until it runs out of resolvable declarations or hits a name
|
|
58
|
+
* in `allowedRoots`. `visited` guards against revisiting the same
|
|
59
|
+
* declaration if a project's types ever formed a cycle.
|
|
60
|
+
*/
|
|
61
|
+
function getInheritanceChain(superClass, checker, allowedRoots) {
|
|
62
|
+
const chain = [];
|
|
63
|
+
const visited = /* @__PURE__ */ new Set();
|
|
64
|
+
let expression = superClass;
|
|
65
|
+
while (expression) {
|
|
66
|
+
const symbol = checker.getTypeAtLocation(expression).getSymbol();
|
|
67
|
+
const className = resolveClassName(symbol, expression);
|
|
68
|
+
chain.push(className);
|
|
69
|
+
if (allowedRoots.has(className)) break;
|
|
70
|
+
expression = resolveNextExpression(symbol, visited);
|
|
71
|
+
}
|
|
72
|
+
return chain;
|
|
73
|
+
}
|
|
74
|
+
function reportTooDeep(context, report) {
|
|
75
|
+
const className = getClassName(report.node);
|
|
76
|
+
context.report({
|
|
77
|
+
node: report.node,
|
|
78
|
+
messageId: "too-deep",
|
|
79
|
+
data: {
|
|
80
|
+
className,
|
|
81
|
+
depth: String(report.chain.length),
|
|
82
|
+
maxDepth: String(report.maxDepth),
|
|
83
|
+
chain: `${className} -> ${report.chain.join(" -> ")}`
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function checkClassDeclaration(context, node, config) {
|
|
88
|
+
if (!node.superClass) return;
|
|
89
|
+
const services = getTypeAwareParserServices(context);
|
|
90
|
+
if (!services) return;
|
|
91
|
+
const chain = getInheritanceChain(services.esTreeNodeToTSNodeMap.get(node.superClass), services.program.getTypeChecker(), config.allowedRoots);
|
|
92
|
+
if (chain.length <= config.maxDepth) return;
|
|
93
|
+
reportTooDeep(context, {
|
|
94
|
+
node,
|
|
95
|
+
chain,
|
|
96
|
+
maxDepth: config.maxDepth
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
var rule = {
|
|
100
|
+
meta: {
|
|
101
|
+
type: "problem",
|
|
102
|
+
docs: { description: "Disallow class inheritance chains deeper than the configured maximum." },
|
|
103
|
+
schema: [{
|
|
104
|
+
type: "object",
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
properties: {
|
|
107
|
+
maxDepth: {
|
|
108
|
+
type: "integer",
|
|
109
|
+
minimum: 1
|
|
110
|
+
},
|
|
111
|
+
allowedRoots: {
|
|
112
|
+
type: "array",
|
|
113
|
+
items: { type: "string" },
|
|
114
|
+
uniqueItems: true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}],
|
|
118
|
+
messages: { "too-deep": "`{{className}}` has inheritance depth {{depth}} (`{{chain}}`). Maximum allowed depth is {{maxDepth}}." }
|
|
119
|
+
},
|
|
120
|
+
create(context) {
|
|
121
|
+
const options = readOptions(context);
|
|
122
|
+
const config = {
|
|
123
|
+
maxDepth: options.maxDepth,
|
|
124
|
+
allowedRoots: new Set(options.allowedRoots)
|
|
125
|
+
};
|
|
126
|
+
return { ClassDeclaration(node) {
|
|
127
|
+
checkClassDeclaration(context, node, config);
|
|
128
|
+
} };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
//#endregion
|
|
132
|
+
export { rule as default };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that flags files re-exporting from more modules than
|
|
3
|
+
* `maxReexports` allows. Agents follow barrel files (`export * from './x'`)
|
|
4
|
+
* transitively - reading one barrel to find a symbol drags an entire
|
|
5
|
+
* dependency closure into context. Deep barrels also block tree-shaking and
|
|
6
|
+
* are a common source of import cycles.
|
|
7
|
+
*/
|
|
8
|
+
import type { Rule } from 'eslint';
|
|
9
|
+
declare const rule: Rule.RuleModule;
|
|
10
|
+
export default rule;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/eslint/rules/no-reexport-barrels.ts
|
|
2
|
+
var DEFAULT_MAX_REEXPORTS = 5;
|
|
3
|
+
function readOptions(context) {
|
|
4
|
+
const provided = context.options[0];
|
|
5
|
+
return {
|
|
6
|
+
maxReexports: provided?.maxReexports ?? DEFAULT_MAX_REEXPORTS,
|
|
7
|
+
allowNamed: provided?.allowNamed ?? true
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
var rule = {
|
|
11
|
+
meta: {
|
|
12
|
+
type: "suggestion",
|
|
13
|
+
docs: {
|
|
14
|
+
description: "Disallow re-exporting from more than a configured number of modules (barrel files).",
|
|
15
|
+
recommended: true
|
|
16
|
+
},
|
|
17
|
+
schema: [{
|
|
18
|
+
type: "object",
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
properties: {
|
|
21
|
+
maxReexports: {
|
|
22
|
+
type: "integer",
|
|
23
|
+
minimum: 0
|
|
24
|
+
},
|
|
25
|
+
allowNamed: { type: "boolean" }
|
|
26
|
+
}
|
|
27
|
+
}],
|
|
28
|
+
messages: { tooManyReexports: "This file re-exports from {{count}} modules (max {{max}}). Barrel files bloat agent context and break tree-shaking; import from concrete modules instead." }
|
|
29
|
+
},
|
|
30
|
+
create(context) {
|
|
31
|
+
const options = readOptions(context);
|
|
32
|
+
const reexports = [];
|
|
33
|
+
return {
|
|
34
|
+
ExportAllDeclaration(node) {
|
|
35
|
+
reexports.push(node);
|
|
36
|
+
},
|
|
37
|
+
ExportNamedDeclaration(node) {
|
|
38
|
+
if (!options.allowNamed && node.source) reexports.push(node);
|
|
39
|
+
},
|
|
40
|
+
"Program:exit"() {
|
|
41
|
+
if (reexports.length <= options.maxReexports) return;
|
|
42
|
+
for (const node of reexports) context.report({
|
|
43
|
+
node,
|
|
44
|
+
messageId: "tooManyReexports",
|
|
45
|
+
data: {
|
|
46
|
+
count: reexports.length,
|
|
47
|
+
max: options.maxReexports
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { rule as default };
|