eslint-try-rules 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025- Dieter Oberkofler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # eslint-try-rules
2
+
3
+ `eslint-try-rules` is a CLI tool designed to help developers incrementally adopt stricter ESLint rules. It allows you to test a set of rules against your codebase and generates a report showing which files would fail, without actually modifying your existing ESLint configuration.
4
+
5
+ ## Features
6
+
7
+ - **Test Rules in Isolation:** Run specific rules against your project to see their impact.
8
+ - **Support for JSON and JSONC:** Load rules from standard JSON or JSON files with comments.
9
+ - **Progress Tracking:** Real-time progress bar with ETA during linting.
10
+ - **Detailed Reports:** Generates a colored CLI summary and a comprehensive HTML report.
11
+ - **Flexible Sorting:** Sort results by rule ID or by severity (total errors + warnings).
12
+ - **ESLint 9+ Support:** Built for the new ESLint Flat Config system.
13
+ - **Safe Adoption:** No changes are made to your existing `eslint.config.js`.
14
+
15
+ ## Installation
16
+
17
+ You can run it directly using `npx`:
18
+
19
+ ```bash
20
+ npx eslint-try-rules --rules try-rules.json
21
+ ```
22
+
23
+ Or install it globally:
24
+
25
+ ```bash
26
+ npm install -g eslint-try-rules
27
+ ```
28
+
29
+ Or as a development dependency in your project:
30
+
31
+ ```bash
32
+ npm install -D eslint-try-rules
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Create a `try-rules.json` (or `.jsonc`) file with the rules you want to test:
38
+
39
+ ```json
40
+ {
41
+ "@typescript-eslint/no-explicit-any": "error",
42
+ "curly": "error"
43
+ }
44
+ ```
45
+
46
+ Run the tool:
47
+
48
+ ```bash
49
+ eslint-try-rules --rules try-rules.json
50
+ ```
51
+
52
+ ### Try the Example
53
+
54
+ You can run an example against this project's own source code:
55
+
56
+ ```bash
57
+ npm run example
58
+ ```
59
+
60
+ This uses the rules defined in `example/try-rules.jsonc`.
61
+
62
+ ### Options
63
+
64
+ - `--rules <path>`: (Required) Path to the JSON/JSONC file containing the rules to test.
65
+ - `--config <path>`: (Optional) Path to your project's ESLint configuration file (e.g., `eslint.config.js`).
66
+ - `--sort <rule|severity>`: (Optional) Sort results by rule ID (default) or by severity (total errors + warnings).
67
+
68
+ ## Output
69
+
70
+ The tool provides:
71
+ 1. **CLI Report:** A summary of errors and warnings per rule, including file locations.
72
+ 2. **HTML Report:** A detailed, interactive HTML report saved as `eslint-incremental-report.html`.
73
+
74
+ ## Development
75
+
76
+ ### Build
77
+ ```bash
78
+ npm run build
79
+ ```
80
+
81
+ ### Lint
82
+ ```bash
83
+ npm run lint
84
+ ```
85
+
86
+ ### Test
87
+ ```bash
88
+ npm run test
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,8 @@
1
+ //#region src/index.d.ts
2
+ /**
3
+ * Main execution.
4
+ * @returns {Promise<void>}
5
+ */
6
+ declare const main: () => Promise<void>;
7
+ //#endregion
8
+ export { main };
package/dist/index.mjs ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ import { runLint } from "./lint.mjs";
3
+ import { generateHtml } from "./report-html.mjs";
4
+ import { printConsoleReport } from "./report-console.mjs";
5
+ import { parseRulesFile } from "./rules.mjs";
6
+ import { writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { performance } from "node:perf_hooks";
9
+ import { Command } from "commander";
10
+ import ansis from "ansis";
11
+
12
+ //#region src/index.ts
13
+ /**
14
+ * Main execution.
15
+ * @returns {Promise<void>}
16
+ */
17
+ const main = async () => {
18
+ const start = performance.now();
19
+ const program = new Command();
20
+ program.name("eslint-try-rules").description("Try stricter ESLint rules on your codebase.").argument("[patterns...]", "Files/directories/globs to lint.", ["."]).requiredOption("--rules <path>", "Path to a JSON/JSONC file containing the ESLint rules to test.").option("--config <path>", "Path to your project's ESLint configuration file.").option("--sort <type>", "Sort results by \"rule\" (default) or \"severity\" (errors + warnings).", "rule");
21
+ program.parse();
22
+ const options = program.opts();
23
+ const patterns = program.args;
24
+ const cwd = process.cwd();
25
+ if (options.sort !== "rule" && options.sort !== "severity") throw new Error("Invalid sort option. Use \"rule\" or \"severity\".");
26
+ const rules = parseRulesFile(path.resolve(cwd, options.rules));
27
+ console.log(`${ansis.bold.blue("ℹ")} Starting eslint-try-rules for ${ansis.bold(String(Object.keys(rules).length))} rules...\n`);
28
+ const finalResults = await runLint(cwd, rules, patterns, options.config ? path.resolve(cwd, options.config) : void 0);
29
+ printConsoleReport(finalResults, options.sort);
30
+ const html = generateHtml(finalResults);
31
+ const outPath = path.resolve(cwd, "eslint-incremental-report.html");
32
+ writeFileSync(outPath, html, "utf8");
33
+ const durationMs = (performance.now() - start) / 1e3;
34
+ console.log(`${ansis.bold.green("✔")} Report generated in ${ansis.bold(durationMs.toFixed(2))}s: ${ansis.underline(`file://${outPath}`)}`);
35
+ };
36
+ /* v8 ignore start */
37
+ if (process.argv[1] === import.meta.filename || process.argv[1]?.endsWith("index.mjs")) try {
38
+ await main();
39
+ } catch (error) {
40
+ if (error instanceof Error) console.error(`\n${ansis.red.bold("✖")} ${error.message}`);
41
+ else console.error(`\n${ansis.red.bold("✖")} Unknown error`, error);
42
+ process.exit(1);
43
+ }
44
+ /* v8 ignore stop */
45
+
46
+ //#endregion
47
+ export { main };
package/dist/lint.mjs ADDED
@@ -0,0 +1,112 @@
1
+ import path from "node:path";
2
+ import { ESLint } from "eslint";
3
+ import cliProgress from "cli-progress";
4
+ import { glob } from "tinyglobby";
5
+
6
+ //#region src/lint.ts
7
+ /**
8
+ * Creates a rule filter for ESLint.
9
+ * @param {Record<string, unknown>} rules - The rules to filter.
10
+ * @returns {(rule: {ruleId: string}) => boolean} The filter function.
11
+ */
12
+ const createRuleFilter = (rules) => ({ ruleId }) => Object.prototype.hasOwnProperty.call(rules, ruleId);
13
+ /**
14
+ * Runs ESLint on the codebase with the provided rules.
15
+ * @param {string} cwd - The current working directory.
16
+ * @param {Record<string, unknown>} rules - The rules to test.
17
+ * @param {string[]} patterns - The file patterns to lint.
18
+ * @param {string} [configFile] - Optional path to the ESLint config file.
19
+ * @returns {Promise<RuleResult[]>} The linting results.
20
+ */
21
+ const runLint = async (cwd, rules, patterns, configFile) => {
22
+ const eslint = new ESLint({
23
+ cwd,
24
+ cache: false,
25
+ overrideConfigFile: configFile,
26
+ overrideConfig: [{ rules }],
27
+ ruleFilter: createRuleFilter(rules)
28
+ });
29
+ console.log("Searching for files...");
30
+ const allFiles = await glob(patterns.map((p) => {
31
+ if (p === ".") return "**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}";
32
+ if (!p.includes("*") && !p.includes("?") && !p.includes("[") && !p.includes("{")) return path.join(p, "**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}");
33
+ return p;
34
+ }), {
35
+ cwd,
36
+ ignore: [
37
+ "**/node_modules/**",
38
+ "**/dist/**",
39
+ "**/coverage/**"
40
+ ],
41
+ absolute: true
42
+ });
43
+ const filesToLint = [];
44
+ for (const file of allFiles) if (!await eslint.isPathIgnored(file)) filesToLint.push(file);
45
+ if (filesToLint.length === 0) {
46
+ console.log("No files found to lint.");
47
+ return [];
48
+ }
49
+ const progressBar = new cliProgress.SingleBar({
50
+ format: "Linting | {bar} | {percentage}% | {value}/{total} Files | ETA: {eta}s",
51
+ barCompleteChar: "█",
52
+ barIncompleteChar: "░",
53
+ hideCursor: true
54
+ });
55
+ progressBar.start(filesToLint.length, 0);
56
+ const allResults = [];
57
+ const chunkSize = 10;
58
+ for (let i = 0; i < filesToLint.length; i += chunkSize) {
59
+ const chunk = filesToLint.slice(i, i + chunkSize);
60
+ const chunkResults = await eslint.lintFiles(chunk);
61
+ allResults.push(...chunkResults);
62
+ progressBar.update(Math.min(i + chunkSize, filesToLint.length));
63
+ }
64
+ progressBar.stop();
65
+ return processResults(cwd, rules, allResults);
66
+ };
67
+ /**
68
+ * Processes ESLint results into RuleResult format.
69
+ * @param {string} cwd - Current working directory.
70
+ * @param {Record<string, unknown>} rules - Rules tested.
71
+ * @param {ESLint.LintResult[]} results - Results from ESLint.
72
+ * @returns {RuleResult[]} Processed results.
73
+ */
74
+ const processResults = (cwd, rules, results) => {
75
+ const ruleMap = /* @__PURE__ */ new Map();
76
+ for (const ruleId of Object.keys(rules)) ruleMap.set(ruleId, {
77
+ ruleId,
78
+ config: rules[ruleId],
79
+ errors: 0,
80
+ warnings: 0,
81
+ fixable: 0,
82
+ details: []
83
+ });
84
+ for (const res of results) for (const msg of res.messages) {
85
+ const rId = msg.ruleId ?? "unknown";
86
+ let rr = ruleMap.get(rId);
87
+ if (!rr) {
88
+ rr = {
89
+ ruleId: rId,
90
+ config: "unknown",
91
+ errors: 0,
92
+ warnings: 0,
93
+ fixable: 0,
94
+ details: []
95
+ };
96
+ ruleMap.set(rId, rr);
97
+ }
98
+ if (msg.severity === 2) rr.errors++;
99
+ else rr.warnings++;
100
+ if (msg.fix !== void 0) rr.fixable++;
101
+ rr.details.push({
102
+ filePath: path.relative(cwd, res.filePath),
103
+ line: msg.line,
104
+ column: msg.column,
105
+ message: msg.message
106
+ });
107
+ }
108
+ return [...ruleMap.values()].filter((r) => r.errors > 0 || r.warnings > 0);
109
+ };
110
+
111
+ //#endregion
112
+ export { runLint };
@@ -0,0 +1,66 @@
1
+ import ansis from "ansis";
2
+
3
+ //#region src/report-console.ts
4
+ /**
5
+ * Sorts rule results by ruleId.
6
+ * @param {RuleResult} a - First result.
7
+ * @param {RuleResult} b - Second result.
8
+ * @returns {number} Comparison result.
9
+ */
10
+ const sortByRuleId = (a, b) => a.ruleId.localeCompare(b.ruleId);
11
+ /**
12
+ * Sorts rule results by severity (errors + warnings).
13
+ * @param {RuleResult} a - First result.
14
+ * @param {RuleResult} b - Second result.
15
+ * @returns {number} Comparison result.
16
+ */
17
+ const sortBySeverity = (a, b) => {
18
+ const totalA = a.errors + a.warnings;
19
+ const totalB = b.errors + b.warnings;
20
+ if (totalA !== totalB) return totalB - totalA;
21
+ return sortByRuleId(a, b);
22
+ };
23
+ /**
24
+ * Sorts message details by file path.
25
+ * @param {MessageDetail} a - First detail.
26
+ * @param {MessageDetail} b - Second detail.
27
+ * @returns {number} Comparison result.
28
+ */
29
+ const sortByFilePath = (a, b) => a.filePath.localeCompare(b.filePath);
30
+ /**
31
+ * Outputs the results to the console.
32
+ * @param {RuleResult[]} results - The results to output.
33
+ * @param {SortOption} sortOption - The sorting criteria.
34
+ */
35
+ const printConsoleReport = (results, sortOption) => {
36
+ const finalResults = [...results].toSorted((a, b) => {
37
+ if (sortOption === "severity") return sortBySeverity(a, b);
38
+ return sortByRuleId(a, b);
39
+ });
40
+ let totalErrors = 0;
41
+ let totalWarnings = 0;
42
+ let totalFixable = 0;
43
+ console.log("\n" + ansis.bold.cyan("--- CLI Report ---"));
44
+ for (const r of finalResults) {
45
+ totalErrors += r.errors;
46
+ totalWarnings += r.warnings;
47
+ totalFixable += r.fixable;
48
+ const sortedDetails = r.details.toSorted(sortByFilePath);
49
+ const stats = [];
50
+ if (r.errors > 0) stats.push(ansis.red(`${r.errors} errors`));
51
+ if (r.warnings > 0) stats.push(ansis.yellow(`${r.warnings} warnings`));
52
+ if (r.fixable > 0) stats.push(ansis.green(`${r.fixable} fixable`));
53
+ console.log(`\n${ansis.bold(r.ruleId)} (${stats.join(" | ")})`);
54
+ for (const d of sortedDetails) console.log(` ${ansis.dim("->")} ${ansis.blue(d.filePath)}:${ansis.magenta(String(d.line))}:${ansis.magenta(String(d.column))} ${ansis.dim("-")} ${d.message}`);
55
+ }
56
+ console.log("\n" + ansis.cyan("------------------"));
57
+ const totals = [
58
+ ansis.red.bold(`${totalErrors} errors`),
59
+ ansis.yellow.bold(`${totalWarnings} warnings`),
60
+ ansis.green.bold(`${totalFixable} fixable`)
61
+ ].join(" | ");
62
+ console.log(`${ansis.bold("Totals")} | ${totals}\n`);
63
+ };
64
+
65
+ //#endregion
66
+ export { printConsoleReport };
@@ -0,0 +1,73 @@
1
+ //#region src/report-html.ts
2
+ const TITLE = "ESLint try rules";
3
+ /**
4
+ * Escapes HTML characters.
5
+ * @param {string} str - The string to escape.
6
+ * @returns {string} The escaped string.
7
+ */
8
+ const escapeHtml = (str) => str.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9
+ /**
10
+ * Generates HTML report.
11
+ * @param {RuleResult[]} results - The results to include in the report.
12
+ * @returns {string} The generated HTML.
13
+ */
14
+ const generateHtml = (results) => {
15
+ let totalErr = 0;
16
+ let totalWarn = 0;
17
+ let totalFix = 0;
18
+ return `<!DOCTYPE html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <title>${TITLE}</title>
23
+ <style>
24
+ body { font-family: sans-serif; margin: 2rem; }
25
+ table { border-collapse: collapse; width: 100%; }
26
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: right; vertical-align: top; }
27
+ th:first-child, td:first-child, th:nth-child(2), td:nth-child(2) { text-align: left; }
28
+ ul { margin: 0; padding-left: 20px; font-size: 0.9em; }
29
+ tfoot { font-weight: bold; background: #eee; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <h1>${TITLE}</h1>
34
+ <table>
35
+ <thead>
36
+ <tr><th>Rule</th><th>Config</th><th>Errors</th><th>Warnings</th><th>Fixable</th></tr>
37
+ </thead>
38
+ <tbody>
39
+ ${results.map((r) => {
40
+ totalErr += r.errors;
41
+ totalWarn += r.warnings;
42
+ totalFix += r.fixable;
43
+ const confStr = escapeHtml(JSON.stringify(r.config ?? "N/A"));
44
+ let detailsRow = "";
45
+ let toggleIcon = "<span style=\"color:#ccc;\">&#x25B6;</span>";
46
+ if (r.details.length > 0) {
47
+ detailsRow = `<tr style="display:none;background:#f9f9f9;"><td colspan="5"><ul>${r.details.map((d) => `<li><code>${escapeHtml(d.filePath)}:${d.line}:${d.column}</code> - ${escapeHtml(d.message)}</li>`).join("")}</ul></td></tr>`;
48
+ toggleIcon = `<span style="cursor:pointer;" onclick="const r=this.closest('tr').nextElementSibling;r.style.display=r.style.display==='none'?'table-row':'none';this.innerHTML=r.style.display==='none'?'&#x25B6;':'&#x25BC;'">&#x25B6;</span>`;
49
+ }
50
+ return `<tr>
51
+ <td>${toggleIcon} ${escapeHtml(r.ruleId)}</td>
52
+ <td><code>${confStr}</code></td>
53
+ <td>${r.errors}</td>
54
+ <td>${r.warnings}</td>
55
+ <td>${r.fixable}</td>
56
+ </tr>\n\t\t\t\t${detailsRow}`;
57
+ }).join("\n ")}
58
+ </tbody>
59
+ <tfoot>
60
+ <tr>
61
+ <td colspan="2">Totals</td>
62
+ <td>${totalErr}</td>
63
+ <td>${totalWarn}</td>
64
+ <td>${totalFix}</td>
65
+ </tr>
66
+ </tfoot>
67
+ </table>
68
+ </body>
69
+ </html>`;
70
+ };
71
+
72
+ //#endregion
73
+ export { generateHtml };
package/dist/rules.mjs ADDED
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import stripJsonComments from "strip-json-comments";
4
+ import { z } from "zod";
5
+
6
+ //#region src/rules.ts
7
+ /**
8
+ * The Zod schema for the rules file.
9
+ */
10
+ const RulesSchema = z.record(z.string(), z.unknown());
11
+ /**
12
+ * Parses the rules file.
13
+ * @param {string} filePath - Path to the rules file (JSON or JSONC).
14
+ * @returns {Record<string, unknown>} The parsed rules.
15
+ * @throws {Error} If the file cannot be read or parsed.
16
+ */
17
+ const parseRulesFile = (filePath) => {
18
+ try {
19
+ const content = readFileSync(path.resolve(filePath), "utf8");
20
+ const json = JSON.parse(stripJsonComments(content));
21
+ return RulesSchema.parse(json);
22
+ } catch (error) {
23
+ if (error instanceof z.ZodError) throw new Error(`Invalid rules format: ${error.message}`);
24
+ if (error instanceof SyntaxError) throw new Error(`Failed to parse rules JSON: ${error.message}`);
25
+ throw error;
26
+ }
27
+ };
28
+
29
+ //#endregion
30
+ export { parseRulesFile };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "eslint-try-rules",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "author": "Dieter Oberkofler",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "keywords": [
11
+ "eslint",
12
+ "rules"
13
+ ],
14
+ "bin": {
15
+ "eslint-try-rules": "./dist/index.mjs"
16
+ },
17
+ "scripts": {
18
+ "dev": "tsdown --watch",
19
+ "build": "tsdown",
20
+ "prepublishOnly": "npm run build",
21
+ "lint": "eslint --no-cache . && tsc && prettier --check .",
22
+ "test": "vitest run --coverage",
23
+ "example": "tsx src/index.ts src --rules example/try-rules.jsonc",
24
+ "ci": "npm run lint && npm run build && npm run test",
25
+ "create-changelog": "conventional-changelog --release-count=0",
26
+ "pack-local": "rm -f *.tgz && npm run build && npm pack"
27
+ },
28
+ "main": "./dist/index.mjs",
29
+ "module": "./dist/index.mjs",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": "./dist/index.mjs",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "engines": {
36
+ "node": ">=22.12.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git://github.com/doberkofler/eslint-try-rules.git"
41
+ },
42
+ "homepage": "https://github.com/doberkofler/eslint-try-rules#readme",
43
+ "devDependencies": {
44
+ "@eslint/js": "^9.31.0",
45
+ "@types/node": "^25.3.0",
46
+ "@vitest/coverage-v8": "^4.0.18",
47
+ "@vitest/eslint-plugin": "^1.6.9",
48
+ "conventional-changelog": "^7.1.1",
49
+ "eslint": "^9.31.0",
50
+ "eslint-plugin-jsdoc": "^62.6.1",
51
+ "eslint-plugin-regexp": "^3.0.0",
52
+ "eslint-plugin-unicorn": "^63.0.0",
53
+ "globals": "^17.3.0",
54
+ "prettier": "^3.8.1",
55
+ "tsdown": "^0.20.3",
56
+ "tsx": "^4.21.0",
57
+ "typescript": "^5.9.3",
58
+ "typescript-eslint": "^8.56.0",
59
+ "vitest": "^4.0.18"
60
+ },
61
+ "dependencies": {
62
+ "@types/cli-progress": "^3.11.6",
63
+ "ansis": "^4.2.0",
64
+ "cli-progress": "^3.12.0",
65
+ "commander": "^14.0.3",
66
+ "strip-json-comments": "^5.0.3",
67
+ "tinyglobby": "^0.2.15",
68
+ "zod": "^4.3.6"
69
+ }
70
+ }