From cb801d9801dad2b25963938c548869a5c8549a1b Mon Sep 17 00:00:00 2001 From: Brian Douglas Date: Tue, 17 Mar 2026 16:20:12 -0700 Subject: [PATCH] fix: add ESLint stylish format parser and fix @-scoped rule matching ESLint's default output format ("stylish") is multi-line with file headers and indented issues. The linter parser only supported single-line formats, so ESLint output fell through to the raw output path causing sub-agent crashes. - Add parseESLintStylish() for multi-line block format parsing - Fix golangci regex to match @-scoped rules (e.g. @typescript-eslint/no-unused-vars) - Mirror both fixes in the TypeScript extension - Add test data and test cases for stylish format --- extensions/sweeper/index.ts | 115 ++++++++++++++-------- pkg/linter/linter.go | 60 ++++++++++- pkg/linter/linter_test.go | 59 +++++++++++ testdata/sample_eslint_stylish_output.txt | 11 +++ 4 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 testdata/sample_eslint_stylish_output.txt diff --git a/extensions/sweeper/index.ts b/extensions/sweeper/index.ts index 92b80df..39d7d1a 100644 --- a/extensions/sweeper/index.ts +++ b/extensions/sweeper/index.ts @@ -229,9 +229,11 @@ export default function sweeper({ tool, widget }: any) { // Parse with three regex patterns matching Go CLI convention const golangciPattern = - /^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$/; + /^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$/; const genericPattern = /^(.+?):(\d+):(\d+):\s+(.+)$/; const minimalPattern = /^(.+?):(\d+):\s+(.+)$/; + const eslintStylishIssue = + /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$/; const issues: Array<{ file: string; @@ -241,46 +243,81 @@ export default function sweeper({ tool, widget }: any) { linter: string; }> = []; - for (const line of rawOutput.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - - let m: RegExpMatchArray | null; - - m = trimmed.match(golangciPattern); - if (m) { - issues.push({ - file: m[1], - line: parseInt(m[2], 10), - col: parseInt(m[3], 10), - message: m[4], - linter: m[5], - }); - continue; - } - - m = trimmed.match(genericPattern); - if (m) { - issues.push({ - file: m[1], - line: parseInt(m[2], 10), - col: parseInt(m[3], 10), - message: m[4], - linter: "custom", - }); - continue; + // Try ESLint stylish (multi-line block) format first + { + let currentFile = ""; + for (const line of rawOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + if ( + trimmed.includes("problem") && + (trimmed.includes("\u2716") || + (trimmed.includes("error") && trimmed.includes("warning"))) + ) + continue; + + const sm = line.match(eslintStylishIssue); + if (sm && currentFile) { + issues.push({ + file: currentFile, + line: parseInt(sm[1], 10), + col: parseInt(sm[2], 10), + message: sm[4].trim(), + linter: sm[5], + }); + continue; + } + + // File header: non-indented, non-empty + if (line === trimmed && trimmed.length > 0 && !trimmed.startsWith("\u2716")) { + currentFile = trimmed; + } } + } - m = trimmed.match(minimalPattern); - if (m) { - issues.push({ - file: m[1], - line: parseInt(m[2], 10), - col: 0, - message: m[3], - linter: "custom", - }); - continue; + // If stylish parse found nothing, fall back to line-by-line patterns + if (issues.length === 0) { + for (const line of rawOutput.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let m: RegExpMatchArray | null; + + m = trimmed.match(golangciPattern); + if (m) { + issues.push({ + file: m[1], + line: parseInt(m[2], 10), + col: parseInt(m[3], 10), + message: m[4], + linter: m[5], + }); + continue; + } + + m = trimmed.match(genericPattern); + if (m) { + issues.push({ + file: m[1], + line: parseInt(m[2], 10), + col: parseInt(m[3], 10), + message: m[4], + linter: "custom", + }); + continue; + } + + m = trimmed.match(minimalPattern); + if (m) { + issues.push({ + file: m[1], + line: parseInt(m[2], 10), + col: 0, + message: m[3], + linter: "custom", + }); + continue; + } } } diff --git a/pkg/linter/linter.go b/pkg/linter/linter.go index 62485fc..2741153 100644 --- a/pkg/linter/linter.go +++ b/pkg/linter/linter.go @@ -27,7 +27,10 @@ type ParseResult struct { var ( // golangci-lint format: file:line:col: message (linter) - golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$`) + // Linter name supports @-scoped rules like @typescript-eslint/no-unused-vars + golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$`) + // ESLint stylish issue line: " line:col error|warning message rule-name" + eslintStylishIssue = regexp.MustCompile(`^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$`) // generic file:line:col: message genericPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)$`) // minimal file:line: message @@ -36,6 +39,14 @@ var ( func ParseOutput(raw string) ParseResult { result := ParseResult{RawOutput: raw} + + // Try ESLint stylish (multi-line block) format first + if issues := parseESLintStylish(raw); len(issues) > 0 { + result.Issues = issues + result.Parsed = true + return result + } + scanner := bufio.NewScanner(strings.NewReader(raw)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -59,6 +70,53 @@ func ParseOutput(raw string) ParseResult { return result } +// parseESLintStylish parses ESLint's default "stylish" multi-line format: +// +// /path/to/file.js +// 2:10 error 'foo' is not defined no-undef +// 5:1 warning Unexpected console statement no-console +// +// ✖ 2 problems (1 error, 1 warning) +func parseESLintStylish(raw string) []Issue { + var issues []Issue + var currentFile string + + for _, line := range strings.Split(raw, "\n") { + // Skip empty lines and summary lines (e.g. "✖ 2 problems...") + if strings.TrimSpace(line) == "" { + continue + } + if strings.Contains(line, "problem") && (strings.Contains(line, "✖") || strings.Contains(line, "error") && strings.Contains(line, "warning")) { + continue + } + + // Issue line: indented with "line:col severity message rule" + if m := eslintStylishIssue.FindStringSubmatch(line); m != nil { + if currentFile == "" { + continue + } + lineNum, _ := strconv.Atoi(m[1]) + col, _ := strconv.Atoi(m[2]) + issues = append(issues, Issue{ + File: currentFile, + Line: lineNum, + Col: col, + Message: strings.TrimSpace(m[4]), + Linter: m[5], + }) + continue + } + + // File header: non-indented line that looks like a path + trimmed := strings.TrimSpace(line) + if line == trimmed && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "✖") { + currentFile = trimmed + } + } + + return issues +} + func parseGolangci(line string) (Issue, bool) { m := golangciPattern.FindStringSubmatch(line) if m == nil { diff --git a/pkg/linter/linter_test.go b/pkg/linter/linter_test.go index 4befaf7..de3019f 100644 --- a/pkg/linter/linter_test.go +++ b/pkg/linter/linter_test.go @@ -114,6 +114,65 @@ func TestParseOutputESLint(t *testing.T) { } } +func TestParseOutputESLintStylish(t *testing.T) { + data, err := os.ReadFile("../../testdata/sample_eslint_stylish_output.txt") + if err != nil { + t.Fatal(err) + } + result := ParseOutput(string(data)) + if !result.Parsed { + t.Fatal("expected Parsed to be true for ESLint stylish output") + } + if len(result.Issues) != 4 { + t.Fatalf("expected 4 issues, got %d", len(result.Issues)) + } + + // Verify first issue + first := result.Issues[0] + if first.File != "src/App.tsx" { + t.Errorf("expected file src/App.tsx, got %s", first.File) + } + if first.Line != 2 { + t.Errorf("expected line 2, got %d", first.Line) + } + if first.Col != 10 { + t.Errorf("expected col 10, got %d", first.Col) + } + if first.Linter != "@typescript-eslint/no-unused-vars" { + t.Errorf("expected linter @typescript-eslint/no-unused-vars, got %s", first.Linter) + } + + // Verify issue from a different file + third := result.Issues[2] + if third.File != "src/components/Header.tsx" { + t.Errorf("expected file src/components/Header.tsx, got %s", third.File) + } + if third.Linter != "@typescript-eslint/explicit-function-return-type" { + t.Errorf("expected linter @typescript-eslint/explicit-function-return-type, got %s", third.Linter) + } + + // Verify last issue uses simple rule name + last := result.Issues[3] + if last.Linter != "no-console" { + t.Errorf("expected linter no-console, got %s", last.Linter) + } +} + +func TestParseOutputESLintStylishOrphanLine(t *testing.T) { + // Issue line appearing before any file header should be skipped + raw := " 1:5 error Unexpected var no-var\n\nsrc/app.ts\n 3:1 warning Missing semicolon semi\n" + result := ParseOutput(raw) + if !result.Parsed { + t.Fatal("expected Parsed to be true") + } + if len(result.Issues) != 1 { + t.Fatalf("expected 1 issue (orphan skipped), got %d", len(result.Issues)) + } + if result.Issues[0].File != "src/app.ts" { + t.Errorf("expected file src/app.ts, got %s", result.Issues[0].File) + } +} + func TestParseOutputPylint(t *testing.T) { data, err := os.ReadFile("../../testdata/sample_pylint_output.txt") if err != nil { diff --git a/testdata/sample_eslint_stylish_output.txt b/testdata/sample_eslint_stylish_output.txt new file mode 100644 index 0000000..a5930fe --- /dev/null +++ b/testdata/sample_eslint_stylish_output.txt @@ -0,0 +1,11 @@ +src/App.tsx + 2:10 error 'useState' is defined but never used @typescript-eslint/no-unused-vars + 34:10 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +src/components/Header.tsx + 8:1 error Missing return type on function @typescript-eslint/explicit-function-return-type + +src/utils/helpers.ts + 22:3 warning Unexpected console statement no-console + +✖ 4 problems (2 errors, 2 warnings)