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 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
@@ -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 (warning only, but low cost)
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;
@@ -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"; // YAML (.yaml, .yml)
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {