pi-lens 3.7.0 → 3.7.1
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/CHANGELOG.md +22 -0
- package/clients/dispatch/plan.ts +16 -1
- package/clients/dispatch/runners/eslint.ts +157 -0
- package/clients/dispatch/runners/golangci-lint.ts +133 -0
- package/clients/dispatch/runners/index.ts +6 -0
- package/clients/dispatch/runners/rubocop.ts +141 -0
- package/clients/file-kinds.ts +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-lens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.7.1] - 2026-04-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **ESLint dispatch runner** — Projects with `.eslintrc` / `eslint.config.js` (any variant)
|
|
9
|
+
now run ESLint automatically on every JS/TS file write. Prefers local
|
|
10
|
+
`node_modules/.bin/eslint` over global. Skips silently on projects using Biome/OxLint
|
|
11
|
+
(no ESLint config). ESLint errors (severity 2) are blocking; warnings are non-blocking.
|
|
12
|
+
|
|
13
|
+
- **golangci-lint dispatch runner** — Go projects with `.golangci.yml` / `.golangci.yaml`
|
|
14
|
+
now run golangci-lint on every `.go` file write (in addition to `go-vet`). Parses JSON
|
|
15
|
+
output. Skips when no config is present (avoids default-rule noise on non-opted-in
|
|
16
|
+
projects). 60s timeout.
|
|
17
|
+
|
|
18
|
+
- **RuboCop dispatch runner** — Ruby files (`.rb`, `.rake`, `.gemspec`, `.ru`) now run
|
|
19
|
+
RuboCop in lint-only mode on every write. Prefers `bundle exec rubocop` when a Gemfile
|
|
20
|
+
references rubocop. Fatal/error offenses are blocking; convention/refactor are warnings.
|
|
21
|
+
|
|
22
|
+
- **`ruby` file kind** — `.rb`, `.rake`, `.gemspec`, `.ru` files are now recognised as
|
|
23
|
+
`ruby` kind, enabling file-kind-gated runners and formatter detection.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
5
27
|
## [3.7.0] - 2026-04-05
|
|
6
28
|
|
|
7
29
|
### Added
|
package/clients/dispatch/plan.ts
CHANGED
|
@@ -41,6 +41,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
|
|
|
41
41
|
{ mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
|
|
42
42
|
// Similarity detection — warns about duplicated/reusable code
|
|
43
43
|
{ mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
|
|
44
|
+
// ESLint: only fires when project has eslint config (skips Biome/OxLint projects)
|
|
45
|
+
{ mode: "fallback", runnerIds: ["eslint"], filterKinds: ["jsts"] },
|
|
44
46
|
// Note: ast-grep CLI kept for ast_grep_search/ast_grep_replace tools only
|
|
45
47
|
// Note: biome, oxlint handled by direct auto-fix calls in index.ts (not in dispatch)
|
|
46
48
|
// Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
|
|
@@ -71,8 +73,10 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
|
|
|
71
73
|
groups: [
|
|
72
74
|
// LSP type checking (gopls)
|
|
73
75
|
{ mode: "all", runnerIds: ["lsp"], filterKinds: ["go"] },
|
|
74
|
-
// Go vet for additional checks
|
|
76
|
+
// Go vet for additional checks
|
|
75
77
|
{ mode: "fallback", runnerIds: ["go-vet"], filterKinds: ["go"] },
|
|
78
|
+
// golangci-lint: only fires when project has .golangci.yml config
|
|
79
|
+
{ mode: "fallback", runnerIds: ["golangci-lint"], filterKinds: ["go"] },
|
|
76
80
|
// Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
|
|
77
81
|
],
|
|
78
82
|
},
|
|
@@ -91,6 +95,16 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
|
|
|
91
95
|
],
|
|
92
96
|
},
|
|
93
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Ruby linting
|
|
100
|
+
*/
|
|
101
|
+
ruby: {
|
|
102
|
+
name: "Ruby Linting",
|
|
103
|
+
groups: [
|
|
104
|
+
{ mode: "fallback", runnerIds: ["rubocop"], filterKinds: ["ruby"] },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
|
|
94
108
|
/**
|
|
95
109
|
* C/C++ linting tools
|
|
96
110
|
*/
|
|
@@ -182,6 +196,7 @@ export const FULL_LINT_PLANS: Record<string, ToolPlan> = {
|
|
|
182
196
|
{ mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
|
|
183
197
|
{ mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
|
|
184
198
|
{ mode: "fallback", runnerIds: ["architect"], filterKinds: ["jsts"] },
|
|
199
|
+
{ mode: "fallback", runnerIds: ["eslint"], filterKinds: ["jsts"] },
|
|
185
200
|
],
|
|
186
201
|
},
|
|
187
202
|
// Override python to include warning-only tools
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint runner for dispatch system
|
|
3
|
+
*
|
|
4
|
+
* Runs ESLint on JS/TS files when an ESLint config is present in the project.
|
|
5
|
+
* Prefers the local node_modules installation over global.
|
|
6
|
+
*
|
|
7
|
+
* Gate: skips when no ESLint config is detected (project uses Biome/OxLint instead).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
13
|
+
import type {
|
|
14
|
+
Diagnostic,
|
|
15
|
+
DispatchContext,
|
|
16
|
+
RunnerDefinition,
|
|
17
|
+
RunnerResult,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
|
|
20
|
+
const ESLINT_CONFIGS = [
|
|
21
|
+
".eslintrc",
|
|
22
|
+
".eslintrc.js",
|
|
23
|
+
".eslintrc.cjs",
|
|
24
|
+
".eslintrc.json",
|
|
25
|
+
".eslintrc.yaml",
|
|
26
|
+
".eslintrc.yml",
|
|
27
|
+
"eslint.config.js",
|
|
28
|
+
"eslint.config.mjs",
|
|
29
|
+
"eslint.config.cjs",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function hasEslintConfig(cwd: string): boolean {
|
|
33
|
+
for (const cfg of ESLINT_CONFIGS) {
|
|
34
|
+
if (fs.existsSync(path.join(cwd, cfg))) return true;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(
|
|
38
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf-8"),
|
|
39
|
+
);
|
|
40
|
+
if (pkg.eslintConfig) return true;
|
|
41
|
+
} catch {}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findEslint(cwd: string): string {
|
|
46
|
+
const isWin = process.platform === "win32";
|
|
47
|
+
const local = path.join(
|
|
48
|
+
cwd,
|
|
49
|
+
"node_modules",
|
|
50
|
+
".bin",
|
|
51
|
+
isWin ? "eslint.cmd" : "eslint",
|
|
52
|
+
);
|
|
53
|
+
if (fs.existsSync(local)) return local;
|
|
54
|
+
// fall back to global
|
|
55
|
+
return "eslint";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface EslintMessage {
|
|
59
|
+
ruleId: string | null;
|
|
60
|
+
severity: 1 | 2;
|
|
61
|
+
message: string;
|
|
62
|
+
line: number;
|
|
63
|
+
column: number;
|
|
64
|
+
fix?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface EslintFileResult {
|
|
68
|
+
filePath: string;
|
|
69
|
+
messages: EslintMessage[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseEslintJson(raw: string, filePath: string): Diagnostic[] {
|
|
73
|
+
try {
|
|
74
|
+
const results: EslintFileResult[] = JSON.parse(raw);
|
|
75
|
+
const diagnostics: Diagnostic[] = [];
|
|
76
|
+
|
|
77
|
+
for (const fileResult of results) {
|
|
78
|
+
for (const msg of fileResult.messages) {
|
|
79
|
+
const severity = msg.severity === 2 ? "error" : "warning";
|
|
80
|
+
diagnostics.push({
|
|
81
|
+
id: `eslint:${msg.ruleId ?? "unknown"}:${msg.line}`,
|
|
82
|
+
message: msg.ruleId ? `${msg.ruleId}: ${msg.message}` : msg.message,
|
|
83
|
+
filePath,
|
|
84
|
+
line: msg.line ?? 1,
|
|
85
|
+
column: msg.column ?? 1,
|
|
86
|
+
severity,
|
|
87
|
+
semantic: severity === "error" ? "blocking" : "warning",
|
|
88
|
+
tool: "eslint",
|
|
89
|
+
rule: msg.ruleId ?? undefined,
|
|
90
|
+
fixable: !!msg.fix,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return diagnostics;
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const eslintRunner: RunnerDefinition = {
|
|
102
|
+
id: "eslint",
|
|
103
|
+
appliesTo: ["jsts"],
|
|
104
|
+
priority: 12,
|
|
105
|
+
enabledByDefault: true,
|
|
106
|
+
|
|
107
|
+
async run(ctx: DispatchContext): Promise<RunnerResult> {
|
|
108
|
+
const cwd = ctx.cwd || process.cwd();
|
|
109
|
+
|
|
110
|
+
// Only run if project has an ESLint config
|
|
111
|
+
if (!hasEslintConfig(cwd)) {
|
|
112
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cmd = findEslint(cwd);
|
|
116
|
+
|
|
117
|
+
// Verify ESLint is actually executable
|
|
118
|
+
const versionCheck = await safeSpawnAsync(cmd, ["--version"], {
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
cwd,
|
|
121
|
+
});
|
|
122
|
+
if (versionCheck.error || versionCheck.status !== 0) {
|
|
123
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await safeSpawnAsync(
|
|
127
|
+
cmd,
|
|
128
|
+
["--format", "json", "--no-error-on-unmatched-pattern", ctx.filePath],
|
|
129
|
+
{ timeout: 30000, cwd },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// ESLint exits 1 when there are lint errors, 2 on fatal/config error
|
|
133
|
+
if (result.status === 2) {
|
|
134
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const raw = result.stdout || result.stderr || "";
|
|
138
|
+
|
|
139
|
+
if (result.status === 0) {
|
|
140
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const diagnostics = parseEslintJson(raw, ctx.filePath);
|
|
144
|
+
if (diagnostics.length === 0) {
|
|
145
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
|
|
149
|
+
return {
|
|
150
|
+
status: "failed",
|
|
151
|
+
diagnostics,
|
|
152
|
+
semantic: hasErrors ? "blocking" : "warning",
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export default eslintRunner;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* golangci-lint runner for dispatch system
|
|
3
|
+
*
|
|
4
|
+
* Runs golangci-lint when a .golangci.yml config is present.
|
|
5
|
+
* golangci-lint is the standard meta-linter for Go projects — it runs
|
|
6
|
+
* staticcheck, errcheck, gosimple, and many others in one pass.
|
|
7
|
+
*
|
|
8
|
+
* Gate: skips when no .golangci.yml/.golangci.yaml config is found (project
|
|
9
|
+
* relies on go-vet only). This avoids noisy default-rule runs on projects
|
|
10
|
+
* that haven't opted in.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
16
|
+
import { stripAnsi } from "../../sanitize.js";
|
|
17
|
+
import type {
|
|
18
|
+
Diagnostic,
|
|
19
|
+
DispatchContext,
|
|
20
|
+
RunnerDefinition,
|
|
21
|
+
RunnerResult,
|
|
22
|
+
} from "../types.js";
|
|
23
|
+
|
|
24
|
+
const GOLANGCI_CONFIGS = [
|
|
25
|
+
".golangci.yml",
|
|
26
|
+
".golangci.yaml",
|
|
27
|
+
".golangci.toml",
|
|
28
|
+
".golangci.json",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function hasGolangciConfig(cwd: string): boolean {
|
|
32
|
+
for (const cfg of GOLANGCI_CONFIGS) {
|
|
33
|
+
if (fs.existsSync(path.join(cwd, cfg))) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface GolangciIssue {
|
|
39
|
+
FromLinter: string;
|
|
40
|
+
Text: string;
|
|
41
|
+
Severity?: string;
|
|
42
|
+
Pos: {
|
|
43
|
+
Filename: string;
|
|
44
|
+
Line: number;
|
|
45
|
+
Column: number;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GolangciOutput {
|
|
50
|
+
Issues: GolangciIssue[] | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseGolangciJson(raw: string, filePath: string): Diagnostic[] {
|
|
54
|
+
try {
|
|
55
|
+
const output: GolangciOutput = JSON.parse(raw);
|
|
56
|
+
if (!output.Issues) return [];
|
|
57
|
+
|
|
58
|
+
const absFile = path.resolve(filePath);
|
|
59
|
+
|
|
60
|
+
return output.Issues.filter(
|
|
61
|
+
(issue) => path.resolve(issue.Pos.Filename) === absFile,
|
|
62
|
+
).map((issue) => {
|
|
63
|
+
const severity = issue.Severity === "error" ? "error" : "warning";
|
|
64
|
+
return {
|
|
65
|
+
id: `golangci:${issue.FromLinter}:${issue.Pos.Line}`,
|
|
66
|
+
message: `${issue.FromLinter}: ${issue.Text}`,
|
|
67
|
+
filePath,
|
|
68
|
+
line: issue.Pos.Line,
|
|
69
|
+
column: issue.Pos.Column,
|
|
70
|
+
severity,
|
|
71
|
+
semantic: severity === "error" ? "blocking" : "warning",
|
|
72
|
+
tool: "golangci-lint",
|
|
73
|
+
rule: issue.FromLinter,
|
|
74
|
+
} satisfies Diagnostic;
|
|
75
|
+
});
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const golangciRunner: RunnerDefinition = {
|
|
82
|
+
id: "golangci-lint",
|
|
83
|
+
appliesTo: ["go"],
|
|
84
|
+
priority: 20,
|
|
85
|
+
enabledByDefault: true,
|
|
86
|
+
|
|
87
|
+
async run(ctx: DispatchContext): Promise<RunnerResult> {
|
|
88
|
+
const cwd = ctx.cwd || process.cwd();
|
|
89
|
+
|
|
90
|
+
// Only run if project has opted in via config file
|
|
91
|
+
if (!hasGolangciConfig(cwd)) {
|
|
92
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check availability
|
|
96
|
+
const versionCheck = await safeSpawnAsync("golangci-lint", ["version"], {
|
|
97
|
+
timeout: 10000,
|
|
98
|
+
cwd,
|
|
99
|
+
});
|
|
100
|
+
if (versionCheck.error || versionCheck.status !== 0) {
|
|
101
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Run on the specific file. golangci-lint accepts file paths directly.
|
|
105
|
+
const result = await safeSpawnAsync(
|
|
106
|
+
"golangci-lint",
|
|
107
|
+
["run", "--out-format=json", ctx.filePath],
|
|
108
|
+
{ timeout: 60000, cwd },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const raw = stripAnsi(result.stdout + result.stderr);
|
|
112
|
+
|
|
113
|
+
if (result.status === 0) {
|
|
114
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const diagnostics = parseGolangciJson(result.stdout, ctx.filePath);
|
|
118
|
+
|
|
119
|
+
if (diagnostics.length === 0) {
|
|
120
|
+
// Non-zero exit but no parseable issues — likely a config/tool error
|
|
121
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
|
|
125
|
+
return {
|
|
126
|
+
status: "failed",
|
|
127
|
+
diagnostics,
|
|
128
|
+
semantic: hasErrors ? "blocking" : "warning",
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export default golangciRunner;
|
|
@@ -7,11 +7,14 @@ import architectRunner from "./architect.js";
|
|
|
7
7
|
import astGrepNapiRunner from "./ast-grep-napi.js";
|
|
8
8
|
import biomeRunner from "./biome.js";
|
|
9
9
|
import configValidationRunner from "./config-validation.js";
|
|
10
|
+
import eslintRunner from "./eslint.js";
|
|
10
11
|
import goVetRunner from "./go-vet.js";
|
|
12
|
+
import golangciRunner from "./golangci-lint.js";
|
|
11
13
|
import lspRunner from "./lsp.js";
|
|
12
14
|
import oxlintRunner from "./oxlint.js";
|
|
13
15
|
import pyrightRunner from "./pyright.js";
|
|
14
16
|
import pythonSlopRunner from "./python-slop.js";
|
|
17
|
+
import rubocopRunner from "./rubocop.js";
|
|
15
18
|
import ruffRunner from "./ruff.js";
|
|
16
19
|
import rustClippyRunner from "./rust-clippy.js";
|
|
17
20
|
import shellcheckRunner from "./shellcheck.js";
|
|
@@ -44,6 +47,9 @@ registerRunner(shellcheckRunner); // Shell script linting (priority 20)
|
|
|
44
47
|
// CLI ast-grep kept for ast_grep_search/ast_grep_replace tools only
|
|
45
48
|
registerRunner(similarityRunner); // Semantic reuse detection (priority 35)
|
|
46
49
|
registerRunner(architectRunner); // Architectural rules (priority 40)
|
|
50
|
+
registerRunner(eslintRunner); // ESLint (priority 12, jsts, config-gated)
|
|
51
|
+
registerRunner(golangciRunner); // golangci-lint (priority 20, go, config-gated)
|
|
52
|
+
registerRunner(rubocopRunner); // RuboCop lint (priority 10, ruby)
|
|
47
53
|
registerRunner(spellcheckRunner); // Spellcheck for markdown/docs (priority 30)
|
|
48
54
|
registerRunner(goVetRunner); // Go analysis (priority 50)
|
|
49
55
|
registerRunner(rustClippyRunner); // Rust analysis (priority 50)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuboCop runner for dispatch system
|
|
3
|
+
*
|
|
4
|
+
* Runs rubocop in lint-only mode (no auto-correct) on Ruby files.
|
|
5
|
+
* Auto-correct is handled by the formatter pipeline — this runner
|
|
6
|
+
* only reports remaining offenses after formatting.
|
|
7
|
+
*
|
|
8
|
+
* Supports bundle exec (preferred in Bundler projects).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { safeSpawnAsync } from "../../safe-spawn.js";
|
|
14
|
+
import type {
|
|
15
|
+
Diagnostic,
|
|
16
|
+
DispatchContext,
|
|
17
|
+
RunnerDefinition,
|
|
18
|
+
RunnerResult,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
|
|
21
|
+
function findRubocop(cwd: string): { cmd: string; args: string[] } {
|
|
22
|
+
// Prefer bundle exec if Gemfile exists
|
|
23
|
+
const gemfile = path.join(cwd, "Gemfile");
|
|
24
|
+
if (fs.existsSync(gemfile)) {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(gemfile, "utf-8");
|
|
27
|
+
if (content.includes("rubocop")) {
|
|
28
|
+
return { cmd: "bundle", args: ["exec", "rubocop"] };
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return { cmd: "rubocop", args: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RubocopOffense {
|
|
36
|
+
severity: string;
|
|
37
|
+
message: string;
|
|
38
|
+
cop_name: string;
|
|
39
|
+
correctable: boolean;
|
|
40
|
+
location: {
|
|
41
|
+
line: number;
|
|
42
|
+
column: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RubocopFile {
|
|
47
|
+
path: string;
|
|
48
|
+
offenses: RubocopOffense[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RubocopOutput {
|
|
52
|
+
files: RubocopFile[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SEVERITY_MAP: Record<string, "error" | "warning" | "info"> = {
|
|
56
|
+
fatal: "error",
|
|
57
|
+
error: "error",
|
|
58
|
+
warning: "warning",
|
|
59
|
+
convention: "warning",
|
|
60
|
+
refactor: "info",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function parseRubocopJson(raw: string, filePath: string): Diagnostic[] {
|
|
64
|
+
try {
|
|
65
|
+
const output: RubocopOutput = JSON.parse(raw);
|
|
66
|
+
const diagnostics: Diagnostic[] = [];
|
|
67
|
+
|
|
68
|
+
for (const file of output.files) {
|
|
69
|
+
for (const offense of file.offenses) {
|
|
70
|
+
const severity = SEVERITY_MAP[offense.severity] ?? "warning";
|
|
71
|
+
diagnostics.push({
|
|
72
|
+
id: `rubocop:${offense.cop_name}:${offense.location.line}`,
|
|
73
|
+
message: `${offense.cop_name}: ${offense.message}`,
|
|
74
|
+
filePath,
|
|
75
|
+
line: offense.location.line,
|
|
76
|
+
column: offense.location.column,
|
|
77
|
+
severity,
|
|
78
|
+
semantic: severity === "error" ? "blocking" : "warning",
|
|
79
|
+
tool: "rubocop",
|
|
80
|
+
rule: offense.cop_name,
|
|
81
|
+
fixable: offense.correctable,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return diagnostics;
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rubocopRunner: RunnerDefinition = {
|
|
93
|
+
id: "rubocop",
|
|
94
|
+
appliesTo: ["ruby"],
|
|
95
|
+
priority: 10,
|
|
96
|
+
enabledByDefault: true,
|
|
97
|
+
|
|
98
|
+
async run(ctx: DispatchContext): Promise<RunnerResult> {
|
|
99
|
+
const cwd = ctx.cwd || process.cwd();
|
|
100
|
+
const { cmd, args } = findRubocop(cwd);
|
|
101
|
+
|
|
102
|
+
// Check availability
|
|
103
|
+
const versionCheck = await safeSpawnAsync(cmd, [...args, "--version"], {
|
|
104
|
+
timeout: 10000,
|
|
105
|
+
cwd,
|
|
106
|
+
});
|
|
107
|
+
if (versionCheck.error || versionCheck.status !== 0) {
|
|
108
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Lint only — no auto-correct (formatter handles that)
|
|
112
|
+
const result = await safeSpawnAsync(
|
|
113
|
+
cmd,
|
|
114
|
+
[...args, "--format", "json", "--no-color", ctx.filePath],
|
|
115
|
+
{ timeout: 30000, cwd },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// rubocop exits 0 = no offenses, 1 = offenses found, 2 = fatal error
|
|
119
|
+
if (result.status === 2) {
|
|
120
|
+
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result.status === 0) {
|
|
124
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const diagnostics = parseRubocopJson(result.stdout, ctx.filePath);
|
|
128
|
+
if (diagnostics.length === 0) {
|
|
129
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
|
|
133
|
+
return {
|
|
134
|
+
status: "failed",
|
|
135
|
+
diagnostics,
|
|
136
|
+
semantic: hasErrors ? "blocking" : "warning",
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default rubocopRunner;
|
package/clients/file-kinds.ts
CHANGED
|
@@ -20,7 +20,8 @@ export type FileKind =
|
|
|
20
20
|
| "json" // JSON (.json)
|
|
21
21
|
| "markdown" // Markdown (.md, .mdx)
|
|
22
22
|
| "css" // CSS (.css, .scss, .less)
|
|
23
|
-
| "yaml"
|
|
23
|
+
| "yaml" // YAML (.yaml, .yml)
|
|
24
|
+
| "ruby"; // Ruby (.rb, .rake, .gemspec, .ru)
|
|
24
25
|
|
|
25
26
|
// --- Extension Maps ---
|
|
26
27
|
|
|
@@ -50,6 +51,7 @@ const KIND_EXTENSIONS: Record<FileKind, readonly string[]> = {
|
|
|
50
51
|
markdown: [".md", ".mdx"],
|
|
51
52
|
css: [".css", ".scss", ".sass", ".less"],
|
|
52
53
|
yaml: [".yaml", ".yml"],
|
|
54
|
+
ruby: [".rb", ".rake", ".gemspec", ".ru"],
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
// Reverse map: extension → file kind (for fast lookup)
|
|
@@ -161,6 +163,7 @@ export function getFileKindLabel(kind: FileKind): string {
|
|
|
161
163
|
markdown: "Markdown",
|
|
162
164
|
css: "CSS",
|
|
163
165
|
yaml: "YAML",
|
|
166
|
+
ruby: "Ruby",
|
|
164
167
|
};
|
|
165
168
|
return labels[kind] ?? kind;
|
|
166
169
|
}
|
|
@@ -211,6 +214,7 @@ export function getLanguageId(kind: FileKind): string {
|
|
|
211
214
|
markdown: "markdown",
|
|
212
215
|
css: "css",
|
|
213
216
|
yaml: "yaml",
|
|
217
|
+
ruby: "ruby",
|
|
214
218
|
};
|
|
215
219
|
return languageIds[kind] ?? "plaintext";
|
|
216
220
|
}
|