oxlint-harness 1.0.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/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # oxlint-harness
2
+
3
+ A harness for [oxlint](https://oxc.rs) that provides ESLint-style bulk suppressions support. This tool allows you to gradually adopt stricter linting rules by suppressing existing violations while preventing new ones.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Bulk Suppressions**: Suppress specific counts of lint violations per file per rule
8
+ - ✅ **Incremental Adoption**: Only fail on new violations beyond suppressed counts
9
+ - ✅ **Rich Colored Output**: Beautiful terminal colors with syntax highlighting
10
+ - ✅ **Code Snippets**: Show actual code context for files with few errors (configurable)
11
+ - ✅ **Smart Package Detection**: Auto-detects pnpm/yarn/npm with monorepo support
12
+ - ✅ **Update Mode**: Generate/update suppression files automatically
13
+ - ✅ **Pass-through**: Forward all arguments to oxlint seamlessly
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install oxlint-harness
19
+ # or
20
+ pnpm add oxlint-harness
21
+ # or
22
+ yarn add oxlint-harness
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # Run oxlint with suppressions (fails on excess errors)
29
+ npx oxlint-harness src/
30
+
31
+ # Generate initial suppression file
32
+ npx oxlint-harness --update src/
33
+
34
+ # Use custom suppression file
35
+ npx oxlint-harness --suppressions .my-suppressions.json src/
36
+ ```
37
+
38
+ ## Suppression File Format
39
+
40
+ The suppression file uses a count-based format:
41
+
42
+ ```json
43
+ {
44
+ "src/App.tsx": {
45
+ "@typescript-eslint/no-unused-vars": { "count": 1 },
46
+ "eqeqeq": { "count": 1 },
47
+ "no-var": { "count": 1 },
48
+ "prefer-const": { "count": 1 }
49
+ },
50
+ "src/utils.ts": {
51
+ "no-console": { "count": 3 }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## CLI Options
57
+
58
+ | Option | Short | Description | Default |
59
+ |--------|--------|-------------|---------|
60
+ | `--suppressions` | `-s` | Path to suppression file | `.oxlint-suppressions.json` |
61
+ | `--update` | `-u` | Update/create suppression file | `false` |
62
+ | `--show-code` | | Show code snippets for files with N or fewer errors (0 to disable) | `3` |
63
+ | `--fail-on-excess` | | Exit 1 if unsuppressed errors exist | `true` |
64
+ | `--no-fail-on-excess` | | Don't exit 1 on unsuppressed errors | - |
65
+ | `--help` | `-h` | Show help | - |
66
+
67
+ All additional arguments are passed directly to oxlint.
68
+
69
+ ## Usage Examples
70
+
71
+ ### Basic Usage
72
+
73
+ ```bash
74
+ # Check files with suppressions
75
+ npx oxlint-harness src/ lib/
76
+
77
+ # Pass oxlint options
78
+ npx oxlint-harness --type-aware src/
79
+
80
+ # Use different suppression file
81
+ npx oxlint-harness -s .eslint-suppressions.json src/
82
+
83
+ # Show code snippets for files with ≤1 error
84
+ npx oxlint-harness --show-code 1 src/
85
+
86
+ # Disable code snippets (simple list only)
87
+ npx oxlint-harness --show-code 0 src/
88
+ ```
89
+
90
+ ### Generating Suppressions
91
+
92
+ ```bash
93
+ # Create initial suppression file (records current error counts)
94
+ npx oxlint-harness --update src/
95
+
96
+ # Update existing suppressions with new counts
97
+ npx oxlint-harness --update --suppressions custom.json src/
98
+ ```
99
+
100
+ ### CI Integration
101
+
102
+ ```bash
103
+ # In CI: fail build if new errors are introduced
104
+ npx oxlint-harness src/
105
+
106
+ # For reporting mode (don't fail build)
107
+ npx oxlint-harness --no-fail-on-excess src/
108
+ ```
109
+
110
+ ## Workflow
111
+
112
+ ### Initial Setup
113
+
114
+ 1. Run with `--update` to create initial suppression file:
115
+ ```bash
116
+ npx oxlint-harness --update src/
117
+ ```
118
+
119
+ 2. Commit the `.oxlint-suppressions.json` file
120
+
121
+ 3. Configure CI to run `npx oxlint-harness src/`
122
+
123
+ ### Daily Development
124
+
125
+ - **Normal runs**: Only new violations (beyond suppressed counts) will cause failures
126
+ - **Fixing violations**: Counts automatically decrease as issues are resolved
127
+ - **Adding suppressions**: Update counts manually or re-run with `--update`
128
+
129
+ ### Example Output
130
+
131
+ #### With Code Snippets (default for files with ≤3 errors)
132
+ ```
133
+ ❌ Found unsuppressed errors:
134
+
135
+ 📄 src/App.tsx:
136
+ ⚠️ eslint(no-unused-vars): 1 excess error (expected: 0, actual: 1)
137
+
138
+ × eslint(no-unused-vars): Variable 'thing' is declared but never used. Unused variables should start with a '_'.
139
+ ╭─[24:7]
140
+ 24 │ }));
141
+ 25 │
142
+ 26 │ const thing = 3;
143
+ ─────────────
144
+ 27 │
145
+ 28 │ const PartnerAppRootBase: React.VFC = () => {
146
+ ╰────
147
+ help: Consider removing this declaration.
148
+
149
+ 📝 To suppress, add to suppression file:
150
+ "src/App.tsx": { "eslint(no-unused-vars)": { "count": 1 } }
151
+
152
+ 📊 Summary:
153
+ • Files with issues: 1
154
+ • Rules with excess errors: 1
155
+ • Total excess errors: 1
156
+
157
+ 💡 To suppress all current errors, run:
158
+ oxlint-harness --update src/
159
+ ```
160
+
161
+ #### Simple List (for files with many errors)
162
+ ```
163
+ 📄 src/Components.tsx:
164
+ ⚠️ prefer-const: 15 excess errors (expected: 10, actual: 25)
165
+ • src/Components.tsx:42:12: 'data' is never reassigned
166
+ • src/Components.tsx:58:8: 'config' is never reassigned
167
+ • src/Components.tsx:74:15: 'result' is never reassigned
168
+ ... and 12 more
169
+
170
+ 📝 To suppress, add to suppression file:
171
+ "src/Components.tsx": { "prefer-const": { "count": 25 } }
172
+ ```
173
+
174
+ ## Requirements
175
+
176
+ - Node.js 22+ (for native TypeScript support)
177
+ - `oxlint` installed and available (via pnpm/yarn/npm)
178
+
179
+ ## How It Works
180
+
181
+ 1. **Package Manager Detection**: Automatically detects if you're using pnpm, yarn, or npm by looking for lock files up the directory tree (supports monorepos)
182
+ 2. **Oxlint Execution**: Runs `pnpm oxlint`, `yarn oxlint`, or `npx oxlint` with JSON output
183
+ 3. **Suppression Matching**: Compares actual error counts against your suppression file
184
+ 4. **Smart Reporting**: Shows code snippets for files with few errors, simple lists for files with many errors
185
+ 5. **Colored Output**: Beautiful terminal colors that automatically disable in non-TTY environments
186
+
187
+ ## Contributing
188
+
189
+ Issues and pull requests are welcome! Please see the [GitHub repository](https://github.com/MIreland/oxlint-harness) for more information.
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ # Install dependencies
195
+ pnpm install
196
+
197
+ # Run tests
198
+ pnpm test
199
+
200
+ # Type checking
201
+ pnpm typecheck
202
+
203
+ # Lint
204
+ pnpm lint
205
+
206
+ # Development mode
207
+ pnpm dev --help
208
+ ```
209
+
210
+ ## License
211
+
212
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import OxlintHarness from './command.js';
3
+ OxlintHarness.run().catch((error) => {
4
+ console.error(error);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,17 @@
1
+ import { ProcessedDiagnostic } from './types.js';
2
+ export interface CodeSnippet {
3
+ beforeLines: string[];
4
+ targetLine: string;
5
+ afterLines: string[];
6
+ lineNumber: number;
7
+ columnStart: number;
8
+ columnEnd: number;
9
+ }
10
+ export declare class CodeSnippetExtractor {
11
+ private fileCache;
12
+ private colors;
13
+ getCodeSnippet(diagnostic: ProcessedDiagnostic): CodeSnippet | null;
14
+ private getFileLines;
15
+ formatCodeSnippet(snippet: CodeSnippet, rule: string, message: string, help?: string): string;
16
+ private highlightSyntax;
17
+ }
@@ -0,0 +1,94 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { ColorFormatter } from './colors.js';
3
+ export class CodeSnippetExtractor {
4
+ fileCache = new Map();
5
+ colors = new ColorFormatter();
6
+ getCodeSnippet(diagnostic) {
7
+ if (!diagnostic.line || !diagnostic.filename) {
8
+ return null;
9
+ }
10
+ const lines = this.getFileLines(diagnostic.filename);
11
+ if (!lines) {
12
+ return null;
13
+ }
14
+ const lineIndex = diagnostic.line - 1; // Convert to 0-based index
15
+ const contextLines = 2; // Show 2 lines before/after
16
+ const beforeLines = lines.slice(Math.max(0, lineIndex - contextLines), lineIndex);
17
+ const targetLine = lines[lineIndex] || '';
18
+ const afterLines = lines.slice(lineIndex + 1, Math.min(lines.length, lineIndex + 1 + contextLines));
19
+ return {
20
+ beforeLines,
21
+ targetLine,
22
+ afterLines,
23
+ lineNumber: diagnostic.line,
24
+ columnStart: diagnostic.column || 0,
25
+ columnEnd: (diagnostic.column || 0) + (diagnostic.message.length || 10), // Rough estimate
26
+ };
27
+ }
28
+ getFileLines(filename) {
29
+ if (this.fileCache.has(filename)) {
30
+ return this.fileCache.get(filename);
31
+ }
32
+ if (!existsSync(filename)) {
33
+ return null;
34
+ }
35
+ try {
36
+ const content = readFileSync(filename, 'utf8');
37
+ const lines = content.split('\n');
38
+ this.fileCache.set(filename, lines);
39
+ return lines;
40
+ }
41
+ catch (error) {
42
+ return null;
43
+ }
44
+ }
45
+ formatCodeSnippet(snippet, rule, message, help) {
46
+ let output = '';
47
+ // Error header with colors
48
+ output += ` ${this.colors.errorIcon()} ${this.colors.rule(rule)}: ${message}\n`;
49
+ const startLineNum = snippet.lineNumber - snippet.beforeLines.length;
50
+ const endLineNum = snippet.lineNumber + snippet.afterLines.length;
51
+ // File path and line range with colors
52
+ output += ` ${this.colors.border('╭─')}${this.colors.filename(`[${startLineNum}:${snippet.columnStart}]`)}\n`;
53
+ // Before lines
54
+ snippet.beforeLines.forEach((line, index) => {
55
+ const lineNum = startLineNum + index;
56
+ const lineNumStr = this.colors.lineNumber(lineNum.toString().padStart(4));
57
+ const border = this.colors.border(' │ ');
58
+ output += `${lineNumStr}${border}${this.highlightSyntax(line)}\n`;
59
+ });
60
+ // Target line with error highlight
61
+ const targetLineNumStr = this.colors.lineNumber(snippet.lineNumber.toString().padStart(4));
62
+ const targetBorder = this.colors.border(' │ ');
63
+ output += `${targetLineNumStr}${targetBorder}${this.highlightSyntax(snippet.targetLine)}\n`;
64
+ // Error pointer with color
65
+ const indent = ' '.repeat(4 + 3 + snippet.columnStart); // Line number + "│ " + column offset
66
+ const pointer = this.colors.errorPointer('─'.repeat(Math.max(1, Math.min(snippet.columnEnd - snippet.columnStart, 20))));
67
+ output += `${indent}${pointer}\n`;
68
+ // After lines
69
+ snippet.afterLines.forEach((line, index) => {
70
+ const lineNum = snippet.lineNumber + 1 + index;
71
+ const lineNumStr = this.colors.lineNumber(lineNum.toString().padStart(4));
72
+ const border = this.colors.border(' │ ');
73
+ output += `${lineNumStr}${border}${this.highlightSyntax(line)}\n`;
74
+ });
75
+ output += ` ${this.colors.border('╰────')}\n`;
76
+ // Help text with color
77
+ if (help) {
78
+ output += ` ${this.colors.help('help:')} ${help}\n`;
79
+ }
80
+ return output;
81
+ }
82
+ highlightSyntax(line) {
83
+ // Simple syntax highlighting for common patterns
84
+ return line
85
+ // Keywords
86
+ .replace(/\b(const|let|var|function|class|if|else|for|while|return|import|export|from|await|async)\b/g, match => this.colors.keyword(match))
87
+ // Strings
88
+ .replace(/(['"`])((?:\\.|(?!\1)[^\\])*)\1/g, match => this.colors.string(match))
89
+ // Comments
90
+ .replace(/(\/\/.*$|\/\*.*?\*\/)/g, match => this.colors.comment(match))
91
+ // Numbers
92
+ .replace(/\b\d+\b/g, match => this.colors.info(match));
93
+ }
94
+ }
@@ -0,0 +1,51 @@
1
+ export declare const colors: {
2
+ reset: string;
3
+ bright: string;
4
+ dim: string;
5
+ red: string;
6
+ green: string;
7
+ yellow: string;
8
+ blue: string;
9
+ magenta: string;
10
+ cyan: string;
11
+ white: string;
12
+ gray: string;
13
+ bgRed: string;
14
+ bgGreen: string;
15
+ bgYellow: string;
16
+ bgBlue: string;
17
+ bgMagenta: string;
18
+ bgCyan: string;
19
+ brightRed: string;
20
+ brightGreen: string;
21
+ brightYellow: string;
22
+ brightBlue: string;
23
+ brightMagenta: string;
24
+ brightCyan: string;
25
+ brightWhite: string;
26
+ };
27
+ export declare class ColorFormatter {
28
+ private useColors;
29
+ constructor(useColors?: boolean);
30
+ private colorize;
31
+ error(text: string): string;
32
+ warning(text: string): string;
33
+ success(text: string): string;
34
+ info(text: string): string;
35
+ rule(text: string): string;
36
+ filename(text: string): string;
37
+ lineNumber(text: string): string;
38
+ highlight(text: string): string;
39
+ help(text: string): string;
40
+ keyword(text: string): string;
41
+ string(text: string): string;
42
+ comment(text: string): string;
43
+ border(text: string): string;
44
+ emphasis(text: string): string;
45
+ muted(text: string): string;
46
+ errorPointer(text: string): string;
47
+ errorIcon(): string;
48
+ warningIcon(): string;
49
+ successIcon(): string;
50
+ infoIcon(): string;
51
+ }
package/dist/colors.js ADDED
@@ -0,0 +1,107 @@
1
+ // ANSI color codes for terminal output
2
+ export const colors = {
3
+ // Basic colors
4
+ reset: '\x1b[0m',
5
+ bright: '\x1b[1m',
6
+ dim: '\x1b[2m',
7
+ // Foreground colors
8
+ red: '\x1b[31m',
9
+ green: '\x1b[32m',
10
+ yellow: '\x1b[33m',
11
+ blue: '\x1b[34m',
12
+ magenta: '\x1b[35m',
13
+ cyan: '\x1b[36m',
14
+ white: '\x1b[37m',
15
+ gray: '\x1b[90m',
16
+ // Background colors
17
+ bgRed: '\x1b[41m',
18
+ bgGreen: '\x1b[42m',
19
+ bgYellow: '\x1b[43m',
20
+ bgBlue: '\x1b[44m',
21
+ bgMagenta: '\x1b[45m',
22
+ bgCyan: '\x1b[46m',
23
+ // Bright foreground colors
24
+ brightRed: '\x1b[91m',
25
+ brightGreen: '\x1b[92m',
26
+ brightYellow: '\x1b[93m',
27
+ brightBlue: '\x1b[94m',
28
+ brightMagenta: '\x1b[95m',
29
+ brightCyan: '\x1b[96m',
30
+ brightWhite: '\x1b[97m',
31
+ };
32
+ export class ColorFormatter {
33
+ useColors;
34
+ constructor(useColors = true) {
35
+ this.useColors = useColors && process.stdout.isTTY;
36
+ }
37
+ colorize(text, color) {
38
+ if (!this.useColors)
39
+ return text;
40
+ return `${color}${text}${colors.reset}`;
41
+ }
42
+ // Error levels
43
+ error(text) {
44
+ return this.colorize(text, colors.brightRed);
45
+ }
46
+ warning(text) {
47
+ return this.colorize(text, colors.brightYellow);
48
+ }
49
+ success(text) {
50
+ return this.colorize(text, colors.brightGreen);
51
+ }
52
+ info(text) {
53
+ return this.colorize(text, colors.brightBlue);
54
+ }
55
+ // Syntax highlighting
56
+ rule(text) {
57
+ return this.colorize(text, colors.brightRed);
58
+ }
59
+ filename(text) {
60
+ return this.colorize(text, colors.brightBlue);
61
+ }
62
+ lineNumber(text) {
63
+ return this.colorize(text, colors.gray);
64
+ }
65
+ highlight(text) {
66
+ return this.colorize(text, colors.brightMagenta);
67
+ }
68
+ help(text) {
69
+ return this.colorize(text, colors.cyan);
70
+ }
71
+ // Code elements
72
+ keyword(text) {
73
+ return this.colorize(text, colors.magenta);
74
+ }
75
+ string(text) {
76
+ return this.colorize(text, colors.green);
77
+ }
78
+ comment(text) {
79
+ return this.colorize(text, colors.gray);
80
+ }
81
+ // UI elements
82
+ border(text) {
83
+ return this.colorize(text, colors.gray);
84
+ }
85
+ emphasis(text) {
86
+ return this.colorize(text, colors.bright);
87
+ }
88
+ muted(text) {
89
+ return this.colorize(text, colors.dim);
90
+ }
91
+ // Special formatting
92
+ errorPointer(text) {
93
+ return this.colorize(text, colors.brightMagenta);
94
+ }
95
+ errorIcon() {
96
+ return this.error('×');
97
+ }
98
+ warningIcon() {
99
+ return this.warning('⚠️');
100
+ }
101
+ successIcon() {
102
+ return this.success('✅');
103
+ }
104
+ infoIcon() {
105
+ return this.info('💡');
106
+ }
107
+ }
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class OxlintHarness extends Command {
3
+ static summary: string;
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: {
7
+ suppressions: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ update: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ 'fail-on-excess': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ 'show-code': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
11
+ help: import("@oclif/core/interfaces").BooleanFlag<void>;
12
+ };
13
+ static args: {
14
+ paths: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
15
+ };
16
+ static strict: boolean;
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,89 @@
1
+ import { Command, Flags, Args } from '@oclif/core';
2
+ import { OxlintRunner } from './oxlint-runner.js';
3
+ import { SuppressionManager } from './suppression-manager.js';
4
+ import { ErrorReporter } from './error-reporter.js';
5
+ import { ColorFormatter } from './colors.js';
6
+ export default class OxlintHarness extends Command {
7
+ static summary = 'Run oxlint with bulk suppressions support';
8
+ static description = `
9
+ Runs oxlint with support for bulk suppressions similar to ESLint.
10
+
11
+ The suppression file format uses counts per rule per file:
12
+ {
13
+ "src/App.tsx": {
14
+ "no-unused-vars": { "count": 1 },
15
+ "prefer-const": { "count": 2 }
16
+ }
17
+ }
18
+ `;
19
+ static examples = [
20
+ '<%= config.bin %> <%= command.id %> src/',
21
+ '<%= config.bin %> <%= command.id %> --update src/',
22
+ '<%= config.bin %> <%= command.id %> --suppressions .my-suppressions.json src/',
23
+ ];
24
+ static flags = {
25
+ suppressions: Flags.string({
26
+ char: 's',
27
+ description: 'Path to suppression file',
28
+ default: '.oxlint-suppressions.json',
29
+ }),
30
+ update: Flags.boolean({
31
+ char: 'u',
32
+ description: 'Update/create suppression file with current error counts',
33
+ default: false,
34
+ }),
35
+ 'fail-on-excess': Flags.boolean({
36
+ description: 'Exit with non-zero code if there are unsuppressed errors',
37
+ default: true,
38
+ allowNo: true,
39
+ }),
40
+ 'show-code': Flags.integer({
41
+ description: 'Show code snippets for files with N or fewer errors (0 to disable)',
42
+ default: 3,
43
+ }),
44
+ help: Flags.help({ char: 'h' }),
45
+ };
46
+ static args = {
47
+ paths: Args.string({
48
+ description: 'Files or directories to lint (passed to oxlint)',
49
+ required: false,
50
+ }),
51
+ };
52
+ static strict = false; // Allow additional args to be passed to oxlint
53
+ async run() {
54
+ const { flags, argv } = await this.parse(OxlintHarness);
55
+ const colors = new ColorFormatter();
56
+ try {
57
+ // Run oxlint with remaining args
58
+ const runner = new OxlintRunner();
59
+ const diagnostics = await runner.run(argv);
60
+ // Handle suppression logic
61
+ const suppressionManager = new SuppressionManager(flags.suppressions);
62
+ if (flags.update) {
63
+ // Update mode: generate/update suppression file
64
+ const currentSuppressions = suppressionManager.loadSuppressions();
65
+ const updatedSuppressions = suppressionManager.updateSuppressions(currentSuppressions, diagnostics);
66
+ suppressionManager.saveSuppressions(updatedSuppressions);
67
+ this.log(`${colors.success('Updated suppression file:')} ${colors.filename(flags.suppressions)}`);
68
+ this.log(`${colors.info('Total diagnostics:')} ${colors.emphasis(diagnostics.length.toString())}`);
69
+ return;
70
+ }
71
+ // Normal mode: check suppressions
72
+ const suppressions = suppressionManager.loadSuppressions();
73
+ const excessErrors = suppressionManager.findExcessErrors(diagnostics, suppressions);
74
+ if (excessErrors.length === 0) {
75
+ this.log(`${colors.successIcon()} ${colors.success('All errors are suppressed')}`);
76
+ return;
77
+ }
78
+ // Report excess errors
79
+ const reporter = new ErrorReporter();
80
+ reporter.reportExcessErrors(excessErrors, flags['show-code']);
81
+ if (flags['fail-on-excess']) {
82
+ this.exit(1);
83
+ }
84
+ }
85
+ catch (error) {
86
+ this.error(error instanceof Error ? error.message : String(error));
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,6 @@
1
+ import { ExcessError } from './types.js';
2
+ export declare class ErrorReporter {
3
+ private snippetExtractor;
4
+ private colors;
5
+ reportExcessErrors(excessErrors: ExcessError[], showCodeThreshold?: number): void;
6
+ }
@@ -0,0 +1,70 @@
1
+ import { CodeSnippetExtractor } from './code-snippet.js';
2
+ import { ColorFormatter } from './colors.js';
3
+ export class ErrorReporter {
4
+ snippetExtractor = new CodeSnippetExtractor();
5
+ colors = new ColorFormatter();
6
+ reportExcessErrors(excessErrors, showCodeThreshold = 3) {
7
+ console.error(`${this.colors.error('❌')} Found unsuppressed errors:\n`);
8
+ let totalExcess = 0;
9
+ // Group errors by file for better readability
10
+ const errorsByFile = new Map();
11
+ for (const error of excessErrors) {
12
+ if (!errorsByFile.has(error.filename)) {
13
+ errorsByFile.set(error.filename, []);
14
+ }
15
+ errorsByFile.get(error.filename).push(error);
16
+ }
17
+ for (const [filename, fileErrors] of errorsByFile.entries()) {
18
+ console.error(`📄 ${this.colors.filename(filename)}:`);
19
+ for (const error of fileErrors) {
20
+ const excess = error.actual - error.expected;
21
+ totalExcess += excess;
22
+ const excessText = excess === 1 ? 'error' : 'errors';
23
+ console.error(` ${this.colors.warningIcon()} ${this.colors.rule(error.rule)}: ${this.colors.emphasis(excess.toString())} excess ${excessText} (expected: ${this.colors.muted(error.expected.toString())}, actual: ${this.colors.emphasis(error.actual.toString())})`);
24
+ // Show detailed code snippets for files with few errors
25
+ if (showCodeThreshold > 0 && error.diagnostics.length <= showCodeThreshold) {
26
+ console.error(''); // Add spacing before code snippets
27
+ error.diagnostics.forEach((diagnostic, index) => {
28
+ const snippet = this.snippetExtractor.getCodeSnippet(diagnostic);
29
+ if (snippet) {
30
+ const formattedSnippet = this.snippetExtractor.formatCodeSnippet(snippet, diagnostic.rule, diagnostic.message, diagnostic.help);
31
+ console.error(formattedSnippet);
32
+ }
33
+ else {
34
+ // Fallback to simple format if can't read file
35
+ const location = diagnostic.line ? `:${this.colors.lineNumber(diagnostic.line.toString())}:${this.colors.lineNumber((diagnostic.column || 0).toString())}` : '';
36
+ console.error(` • ${this.colors.filename(diagnostic.filename)}${location}: ${diagnostic.message}`);
37
+ if (diagnostic.help) {
38
+ console.error(` ${this.colors.infoIcon()} ${this.colors.help(diagnostic.help)}`);
39
+ }
40
+ }
41
+ });
42
+ }
43
+ else {
44
+ // Show up to 3 example diagnostics for files with many errors
45
+ const exampleCount = Math.min(3, error.diagnostics.length);
46
+ for (let i = 0; i < exampleCount; i++) {
47
+ const diagnostic = error.diagnostics[i];
48
+ const location = diagnostic.line ? `:${this.colors.lineNumber(diagnostic.line.toString())}:${this.colors.lineNumber((diagnostic.column || 0).toString())}` : '';
49
+ console.error(` • ${this.colors.filename(diagnostic.filename)}${location}: ${diagnostic.message}`);
50
+ if (diagnostic.help) {
51
+ console.error(` ${this.colors.infoIcon()} ${this.colors.help(diagnostic.help)}`);
52
+ }
53
+ }
54
+ if (error.diagnostics.length > exampleCount) {
55
+ console.error(` ${this.colors.muted(`... and ${error.diagnostics.length - exampleCount} more`)}`);
56
+ }
57
+ }
58
+ // Suggestion to suppress
59
+ console.error(` 📝 ${this.colors.info('To suppress, add to suppression file:')}`);
60
+ console.error(` ${this.colors.muted('"')}${this.colors.filename(error.filename)}${this.colors.muted('"')}: { ${this.colors.muted('"')}${this.colors.rule(error.rule)}${this.colors.muted('"')}: { ${this.colors.muted('"count"')}: ${this.colors.emphasis(error.actual.toString())} } }\n`);
61
+ }
62
+ }
63
+ console.error(`\n📊 ${this.colors.info('Summary:')}`);
64
+ console.error(` • Files with issues: ${this.colors.emphasis(errorsByFile.size.toString())}`);
65
+ console.error(` • Rules with excess errors: ${this.colors.emphasis(excessErrors.length.toString())}`);
66
+ console.error(` • Total excess errors: ${this.colors.emphasis(totalExcess.toString())}`);
67
+ console.error(`\n${this.colors.infoIcon()} ${this.colors.info('To suppress all current errors, run:')}`);
68
+ console.error(` ${this.colors.emphasis('oxlint-harness --update [your-args]')}`);
69
+ }
70
+ }
@@ -0,0 +1,6 @@
1
+ export { OxlintRunner } from './oxlint-runner.js';
2
+ export { SuppressionManager } from './suppression-manager.js';
3
+ export { ErrorReporter } from './error-reporter.js';
4
+ export { CodeSnippetExtractor } from './code-snippet.js';
5
+ export { ColorFormatter } from './colors.js';
6
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { OxlintRunner } from './oxlint-runner.js';
2
+ export { SuppressionManager } from './suppression-manager.js';
3
+ export { ErrorReporter } from './error-reporter.js';
4
+ export { CodeSnippetExtractor } from './code-snippet.js';
5
+ export { ColorFormatter } from './colors.js';
6
+ export * from './types.js';
@@ -0,0 +1,7 @@
1
+ import { ProcessedDiagnostic } from './types.js';
2
+ export declare class OxlintRunner {
3
+ private findLockFile;
4
+ private detectPackageManager;
5
+ run(args?: string[]): Promise<ProcessedDiagnostic[]>;
6
+ private parseOxlintOutput;
7
+ }
@@ -0,0 +1,133 @@
1
+ import { spawn } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { resolve, dirname, parse } from 'path';
4
+ export class OxlintRunner {
5
+ findLockFile(startDir) {
6
+ let currentDir = startDir;
7
+ const root = parse(currentDir).root;
8
+ while (currentDir !== root) {
9
+ // Check for lock files in order of preference
10
+ const lockFiles = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json'];
11
+ for (const lockFile of lockFiles) {
12
+ if (existsSync(resolve(currentDir, lockFile))) {
13
+ return resolve(currentDir, lockFile);
14
+ }
15
+ }
16
+ // Move up one directory
17
+ const parent = dirname(currentDir);
18
+ if (parent === currentDir)
19
+ break; // Reached filesystem root
20
+ currentDir = parent;
21
+ }
22
+ return null;
23
+ }
24
+ detectPackageManager() {
25
+ const cwd = process.cwd();
26
+ const lockFile = this.findLockFile(cwd);
27
+ if (lockFile) {
28
+ const lockFileName = parse(lockFile).base;
29
+ if (lockFileName === 'pnpm-lock.yaml') {
30
+ return { command: 'pnpm', args: ['exec', 'oxlint'] };
31
+ }
32
+ if (lockFileName === 'yarn.lock') {
33
+ return { command: 'yarn', args: ['exec', 'oxlint'] };
34
+ }
35
+ if (lockFileName === 'package-lock.json') {
36
+ return { command: 'npx', args: ['oxlint'] };
37
+ }
38
+ }
39
+ // Fallback to direct oxlint if no lock file found
40
+ return { command: 'oxlint', args: [] };
41
+ }
42
+ async run(args = []) {
43
+ const packageManager = this.detectPackageManager();
44
+ const finalArgs = [...packageManager.args, '-f', 'json', ...args];
45
+ return new Promise((resolve, reject) => {
46
+ const process = spawn(packageManager.command, finalArgs, {
47
+ stdio: ['pipe', 'pipe', 'pipe']
48
+ });
49
+ let stdout = '';
50
+ let stderr = '';
51
+ process.stdout.on('data', (data) => {
52
+ stdout += data.toString();
53
+ });
54
+ process.stderr.on('data', (data) => {
55
+ stderr += data.toString();
56
+ });
57
+ process.on('close', (code) => {
58
+ try {
59
+ // oxlint exits with non-zero when linting issues are found
60
+ // We still want to parse the JSON output
61
+ const diagnostics = this.parseOxlintOutput(stdout);
62
+ resolve(diagnostics);
63
+ }
64
+ catch (error) {
65
+ reject(new Error(`Failed to parse oxlint output: ${error instanceof Error ? error.message : String(error)}\nStdout: ${stdout}\nStderr: ${stderr}`));
66
+ }
67
+ });
68
+ process.on('error', (error) => {
69
+ reject(new Error(`Failed to run ${packageManager.command}: ${error.message}`));
70
+ });
71
+ });
72
+ }
73
+ parseOxlintOutput(output) {
74
+ if (!output.trim()) {
75
+ return [];
76
+ }
77
+ try {
78
+ const parsed = JSON.parse(output);
79
+ const diagnostics = [];
80
+ // Handle the new oxlint JSON format with diagnostics array
81
+ if (parsed.diagnostics && Array.isArray(parsed.diagnostics)) {
82
+ for (const diagnostic of parsed.diagnostics) {
83
+ const processed = {
84
+ filename: diagnostic.filename || 'unknown',
85
+ rule: diagnostic.code || 'unknown',
86
+ severity: diagnostic.severity,
87
+ message: diagnostic.message,
88
+ help: diagnostic.help,
89
+ url: diagnostic.url
90
+ };
91
+ // Extract line/column from labels if available
92
+ if (diagnostic.labels && diagnostic.labels.length > 0) {
93
+ const firstLabel = diagnostic.labels[0];
94
+ if (firstLabel.span) {
95
+ processed.line = firstLabel.span.line;
96
+ processed.column = firstLabel.span.column;
97
+ }
98
+ }
99
+ diagnostics.push(processed);
100
+ }
101
+ }
102
+ // Fallback to old format if needed
103
+ else if (typeof parsed === 'object' && !Array.isArray(parsed)) {
104
+ for (const [filename, fileDiagnostics] of Object.entries(parsed)) {
105
+ if (Array.isArray(fileDiagnostics)) {
106
+ for (const diagnostic of fileDiagnostics) {
107
+ const processed = {
108
+ filename,
109
+ rule: diagnostic.rule_id || diagnostic.code || 'unknown',
110
+ severity: diagnostic.severity,
111
+ message: diagnostic.message,
112
+ help: diagnostic.help,
113
+ url: diagnostic.url
114
+ };
115
+ if (diagnostic.labels && diagnostic.labels.length > 0) {
116
+ const firstLabel = diagnostic.labels[0];
117
+ if (firstLabel.span) {
118
+ processed.line = firstLabel.span.line || firstLabel.span.start;
119
+ processed.column = firstLabel.span.column || (firstLabel.span.end - firstLabel.span.start);
120
+ }
121
+ }
122
+ diagnostics.push(processed);
123
+ }
124
+ }
125
+ }
126
+ }
127
+ return diagnostics;
128
+ }
129
+ catch (error) {
130
+ throw new Error(`Invalid JSON output: ${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,10 @@
1
+ import { SuppressionFile, ProcessedDiagnostic, ExcessError } from './types.js';
2
+ export declare class SuppressionManager {
3
+ private suppressionFile;
4
+ constructor(suppressionFile?: string);
5
+ loadSuppressions(): SuppressionFile;
6
+ saveSuppressions(suppressions: SuppressionFile): void;
7
+ generateSuppressions(diagnostics: ProcessedDiagnostic[]): SuppressionFile;
8
+ findExcessErrors(diagnostics: ProcessedDiagnostic[], suppressions: SuppressionFile): ExcessError[];
9
+ updateSuppressions(currentSuppressions: SuppressionFile, diagnostics: ProcessedDiagnostic[]): SuppressionFile;
10
+ }
@@ -0,0 +1,96 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ export class SuppressionManager {
3
+ suppressionFile;
4
+ constructor(suppressionFile = '.oxlint-suppressions.json') {
5
+ this.suppressionFile = suppressionFile;
6
+ }
7
+ loadSuppressions() {
8
+ if (!existsSync(this.suppressionFile)) {
9
+ return {};
10
+ }
11
+ try {
12
+ const content = readFileSync(this.suppressionFile, 'utf8');
13
+ return JSON.parse(content);
14
+ }
15
+ catch (error) {
16
+ throw new Error(`Failed to parse suppression file ${this.suppressionFile}: ${error instanceof Error ? error.message : String(error)}`);
17
+ }
18
+ }
19
+ saveSuppressions(suppressions) {
20
+ try {
21
+ const content = JSON.stringify(suppressions, null, 2);
22
+ writeFileSync(this.suppressionFile, content, 'utf8');
23
+ }
24
+ catch (error) {
25
+ throw new Error(`Failed to write suppression file ${this.suppressionFile}: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ }
28
+ generateSuppressions(diagnostics) {
29
+ const suppressions = {};
30
+ // Group diagnostics by file and rule
31
+ const counts = new Map();
32
+ for (const diagnostic of diagnostics) {
33
+ if (!counts.has(diagnostic.filename)) {
34
+ counts.set(diagnostic.filename, new Map());
35
+ }
36
+ const fileRules = counts.get(diagnostic.filename);
37
+ const currentCount = fileRules.get(diagnostic.rule) || 0;
38
+ fileRules.set(diagnostic.rule, currentCount + 1);
39
+ }
40
+ // Convert to suppression format
41
+ for (const [filename, rules] of counts.entries()) {
42
+ suppressions[filename] = {};
43
+ for (const [rule, count] of rules.entries()) {
44
+ suppressions[filename][rule] = { count };
45
+ }
46
+ }
47
+ return suppressions;
48
+ }
49
+ findExcessErrors(diagnostics, suppressions) {
50
+ const excessErrors = [];
51
+ // Group diagnostics by file and rule
52
+ const actualCounts = new Map();
53
+ for (const diagnostic of diagnostics) {
54
+ if (!actualCounts.has(diagnostic.filename)) {
55
+ actualCounts.set(diagnostic.filename, new Map());
56
+ }
57
+ const fileRules = actualCounts.get(diagnostic.filename);
58
+ if (!fileRules.has(diagnostic.rule)) {
59
+ fileRules.set(diagnostic.rule, []);
60
+ }
61
+ fileRules.get(diagnostic.rule).push(diagnostic);
62
+ }
63
+ // Compare actual vs suppressed counts
64
+ for (const [filename, rules] of actualCounts.entries()) {
65
+ for (const [rule, diagnosticsForRule] of rules.entries()) {
66
+ const actual = diagnosticsForRule.length;
67
+ const expected = suppressions[filename]?.[rule]?.count || 0;
68
+ if (actual > expected) {
69
+ excessErrors.push({
70
+ rule,
71
+ filename,
72
+ expected,
73
+ actual,
74
+ diagnostics: diagnosticsForRule
75
+ });
76
+ }
77
+ }
78
+ }
79
+ return excessErrors;
80
+ }
81
+ updateSuppressions(currentSuppressions, diagnostics) {
82
+ const newSuppressions = this.generateSuppressions(diagnostics);
83
+ // Merge with existing suppressions, using new counts
84
+ const updated = { ...currentSuppressions };
85
+ for (const [filename, rules] of Object.entries(newSuppressions)) {
86
+ updated[filename] = { ...updated[filename], ...rules };
87
+ }
88
+ // Remove files with no rules
89
+ for (const [filename, rules] of Object.entries(updated)) {
90
+ if (Object.keys(rules).length === 0) {
91
+ delete updated[filename];
92
+ }
93
+ }
94
+ return updated;
95
+ }
96
+ }
@@ -0,0 +1,42 @@
1
+ export interface OxlintDiagnostic {
2
+ severity: 'error' | 'warning';
3
+ message: string;
4
+ labels: Array<{
5
+ span: {
6
+ start: number;
7
+ end: number;
8
+ };
9
+ message: string;
10
+ }>;
11
+ rule_id?: string;
12
+ help?: string;
13
+ url?: string;
14
+ }
15
+ export interface OxlintOutput {
16
+ [filename: string]: OxlintDiagnostic[];
17
+ }
18
+ export interface SuppressionRule {
19
+ count: number;
20
+ }
21
+ export interface SuppressionFile {
22
+ [filename: string]: {
23
+ [ruleName: string]: SuppressionRule;
24
+ };
25
+ }
26
+ export interface ProcessedDiagnostic {
27
+ filename: string;
28
+ rule: string;
29
+ severity: 'error' | 'warning';
30
+ message: string;
31
+ line?: number;
32
+ column?: number;
33
+ help?: string;
34
+ url?: string;
35
+ }
36
+ export interface ExcessError {
37
+ rule: string;
38
+ filename: string;
39
+ expected: number;
40
+ actual: number;
41
+ diagnostics: ProcessedDiagnostic[];
42
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "oxlint-harness",
3
+ "version": "1.0.0",
4
+ "description": "A harness for oxlint with bulk suppressions support",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "oxlint-harness": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsgo",
17
+ "prepublishOnly": "npm run build",
18
+ "dev": "node src/cli.ts",
19
+ "test": "vitest",
20
+ "test:watch": "vitest --watch",
21
+ "lint": "oxlint src/",
22
+ "typecheck": "tsgo"
23
+ },
24
+ "keywords": ["oxlint", "linting", "suppressions", "eslint", "bulk-suppressions", "cli"],
25
+ "author": "Generated with Claude Code",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/MIreland/oxlint-harness.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/MIreland/oxlint-harness/issues"
33
+ },
34
+ "homepage": "https://github.com/MIreland/oxlint-harness#readme",
35
+ "dependencies": {
36
+ "@oclif/core": "^4.0.0",
37
+ "@oclif/plugin-help": "^6.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.10.0",
41
+ "@typescript/native-preview": "7.0.0-dev.20251222.1",
42
+ "vitest": "4.0.16"
43
+ },
44
+ "engines": {
45
+ "node": ">=22"
46
+ }
47
+ }