@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,364 @@
|
|
|
1
|
+
import { IMPLICIT_EXCLUDED_DIRS, getPackageFiles, normalizePath, readFileSafe, tryParseJson, walk } from "./utils.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
//#region src/eslint/rules/no-single-consumer-abstraction.ts
|
|
5
|
+
/**
|
|
6
|
+
* Flags an exported abstraction (an `abstract` class, or one whose name matches
|
|
7
|
+
* a configured `Base…`/`…Base` pattern) with fewer than `minimumConsumers`
|
|
8
|
+
* implementation consumers. Consumers are found by scanning sibling source
|
|
9
|
+
* files on disk (cached per package root) and text-matching `extends` clauses,
|
|
10
|
+
* resolving named/aliased/namespace/default imports and package barrel re-exports.
|
|
11
|
+
*/
|
|
12
|
+
var DEFAULT_MINIMUM_CONSUMERS = 2;
|
|
13
|
+
var DEFAULT_INCLUDE = ["src/**"];
|
|
14
|
+
var DEFAULT_NAME_PATTERNS = ["^Base", "Base$"];
|
|
15
|
+
var DEFAULT_EXTENSIONS = [
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".js"
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Equivalent specifier suffixes a consumer might use to import a `.ts` source
|
|
22
|
+
* file: extensionless (bundler-resolution projects), `.js` (the NodeNext
|
|
23
|
+
* "import the compiled output extension" convention), or `.ts`
|
|
24
|
+
* (`allowImportingTsExtensions`).
|
|
25
|
+
*/
|
|
26
|
+
var TS_SPECIFIER_SUFFIXES = [
|
|
27
|
+
"",
|
|
28
|
+
".js",
|
|
29
|
+
".ts"
|
|
30
|
+
];
|
|
31
|
+
/** {@link TS_SPECIFIER_SUFFIXES} plus the JSX-bearing equivalents `.tsx` sources may use. */
|
|
32
|
+
var TSX_SPECIFIER_SUFFIXES = [
|
|
33
|
+
"",
|
|
34
|
+
".js",
|
|
35
|
+
".ts",
|
|
36
|
+
".jsx",
|
|
37
|
+
".tsx"
|
|
38
|
+
];
|
|
39
|
+
var rule = {
|
|
40
|
+
meta: {
|
|
41
|
+
type: "problem",
|
|
42
|
+
docs: {
|
|
43
|
+
description: "Disallow abstractions (base classes) that have fewer than two implementation consumers.",
|
|
44
|
+
recommended: true
|
|
45
|
+
},
|
|
46
|
+
schema: [{
|
|
47
|
+
type: "object",
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
properties: {
|
|
50
|
+
minimumConsumers: {
|
|
51
|
+
type: "number",
|
|
52
|
+
minimum: 2
|
|
53
|
+
},
|
|
54
|
+
rootDir: { type: "string" },
|
|
55
|
+
include: {
|
|
56
|
+
type: "array",
|
|
57
|
+
items: { type: "string" }
|
|
58
|
+
},
|
|
59
|
+
exclude: {
|
|
60
|
+
type: "array",
|
|
61
|
+
items: { type: "string" }
|
|
62
|
+
},
|
|
63
|
+
detectAbstract: { type: "boolean" },
|
|
64
|
+
namePatterns: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: { type: "string" }
|
|
67
|
+
},
|
|
68
|
+
extensions: {
|
|
69
|
+
type: "array",
|
|
70
|
+
items: { type: "string" }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}],
|
|
74
|
+
messages: { "single-consumer": "Abstraction \"{{name}}\" has {{count}} implementation consumer(s). Inline it into the consumer until at least {{minimum}} consumers need the abstraction." }
|
|
75
|
+
},
|
|
76
|
+
create(context) {
|
|
77
|
+
const filename = normalizePath(context.physicalFilename);
|
|
78
|
+
const options = normalizeOptions(context.options[0]);
|
|
79
|
+
const root = options.rootDir ?? findPackageRoot(filename);
|
|
80
|
+
if (root === null || !shouldLint(filename, root, options)) return {};
|
|
81
|
+
return { ClassDeclaration(node) {
|
|
82
|
+
const className = getCandidateName(node, options);
|
|
83
|
+
if (className === null) return;
|
|
84
|
+
const defaultExported = isDefaultExported(node);
|
|
85
|
+
const count = countConsumers({
|
|
86
|
+
ast: context.sourceCode.ast,
|
|
87
|
+
className,
|
|
88
|
+
defaultExported,
|
|
89
|
+
filename,
|
|
90
|
+
root,
|
|
91
|
+
options
|
|
92
|
+
});
|
|
93
|
+
if (count >= options.minimumConsumers) return;
|
|
94
|
+
const data = {
|
|
95
|
+
name: className,
|
|
96
|
+
count: String(count),
|
|
97
|
+
minimum: String(options.minimumConsumers)
|
|
98
|
+
};
|
|
99
|
+
context.report({
|
|
100
|
+
node,
|
|
101
|
+
messageId: "single-consumer",
|
|
102
|
+
data
|
|
103
|
+
});
|
|
104
|
+
} };
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
function normalizeOptions(raw) {
|
|
108
|
+
const record = isRecord(raw) ? raw : {};
|
|
109
|
+
return {
|
|
110
|
+
minimumConsumers: readNumber(record["minimumConsumers"], DEFAULT_MINIMUM_CONSUMERS),
|
|
111
|
+
rootDir: readOptionalRoot(record["rootDir"]),
|
|
112
|
+
include: readStringArray(record["include"], DEFAULT_INCLUDE).map(globToRegExp),
|
|
113
|
+
exclude: readStringArray(record["exclude"], []).map(globToRegExp),
|
|
114
|
+
detectAbstract: readBoolean(record["detectAbstract"], true),
|
|
115
|
+
namePatterns: compilePatterns(readStringArray(record["namePatterns"], DEFAULT_NAME_PATTERNS)),
|
|
116
|
+
extensions: readStringArray(record["extensions"], DEFAULT_EXTENSIONS)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function readOptionalRoot(value) {
|
|
120
|
+
return isString(value) ? normalizePath(value) : null;
|
|
121
|
+
}
|
|
122
|
+
function compilePatterns(sources) {
|
|
123
|
+
const patterns = [];
|
|
124
|
+
for (const source of sources) {
|
|
125
|
+
const compiled = tryCompile(source);
|
|
126
|
+
if (compiled !== null) patterns.push(compiled);
|
|
127
|
+
}
|
|
128
|
+
return patterns;
|
|
129
|
+
}
|
|
130
|
+
function tryCompile(source) {
|
|
131
|
+
try {
|
|
132
|
+
return new RegExp(source);
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function getCandidateName(node, options) {
|
|
138
|
+
const name = getClassName(node);
|
|
139
|
+
if (name === null || !isExported(node)) return null;
|
|
140
|
+
if (options.detectAbstract && readProp(node, "abstract") === true) return name;
|
|
141
|
+
return options.namePatterns.some((pattern) => pattern.test(name)) ? name : null;
|
|
142
|
+
}
|
|
143
|
+
function getClassName(node) {
|
|
144
|
+
const name = readProp(readProp(node, "id"), "name");
|
|
145
|
+
return isString(name) ? name : null;
|
|
146
|
+
}
|
|
147
|
+
function isExported(node) {
|
|
148
|
+
const parentType = node.parent.type;
|
|
149
|
+
return parentType === "ExportNamedDeclaration" || parentType === "ExportDefaultDeclaration";
|
|
150
|
+
}
|
|
151
|
+
function isDefaultExported(node) {
|
|
152
|
+
return node.parent.type === "ExportDefaultDeclaration";
|
|
153
|
+
}
|
|
154
|
+
function shouldLint(filename, root, options) {
|
|
155
|
+
return isSourceFile(toRelativePath(root, filename), options);
|
|
156
|
+
}
|
|
157
|
+
function isSourceFile(relPath, options) {
|
|
158
|
+
if (relPath === null || isInExcludedDir(relPath) || relPath.endsWith(".d.ts")) return false;
|
|
159
|
+
if (!hasScannedExtension(relPath, options.extensions)) return false;
|
|
160
|
+
return matchesAny(relPath, options.include) && !matchesAny(relPath, options.exclude);
|
|
161
|
+
}
|
|
162
|
+
function isInExcludedDir(relPath) {
|
|
163
|
+
return relPath.split("/").some((segment) => IMPLICIT_EXCLUDED_DIRS.includes(segment));
|
|
164
|
+
}
|
|
165
|
+
function hasScannedExtension(relPath, extensions) {
|
|
166
|
+
return extensions.some((extension) => relPath.endsWith(extension));
|
|
167
|
+
}
|
|
168
|
+
function matchesAny(relPath, patterns) {
|
|
169
|
+
return patterns.some((pattern) => pattern.test(relPath));
|
|
170
|
+
}
|
|
171
|
+
function toRelativePath(root, file) {
|
|
172
|
+
const rel = normalizePath(relative(root, file));
|
|
173
|
+
if (rel === "" || rel === "." || rel === ".." || rel.startsWith("../")) return null;
|
|
174
|
+
return rel;
|
|
175
|
+
}
|
|
176
|
+
function countConsumers(args) {
|
|
177
|
+
let consumers = countLocalSubclasses(args.ast, args.className);
|
|
178
|
+
const barrels = getBarrelSpecifiers(args);
|
|
179
|
+
for (const file of getSourceFiles(args.root, args.options)) if (file !== args.filename) consumers += countConsumersInFile(file, args, barrels);
|
|
180
|
+
return consumers;
|
|
181
|
+
}
|
|
182
|
+
function countConsumersInFile(file, args, barrels) {
|
|
183
|
+
const text = readFileSafe(file);
|
|
184
|
+
if (text === null) return 0;
|
|
185
|
+
const specifiers = relativeImportSpecifiers(file, args.filename);
|
|
186
|
+
const names = new Set(importedNames(text, specifiers, {
|
|
187
|
+
className: args.className,
|
|
188
|
+
defaultExported: args.defaultExported
|
|
189
|
+
}));
|
|
190
|
+
for (const barrel of barrels) for (const name of importedNames(text, [barrel], {
|
|
191
|
+
className: args.className,
|
|
192
|
+
defaultExported: false
|
|
193
|
+
})) names.add(name);
|
|
194
|
+
return countSubclassMatches(text, [...names]);
|
|
195
|
+
}
|
|
196
|
+
function countLocalSubclasses(ast, className) {
|
|
197
|
+
let count = 0;
|
|
198
|
+
walk(ast, (node) => {
|
|
199
|
+
if (node.type === "ClassDeclaration" && node.superClass && extendsClass(node.superClass, className)) count += 1;
|
|
200
|
+
});
|
|
201
|
+
return count;
|
|
202
|
+
}
|
|
203
|
+
function extendsClass(superClass, className) {
|
|
204
|
+
const type = readProp(superClass, "type");
|
|
205
|
+
if (type === "Identifier") return readProp(superClass, "name") === className;
|
|
206
|
+
if (type === "TSInstantiationExpression") return extendsClass(readProp(superClass, "expression"), className);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
function countSubclassMatches(text, localNames) {
|
|
210
|
+
if (localNames.length === 0) return 0;
|
|
211
|
+
const alternation = localNames.map(escapeRegExp).join("|");
|
|
212
|
+
const pattern = new RegExp(String.raw`\bclass\s+[A-Za-z_$][\w$]*(?:\s*<[^{}]*?>)?\s+extends\s+(?:${alternation})\b`, "g");
|
|
213
|
+
return [...text.matchAll(pattern)].length;
|
|
214
|
+
}
|
|
215
|
+
function importedNames(text, specifiers, query) {
|
|
216
|
+
const importMap = buildImportMap(text, specifiers);
|
|
217
|
+
const names = new Set(importMap.get(query.className) ?? []);
|
|
218
|
+
for (const namespace of importMap.get("*") ?? []) names.add(`${namespace}.${query.className}`);
|
|
219
|
+
if (query.defaultExported) for (const local of importMap.get("default") ?? []) names.add(local);
|
|
220
|
+
return [...names];
|
|
221
|
+
}
|
|
222
|
+
function buildImportMap(text, specifiers) {
|
|
223
|
+
const imports = /* @__PURE__ */ new Map();
|
|
224
|
+
const alternation = specifierAlternation(specifiers);
|
|
225
|
+
const importPattern = new RegExp(String.raw`import\s+(?:type\s+)?([^;]*?)\s+from\s+['"](?:${alternation})['"]`, "g");
|
|
226
|
+
for (const match of text.matchAll(importPattern)) {
|
|
227
|
+
const clause = (match[1] ?? "").trim();
|
|
228
|
+
addDefaultImport(imports, clause);
|
|
229
|
+
addNamespaceImport(imports, clause);
|
|
230
|
+
addNamedImports(imports, clause);
|
|
231
|
+
}
|
|
232
|
+
return imports;
|
|
233
|
+
}
|
|
234
|
+
function addDefaultImport(imports, clause) {
|
|
235
|
+
if (clause === "" || clause.startsWith("{") || clause.startsWith("*")) return;
|
|
236
|
+
const localName = (clause.split(/[,{]/)[0] ?? "").trim();
|
|
237
|
+
if (isIdentifier(localName)) addImport(imports, "default", localName);
|
|
238
|
+
}
|
|
239
|
+
function addNamespaceImport(imports, clause) {
|
|
240
|
+
const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
241
|
+
if (namespace !== void 0) addImport(imports, "*", namespace);
|
|
242
|
+
}
|
|
243
|
+
function addNamedImports(imports, clause) {
|
|
244
|
+
const named = clause.match(/{(?<imports>[\s\S]*?)}/)?.groups?.["imports"];
|
|
245
|
+
if (named === void 0) return;
|
|
246
|
+
for (const entry of named.split(",")) addNamedEntry(imports, entry);
|
|
247
|
+
}
|
|
248
|
+
function addNamedEntry(imports, entry) {
|
|
249
|
+
const match = entry.trim().replace(/^type\s+/, "").match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
250
|
+
const exported = match?.[1];
|
|
251
|
+
if (exported !== void 0) addImport(imports, exported, match?.[2] ?? exported);
|
|
252
|
+
}
|
|
253
|
+
function addImport(imports, exportedName, localName) {
|
|
254
|
+
const set = imports.get(exportedName) ?? /* @__PURE__ */ new Set();
|
|
255
|
+
set.add(localName);
|
|
256
|
+
imports.set(exportedName, set);
|
|
257
|
+
}
|
|
258
|
+
function getBarrelSpecifiers(args) {
|
|
259
|
+
const packageName = readPackageName(args.root);
|
|
260
|
+
if (packageName === null) return [];
|
|
261
|
+
return barrelReExports(args) ? [packageName] : [];
|
|
262
|
+
}
|
|
263
|
+
function barrelReExports(args) {
|
|
264
|
+
for (const barrel of barrelCandidates(args.root, args.options)) {
|
|
265
|
+
const text = readFileSafe(barrel);
|
|
266
|
+
if (text !== null && reExportsClass(text, args.className, relativeImportSpecifiers(barrel, args.filename))) return true;
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
function barrelCandidates(root, options) {
|
|
271
|
+
const candidates = [];
|
|
272
|
+
for (const extension of options.extensions) {
|
|
273
|
+
candidates.push(normalizePath(join(root, "src", `index${extension}`)));
|
|
274
|
+
candidates.push(normalizePath(join(root, `index${extension}`)));
|
|
275
|
+
}
|
|
276
|
+
return candidates;
|
|
277
|
+
}
|
|
278
|
+
function reExportsClass(text, className, specifiers) {
|
|
279
|
+
const alternation = specifierAlternation(specifiers);
|
|
280
|
+
const starPattern = new RegExp(String.raw`export\s+\*\s+from\s+['"](?:${alternation})['"]`, "m");
|
|
281
|
+
const namedPattern = new RegExp(String.raw`export\s+{[^}]*\b${escapeRegExp(className)}\b[^}]*}\s+from\s+['"](?:${alternation})['"]`, "m");
|
|
282
|
+
return starPattern.test(text) || namedPattern.test(text);
|
|
283
|
+
}
|
|
284
|
+
function getSourceFiles(root, options) {
|
|
285
|
+
return getPackageFiles(root).filter((file) => isSourceFile(toRelativePath(root, file), options));
|
|
286
|
+
}
|
|
287
|
+
function readPackageName(root) {
|
|
288
|
+
const text = readFileSafe(join(root, "package.json"));
|
|
289
|
+
if (text === null) return null;
|
|
290
|
+
const name = readProp(tryParseJson(text), "name");
|
|
291
|
+
return isString(name) ? name : null;
|
|
292
|
+
}
|
|
293
|
+
function findPackageRoot(filename) {
|
|
294
|
+
let current = dirname(filename);
|
|
295
|
+
while (current !== dirname(current)) {
|
|
296
|
+
if (existsSync(join(current, "package.json")) && existsSync(join(current, "src"))) return normalizePath(current);
|
|
297
|
+
current = dirname(current);
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Every specifier a consumer could plausibly use to import `toFile` from
|
|
303
|
+
* `fromFile`: extensionless, `.js`, `.ts`, and - when `toFile` is a `.tsx`
|
|
304
|
+
* source - the JSX-bearing equivalents too. Text-matching imports can't know
|
|
305
|
+
* which module resolution convention a given consumer file follows, so every
|
|
306
|
+
* candidate is checked instead of assuming one.
|
|
307
|
+
*/
|
|
308
|
+
function relativeImportSpecifiers(fromFile, toFile) {
|
|
309
|
+
const relativeBase = normalizePath(relative(fromFile.slice(0, fromFile.lastIndexOf("/")), toFile.replace(/\.tsx?$/, "")));
|
|
310
|
+
const specifierBase = relativeBase.startsWith(".") ? relativeBase : `./${relativeBase}`;
|
|
311
|
+
return (toFile.endsWith(".tsx") ? TSX_SPECIFIER_SUFFIXES : TS_SPECIFIER_SUFFIXES).map((suffix) => `${specifierBase}${suffix}`);
|
|
312
|
+
}
|
|
313
|
+
function specifierAlternation(specifiers) {
|
|
314
|
+
return specifiers.map(escapeRegExp).join("|");
|
|
315
|
+
}
|
|
316
|
+
function globToRegExp(glob) {
|
|
317
|
+
const normalized = glob.replaceAll(sep, "/");
|
|
318
|
+
const parts = [];
|
|
319
|
+
let index = 0;
|
|
320
|
+
while (index < normalized.length) index = appendGlobToken(normalized, index, parts);
|
|
321
|
+
return new RegExp(`^${parts.join("")}$`);
|
|
322
|
+
}
|
|
323
|
+
function appendGlobToken(glob, index, parts) {
|
|
324
|
+
const char = glob[index] ?? "";
|
|
325
|
+
if (char === "*" && glob[index + 1] === "*") return appendGlobstar(glob, index, parts);
|
|
326
|
+
if (char === "*") parts.push("[^/]*");
|
|
327
|
+
else if (char === "?") parts.push("[^/]");
|
|
328
|
+
else parts.push(escapeRegExp(char));
|
|
329
|
+
return index + 1;
|
|
330
|
+
}
|
|
331
|
+
function appendGlobstar(glob, index, parts) {
|
|
332
|
+
if (glob[index + 2] === "/") {
|
|
333
|
+
parts.push("(?:[^/]*/)*");
|
|
334
|
+
return index + 3;
|
|
335
|
+
}
|
|
336
|
+
parts.push(".*");
|
|
337
|
+
return index + 2;
|
|
338
|
+
}
|
|
339
|
+
function isRecord(value) {
|
|
340
|
+
return typeof value === "object" && value !== null;
|
|
341
|
+
}
|
|
342
|
+
function isString(value) {
|
|
343
|
+
return typeof value === "string";
|
|
344
|
+
}
|
|
345
|
+
function readProp(value, key) {
|
|
346
|
+
return isRecord(value) ? value[key] : void 0;
|
|
347
|
+
}
|
|
348
|
+
function readNumber(value, fallback) {
|
|
349
|
+
return typeof value === "number" ? value : fallback;
|
|
350
|
+
}
|
|
351
|
+
function readBoolean(value, fallback) {
|
|
352
|
+
return typeof value === "boolean" ? value : fallback;
|
|
353
|
+
}
|
|
354
|
+
function readStringArray(value, fallback) {
|
|
355
|
+
return Array.isArray(value) ? value.filter(isString) : fallback;
|
|
356
|
+
}
|
|
357
|
+
function isIdentifier(value) {
|
|
358
|
+
return /^[A-Za-z_$][\w$]*$/.test(value);
|
|
359
|
+
}
|
|
360
|
+
function escapeRegExp(value) {
|
|
361
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
export { rule as default };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that requires every `eslint-disable*` directive comment to
|
|
3
|
+
* carry a trailing `-- <reason>` justification (and, by default, an explicit
|
|
4
|
+
* rule list rather than a blanket disable). Agents reach for `eslint-disable`
|
|
5
|
+
* to get a green CI run; without a reason attached, a reviewer (human or
|
|
6
|
+
* agent) has no way to tell a legitimate suppression from one hiding a real
|
|
7
|
+
* bug, and a blanket disable silences rules nobody has looked at yet.
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule } from 'eslint';
|
|
10
|
+
declare const rule: Rule.RuleModule;
|
|
11
|
+
export default rule;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/eslint/rules/no-unjustified-disable.ts
|
|
2
|
+
function readOptions(context) {
|
|
3
|
+
return { requireRuleIds: context.options[0]?.requireRuleIds ?? true };
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Matches a comment that opens with one of the three ESLint directive
|
|
7
|
+
* keywords (`eslint-disable`, `eslint-disable-line`, `eslint-disable-next-line`),
|
|
8
|
+
* anchored so the keyword must be the very first thing in the (trimmed)
|
|
9
|
+
* comment text and be followed by whitespace or end-of-text. This
|
|
10
|
+
* deliberately excludes `eslint-enable` (not a suppression, nothing to
|
|
11
|
+
* justify) and prose that merely mentions "eslint-disable" mid-sentence.
|
|
12
|
+
*/
|
|
13
|
+
var DIRECTIVE_KEYWORD_PATTERN = /^(eslint-disable-next-line|eslint-disable-line|eslint-disable)(?=\s|$)/;
|
|
14
|
+
/**
|
|
15
|
+
* The `--` separator ESLint's own directive syntax uses to split the rule
|
|
16
|
+
* list from the human-readable description, e.g.
|
|
17
|
+
* `eslint-disable-next-line no-console -- reason`. Requires at least one
|
|
18
|
+
* whitespace character before the dashes (so it can't misfire mid rule-id)
|
|
19
|
+
* but is otherwise lenient about spacing around it.
|
|
20
|
+
*/
|
|
21
|
+
var JUSTIFICATION_SEPARATOR_PATTERN = /\s+--\s*/;
|
|
22
|
+
/** Parses a comment's raw text as a directive, or returns `null` if it isn't one. */
|
|
23
|
+
function parseDirective(commentText) {
|
|
24
|
+
const trimmedStart = commentText.replace(/^\s+/, "");
|
|
25
|
+
const keywordMatch = DIRECTIVE_KEYWORD_PATTERN.exec(trimmedStart);
|
|
26
|
+
if (!keywordMatch) return null;
|
|
27
|
+
const rest = trimmedStart.slice(keywordMatch[0].length);
|
|
28
|
+
const separatorMatch = JUSTIFICATION_SEPARATOR_PATTERN.exec(rest);
|
|
29
|
+
if (!separatorMatch) return {
|
|
30
|
+
rulePart: rest.trim(),
|
|
31
|
+
justification: ""
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
rulePart: rest.slice(0, separatorMatch.index).trim(),
|
|
35
|
+
justification: rest.slice(separatorMatch.index + separatorMatch[0].length).trim()
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function checkComment(context, comment, options) {
|
|
39
|
+
const parsed = parseDirective(comment.value);
|
|
40
|
+
if (!parsed) return;
|
|
41
|
+
if (parsed.justification === "") context.report({
|
|
42
|
+
node: comment,
|
|
43
|
+
messageId: "missingJustification"
|
|
44
|
+
});
|
|
45
|
+
if (options.requireRuleIds && parsed.rulePart === "") context.report({
|
|
46
|
+
node: comment,
|
|
47
|
+
messageId: "missingRuleIds"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
var rule = {
|
|
51
|
+
meta: {
|
|
52
|
+
type: "suggestion",
|
|
53
|
+
docs: {
|
|
54
|
+
description: "Require eslint-disable directives to explain why, and (by default) which rules they target.",
|
|
55
|
+
recommended: true
|
|
56
|
+
},
|
|
57
|
+
schema: [{
|
|
58
|
+
type: "object",
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
properties: { requireRuleIds: { type: "boolean" } }
|
|
61
|
+
}],
|
|
62
|
+
messages: {
|
|
63
|
+
missingJustification: "eslint-disable directives must explain why: append `-- <reason>`",
|
|
64
|
+
missingRuleIds: "Blanket eslint-disable hides unknown future violations; list the specific rules being disabled"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const options = readOptions(context);
|
|
69
|
+
return { Program() {
|
|
70
|
+
for (const comment of context.sourceCode.getAllComments()) checkComment(context, comment, options);
|
|
71
|
+
} };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
//#endregion
|
|
75
|
+
export { rule as default };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforces version-specifier conventions in `package.json` dependency blocks
|
|
3
|
+
* so that installs stay reproducible without breaking downstream
|
|
4
|
+
* deduplication:
|
|
5
|
+
*
|
|
6
|
+
* - Private packages: every dependency must be pinned to an exact version
|
|
7
|
+
* (or a `workspace:*` link) - there is no consumer to deduplicate for, so
|
|
8
|
+
* a range only adds nondeterminism.
|
|
9
|
+
* - Published packages: runtime dependencies (`dependencies`,
|
|
10
|
+
* `peerDependencies`, `optionalDependencies`) must use a range (`^`/`~`,
|
|
11
|
+
* or a concrete `workspace:` range) so consumers can dedupe; `devDependencies`
|
|
12
|
+
* must stay pinned since they never affect consumers.
|
|
13
|
+
*
|
|
14
|
+
* Agents habitually run `pnpm add` (or edit `package.json` by hand) using
|
|
15
|
+
* whatever specifier the tool defaults to, which is easy to get backwards
|
|
16
|
+
* for a given package's publish status - this rule catches that in the diff
|
|
17
|
+
* instead of at release time.
|
|
18
|
+
*
|
|
19
|
+
* This is a JSON-language rule (built on `@eslint/json`'s momoa-backed AST),
|
|
20
|
+
* not an ESTree rule. `@eslint/json` exports a `JSONRuleDefinition` helper
|
|
21
|
+
* type, which is used below for `meta`/`create` typing; the shapes of the
|
|
22
|
+
* individual momoa nodes touched inside `create` (Document/Object/Member/
|
|
23
|
+
* String/Boolean) are never named explicitly - TypeScript infers them
|
|
24
|
+
* contextually from the `Document(node)` visitor signature and narrows them
|
|
25
|
+
* via the `.type` discriminant checks, so no local structural interfaces
|
|
26
|
+
* (or a direct dependency on `@humanwhocodes/momoa`, which this package
|
|
27
|
+
* does not declare) are needed.
|
|
28
|
+
*/
|
|
29
|
+
import type { JSONRuleDefinition } from '@eslint/json';
|
|
30
|
+
interface NoUnpinnedDependencyRangesOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Whether pnpm's `catalog:` protocol (e.g. `catalog:`, `catalog:publish`)
|
|
33
|
+
* is accepted unconditionally, bypassing the pinned/range checks below.
|
|
34
|
+
* Defaults to `true`. Set to `false` to make `catalog:` specifiers subject
|
|
35
|
+
* to the same rules as any other version string.
|
|
36
|
+
*/
|
|
37
|
+
readonly allowCatalog?: boolean;
|
|
38
|
+
}
|
|
39
|
+
type NoUnpinnedDependencyRangesRuleOptions = [NoUnpinnedDependencyRangesOptions?];
|
|
40
|
+
type NoUnpinnedDependencyRangesMessageIds = 'unpinned-range';
|
|
41
|
+
export type NoUnpinnedDependencyRangesRuleDefinition = JSONRuleDefinition<{
|
|
42
|
+
RuleOptions: NoUnpinnedDependencyRangesRuleOptions;
|
|
43
|
+
MessageIds: NoUnpinnedDependencyRangesMessageIds;
|
|
44
|
+
}>;
|
|
45
|
+
declare const rule: NoUnpinnedDependencyRangesRuleDefinition;
|
|
46
|
+
export default rule;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/eslint/rules/no-unpinned-dependency-ranges.ts
|
|
2
|
+
var DEPENDENCY_GROUPS = /* @__PURE__ */ new Set([
|
|
3
|
+
"dependencies",
|
|
4
|
+
"devDependencies",
|
|
5
|
+
"peerDependencies",
|
|
6
|
+
"optionalDependencies"
|
|
7
|
+
]);
|
|
8
|
+
var RUNTIME_DEPENDENCY_GROUPS = /* @__PURE__ */ new Set([
|
|
9
|
+
"dependencies",
|
|
10
|
+
"peerDependencies",
|
|
11
|
+
"optionalDependencies"
|
|
12
|
+
]);
|
|
13
|
+
/**
|
|
14
|
+
* Any specifier that can resolve to more than one version: caret/tilde,
|
|
15
|
+
* comparators (`>=9`, `<6.1.0`), unions (`^9 || ^10`), hyphen ranges
|
|
16
|
+
* (`1.2 - 2`), and wildcard components (`*`, `1.x`, `1.2.x`). Protocol
|
|
17
|
+
* specifiers such as `workspace:*` and `catalog:` deliberately do not match;
|
|
18
|
+
* they are handled by their own branches.
|
|
19
|
+
*/
|
|
20
|
+
var VERSION_RANGE_PATTERN = /^[\^~]|^[<>]=?\s*\d|\|\||\s-\s|^(?:\d+(?:\.\d+)?\.)?[x*]$/;
|
|
21
|
+
function isVersionRange(version) {
|
|
22
|
+
return VERSION_RANGE_PATTERN.test(version);
|
|
23
|
+
}
|
|
24
|
+
/** Returns a human-readable reason the version specifier is invalid, or `null` if it's fine. */
|
|
25
|
+
function reasonForDependency({ isPrivate, isRuntime, version }) {
|
|
26
|
+
if (isPrivate) return isVersionRange(version) ? "Private packages must use pinned versions to ensure reproducible installs." : null;
|
|
27
|
+
if (isRuntime) return isVersionRange(version) || version.startsWith("workspace:") && version !== "workspace:*" ? null : "Published runtime dependencies must use a range specifier (^, ~, or a comparator such as >=) for downstream deduplication.";
|
|
28
|
+
return isVersionRange(version) ? "Published devDependencies must use pinned versions for reproducible installs." : null;
|
|
29
|
+
}
|
|
30
|
+
var rule = {
|
|
31
|
+
meta: {
|
|
32
|
+
type: "problem",
|
|
33
|
+
docs: { description: "Require package.json dependency version specifiers appropriate to the package's publish status (private vs. published) and dependency kind (runtime vs. dev)." },
|
|
34
|
+
schema: [{
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { allowCatalog: {
|
|
37
|
+
type: "boolean",
|
|
38
|
+
default: true
|
|
39
|
+
} },
|
|
40
|
+
additionalProperties: false
|
|
41
|
+
}],
|
|
42
|
+
messages: { "unpinned-range": "Dependency \"{{name}}\" has an invalid version specifier \"{{version}}\". {{reason}}" }
|
|
43
|
+
},
|
|
44
|
+
create(context) {
|
|
45
|
+
const allowCatalog = context.options[0]?.allowCatalog ?? true;
|
|
46
|
+
return { Document(node) {
|
|
47
|
+
const root = node.body;
|
|
48
|
+
if (root.type !== "Object") return;
|
|
49
|
+
const isPrivate = root.members.some((member) => member.name.type === "String" && member.name.value === "private" && member.value.type === "Boolean" && member.value.value);
|
|
50
|
+
root.members.forEach((member) => {
|
|
51
|
+
if (member.name.type !== "String" || !DEPENDENCY_GROUPS.has(member.name.value) || member.value.type !== "Object") return;
|
|
52
|
+
const isRuntime = RUNTIME_DEPENDENCY_GROUPS.has(member.name.value);
|
|
53
|
+
member.value.members.forEach((dep) => {
|
|
54
|
+
if (dep.name.type !== "String" || dep.value.type !== "String") return;
|
|
55
|
+
const name = dep.name.value;
|
|
56
|
+
const version = dep.value.value;
|
|
57
|
+
if (allowCatalog && version.startsWith("catalog:")) return;
|
|
58
|
+
const reason = reasonForDependency({
|
|
59
|
+
isPrivate,
|
|
60
|
+
isRuntime,
|
|
61
|
+
version
|
|
62
|
+
});
|
|
63
|
+
if (!reason) return;
|
|
64
|
+
context.report({
|
|
65
|
+
loc: dep.value.loc,
|
|
66
|
+
messageId: "unpinned-range",
|
|
67
|
+
data: {
|
|
68
|
+
name,
|
|
69
|
+
version,
|
|
70
|
+
reason
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
export { rule as default };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that catches event listener leaks in class-based lifecycles
|
|
3
|
+
* (custom elements, and anything else that exposes a paired setup/teardown
|
|
4
|
+
* method, e.g. `onMount`/`onDestroy`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Rule } from 'eslint';
|
|
7
|
+
declare const rule: Rule.RuleModule;
|
|
8
|
+
export default rule;
|