Skip to content

Commit edbc4ea

Browse files
authored
fix: add ESLint stylish format parser and fix @-scoped rule matching (#11)
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
1 parent 65604e5 commit edbc4ea

4 files changed

Lines changed: 205 additions & 40 deletions

File tree

extensions/sweeper/index.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,11 @@ export default function sweeper({ tool, widget }: any) {
229229

230230
// Parse with three regex patterns matching Go CLI convention
231231
const golangciPattern =
232-
/^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$/;
232+
/^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$/;
233233
const genericPattern = /^(.+?):(\d+):(\d+):\s+(.+)$/;
234234
const minimalPattern = /^(.+?):(\d+):\s+(.+)$/;
235+
const eslintStylishIssue =
236+
/^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$/;
235237

236238
const issues: Array<{
237239
file: string;
@@ -241,46 +243,81 @@ export default function sweeper({ tool, widget }: any) {
241243
linter: string;
242244
}> = [];
243245

244-
for (const line of rawOutput.split("\n")) {
245-
const trimmed = line.trim();
246-
if (!trimmed) continue;
247-
248-
let m: RegExpMatchArray | null;
249-
250-
m = trimmed.match(golangciPattern);
251-
if (m) {
252-
issues.push({
253-
file: m[1],
254-
line: parseInt(m[2], 10),
255-
col: parseInt(m[3], 10),
256-
message: m[4],
257-
linter: m[5],
258-
});
259-
continue;
260-
}
261-
262-
m = trimmed.match(genericPattern);
263-
if (m) {
264-
issues.push({
265-
file: m[1],
266-
line: parseInt(m[2], 10),
267-
col: parseInt(m[3], 10),
268-
message: m[4],
269-
linter: "custom",
270-
});
271-
continue;
246+
// Try ESLint stylish (multi-line block) format first
247+
{
248+
let currentFile = "";
249+
for (const line of rawOutput.split("\n")) {
250+
const trimmed = line.trim();
251+
if (!trimmed) continue;
252+
if (
253+
trimmed.includes("problem") &&
254+
(trimmed.includes("\u2716") ||
255+
(trimmed.includes("error") && trimmed.includes("warning")))
256+
)
257+
continue;
258+
259+
const sm = line.match(eslintStylishIssue);
260+
if (sm && currentFile) {
261+
issues.push({
262+
file: currentFile,
263+
line: parseInt(sm[1], 10),
264+
col: parseInt(sm[2], 10),
265+
message: sm[4].trim(),
266+
linter: sm[5],
267+
});
268+
continue;
269+
}
270+
271+
// File header: non-indented, non-empty
272+
if (line === trimmed && trimmed.length > 0 && !trimmed.startsWith("\u2716")) {
273+
currentFile = trimmed;
274+
}
272275
}
276+
}
273277

274-
m = trimmed.match(minimalPattern);
275-
if (m) {
276-
issues.push({
277-
file: m[1],
278-
line: parseInt(m[2], 10),
279-
col: 0,
280-
message: m[3],
281-
linter: "custom",
282-
});
283-
continue;
278+
// If stylish parse found nothing, fall back to line-by-line patterns
279+
if (issues.length === 0) {
280+
for (const line of rawOutput.split("\n")) {
281+
const trimmed = line.trim();
282+
if (!trimmed) continue;
283+
284+
let m: RegExpMatchArray | null;
285+
286+
m = trimmed.match(golangciPattern);
287+
if (m) {
288+
issues.push({
289+
file: m[1],
290+
line: parseInt(m[2], 10),
291+
col: parseInt(m[3], 10),
292+
message: m[4],
293+
linter: m[5],
294+
});
295+
continue;
296+
}
297+
298+
m = trimmed.match(genericPattern);
299+
if (m) {
300+
issues.push({
301+
file: m[1],
302+
line: parseInt(m[2], 10),
303+
col: parseInt(m[3], 10),
304+
message: m[4],
305+
linter: "custom",
306+
});
307+
continue;
308+
}
309+
310+
m = trimmed.match(minimalPattern);
311+
if (m) {
312+
issues.push({
313+
file: m[1],
314+
line: parseInt(m[2], 10),
315+
col: 0,
316+
message: m[3],
317+
linter: "custom",
318+
});
319+
continue;
320+
}
284321
}
285322
}
286323

pkg/linter/linter.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ type ParseResult struct {
2727

2828
var (
2929
// golangci-lint format: file:line:col: message (linter)
30-
golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\((\w[\w-]*)\)$`)
30+
// Linter name supports @-scoped rules like @typescript-eslint/no-unused-vars
31+
golangciPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)\s+\(([@\w][\w./@-]*)\)$`)
32+
// ESLint stylish issue line: " line:col error|warning message rule-name"
33+
eslintStylishIssue = regexp.MustCompile(`^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$`)
3134
// generic file:line:col: message
3235
genericPattern = regexp.MustCompile(`^(.+?):(\d+):(\d+):\s+(.+)$`)
3336
// minimal file:line: message
@@ -36,6 +39,14 @@ var (
3639

3740
func ParseOutput(raw string) ParseResult {
3841
result := ParseResult{RawOutput: raw}
42+
43+
// Try ESLint stylish (multi-line block) format first
44+
if issues := parseESLintStylish(raw); len(issues) > 0 {
45+
result.Issues = issues
46+
result.Parsed = true
47+
return result
48+
}
49+
3950
scanner := bufio.NewScanner(strings.NewReader(raw))
4051
for scanner.Scan() {
4152
line := strings.TrimSpace(scanner.Text())
@@ -59,6 +70,53 @@ func ParseOutput(raw string) ParseResult {
5970
return result
6071
}
6172

73+
// parseESLintStylish parses ESLint's default "stylish" multi-line format:
74+
//
75+
// /path/to/file.js
76+
// 2:10 error 'foo' is not defined no-undef
77+
// 5:1 warning Unexpected console statement no-console
78+
//
79+
// ✖ 2 problems (1 error, 1 warning)
80+
func parseESLintStylish(raw string) []Issue {
81+
var issues []Issue
82+
var currentFile string
83+
84+
for _, line := range strings.Split(raw, "\n") {
85+
// Skip empty lines and summary lines (e.g. "✖ 2 problems...")
86+
if strings.TrimSpace(line) == "" {
87+
continue
88+
}
89+
if strings.Contains(line, "problem") && (strings.Contains(line, "✖") || strings.Contains(line, "error") && strings.Contains(line, "warning")) {
90+
continue
91+
}
92+
93+
// Issue line: indented with "line:col severity message rule"
94+
if m := eslintStylishIssue.FindStringSubmatch(line); m != nil {
95+
if currentFile == "" {
96+
continue
97+
}
98+
lineNum, _ := strconv.Atoi(m[1])
99+
col, _ := strconv.Atoi(m[2])
100+
issues = append(issues, Issue{
101+
File: currentFile,
102+
Line: lineNum,
103+
Col: col,
104+
Message: strings.TrimSpace(m[4]),
105+
Linter: m[5],
106+
})
107+
continue
108+
}
109+
110+
// File header: non-indented line that looks like a path
111+
trimmed := strings.TrimSpace(line)
112+
if line == trimmed && len(trimmed) > 0 && !strings.HasPrefix(trimmed, "✖") {
113+
currentFile = trimmed
114+
}
115+
}
116+
117+
return issues
118+
}
119+
62120
func parseGolangci(line string) (Issue, bool) {
63121
m := golangciPattern.FindStringSubmatch(line)
64122
if m == nil {

pkg/linter/linter_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,65 @@ func TestParseOutputESLint(t *testing.T) {
114114
}
115115
}
116116

117+
func TestParseOutputESLintStylish(t *testing.T) {
118+
data, err := os.ReadFile("../../testdata/sample_eslint_stylish_output.txt")
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
result := ParseOutput(string(data))
123+
if !result.Parsed {
124+
t.Fatal("expected Parsed to be true for ESLint stylish output")
125+
}
126+
if len(result.Issues) != 4 {
127+
t.Fatalf("expected 4 issues, got %d", len(result.Issues))
128+
}
129+
130+
// Verify first issue
131+
first := result.Issues[0]
132+
if first.File != "src/App.tsx" {
133+
t.Errorf("expected file src/App.tsx, got %s", first.File)
134+
}
135+
if first.Line != 2 {
136+
t.Errorf("expected line 2, got %d", first.Line)
137+
}
138+
if first.Col != 10 {
139+
t.Errorf("expected col 10, got %d", first.Col)
140+
}
141+
if first.Linter != "@typescript-eslint/no-unused-vars" {
142+
t.Errorf("expected linter @typescript-eslint/no-unused-vars, got %s", first.Linter)
143+
}
144+
145+
// Verify issue from a different file
146+
third := result.Issues[2]
147+
if third.File != "src/components/Header.tsx" {
148+
t.Errorf("expected file src/components/Header.tsx, got %s", third.File)
149+
}
150+
if third.Linter != "@typescript-eslint/explicit-function-return-type" {
151+
t.Errorf("expected linter @typescript-eslint/explicit-function-return-type, got %s", third.Linter)
152+
}
153+
154+
// Verify last issue uses simple rule name
155+
last := result.Issues[3]
156+
if last.Linter != "no-console" {
157+
t.Errorf("expected linter no-console, got %s", last.Linter)
158+
}
159+
}
160+
161+
func TestParseOutputESLintStylishOrphanLine(t *testing.T) {
162+
// Issue line appearing before any file header should be skipped
163+
raw := " 1:5 error Unexpected var no-var\n\nsrc/app.ts\n 3:1 warning Missing semicolon semi\n"
164+
result := ParseOutput(raw)
165+
if !result.Parsed {
166+
t.Fatal("expected Parsed to be true")
167+
}
168+
if len(result.Issues) != 1 {
169+
t.Fatalf("expected 1 issue (orphan skipped), got %d", len(result.Issues))
170+
}
171+
if result.Issues[0].File != "src/app.ts" {
172+
t.Errorf("expected file src/app.ts, got %s", result.Issues[0].File)
173+
}
174+
}
175+
117176
func TestParseOutputPylint(t *testing.T) {
118177
data, err := os.ReadFile("../../testdata/sample_pylint_output.txt")
119178
if err != nil {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
src/App.tsx
2+
2:10 error 'useState' is defined but never used @typescript-eslint/no-unused-vars
3+
34:10 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
4+
5+
src/components/Header.tsx
6+
8:1 error Missing return type on function @typescript-eslint/explicit-function-return-type
7+
8+
src/utils/helpers.ts
9+
22:3 warning Unexpected console statement no-console
10+
11+
✖ 4 problems (2 errors, 2 warnings)

0 commit comments

Comments
 (0)