pi-lens 2.1.0 → 2.2.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +70 -1
  3. package/clients/ast-grep-client.js +12 -12
  4. package/clients/ast-grep-client.ts +21 -11
  5. package/clients/dispatch/dispatcher.js +2 -2
  6. package/clients/dispatch/dispatcher.ts +2 -2
  7. package/clients/dispatch/runners/index.js +3 -1
  8. package/clients/dispatch/runners/index.ts +3 -1
  9. package/clients/dispatch/runners/pyright.js +68 -0
  10. package/clients/dispatch/runners/pyright.test.js +84 -0
  11. package/clients/dispatch/runners/pyright.test.ts +109 -0
  12. package/clients/dispatch/runners/pyright.ts +102 -0
  13. package/clients/dispatch/runners/secrets.js +109 -0
  14. package/clients/secrets-scanner.js +113 -0
  15. package/clients/secrets-scanner.test.js +100 -0
  16. package/clients/secrets-scanner.test.ts +113 -0
  17. package/clients/secrets-scanner.ts +134 -0
  18. package/clients/sg-runner.js +15 -2
  19. package/clients/sg-runner.ts +25 -2
  20. package/commands/fix.js +48 -50
  21. package/commands/fix.ts +71 -61
  22. package/commands/rate.js +285 -0
  23. package/commands/rate.test.js +119 -0
  24. package/commands/rate.test.ts +131 -0
  25. package/commands/rate.ts +348 -0
  26. package/commands/refactor.js +33 -9
  27. package/commands/refactor.ts +44 -11
  28. package/default-architect.yaml +7 -0
  29. package/index.ts +58 -10
  30. package/package.json +1 -1
  31. package/rules/ast-grep-rules/rules/no-default-export.yml +19 -0
  32. package/rules/ast-grep-rules/rules/no-hardcoded-secrets.yml +9 -6
  33. package/rules/ast-grep-rules/rules/no-process-env.yml +12 -12
  34. package/rules/ast-grep-rules/rules/no-relative-imports.yml +21 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [2.1.1] - 2026-03-29
6
+
7
+ ### Added
8
+ - **Content-level secret scanning**: Catches secrets in ANY file type on write/edit (`.env`, `.yaml`, `.json`, not just TypeScript). Blocks before save with patterns for `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
9
+ - **Project rules integration**: Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start and surfaces in system prompt.
10
+ - **Grep-ability rules**: New ast-grep rules for `no-default-export` and `no-relative-cross-package-import` to improve agent searchability.
11
+
12
+ ### Changed
13
+ - **Inline feedback stripped to blocking only**: Warnings no longer shown inline (noise). Only blocking violations and test failures interrupt the agent.
14
+ - **booboo-fix output compacted**: Summary in terminal, full plan in `.pi-lens/reports/fix-plan.tsv`.
15
+ - **booboo-refactor output compacted**: Top 5 worst offenders in terminal, full ranked list in `.pi-lens/reports/refactor-ranked.tsv`.
16
+ - **`ast_grep_search` new params**: Added `selector` (extract specific AST node) and `context` (show surrounding lines).
17
+ - **`ast_grep_replace` mode indicator**: Shows `[DRY-RUN]` or `[APPLIED]` prefix.
18
+ - **no-hardcoded-secrets**: Fixed to only flag actual hardcoded strings (not `process.env` assignments).
19
+ - **no-process-env**: Now only flags secret-related env vars (not PORT, NODE_ENV, etc.).
20
+ - **Removed Factory AI article reference** from architect.yaml.
21
+
5
22
  ## [2.0.40] - 2026-03-27
6
23
 
7
24
  ### Changed
@@ -281,6 +298,16 @@ All notable changes to pi-lens will be documented in this file.
281
298
  ### Changed
282
299
  - **Improved ast-grep tool descriptions**: Better pattern guidance to prevent overly broad searches.
283
300
 
301
+ ## [2.2.0] - 2026-03-29
302
+
303
+ ### Added
304
+ - **`/lens-rate` command**: Visual code quality scoring across 6 dimensions (Type Safety, Complexity, Security, Architecture, Dead Code, Tests). Shows grade A-F and colored progress bars.
305
+ - **Pyright runner**: Real Python type-checking via pyright. Catches type errors like `result: str = add(1, 2)` that ruff misses. Runs alongside ruff (pyright for types, ruff for linting).
306
+ - **Vitest config**: Increased test timeout to 15s for CLI spawn tests. Fixes flaky test failures when npx downloads packages.
307
+
308
+ ### Fixed
309
+ - **Test flakiness**: Availability tests (biome, knip, jscpd) no longer timeout when npx is downloading packages.
310
+
284
311
  ## [1.3.0] - 2026-03-23
285
312
 
286
313
  ### Changed
package/README.md CHANGED
@@ -16,6 +16,74 @@ pi install git:github.com/apmantza/pi-lens
16
16
 
17
17
  ---
18
18
 
19
+ ## What's New (v2.2)
20
+
21
+ ### `/lens-rate` — Code Quality Scoring
22
+
23
+ Visual scoring breakdown across 6 dimensions with grade A-F:
24
+
25
+ ```
26
+ ┌─────────────────────────────────────────────────────────┐
27
+ │ 📊 CODE QUALITY SCORE: 85/100 (B) │
28
+ ├─────────────────────────────────────────────────────────┤
29
+ │ 🔷 Type Safety 🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜ 85 │
30
+ │ 🧩 Complexity 🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜ 82 │
31
+ │ 🔒 Security 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 100 │
32
+ │ 🏗️ Architecture 🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜ 80 │
33
+ │ 🗑️ Dead Code 🟩🟩🟩🟩🟩🟩🟩🟩🟩⬜ 90 │
34
+ │ ✅ Tests 🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩 100 │
35
+ └─────────────────────────────────────────────────────────┘
36
+ ```
37
+
38
+ Scores are calculated from real scan data: type-coverage, complexity metrics, secret detection, architect rules, knip, and test results.
39
+
40
+ ### Python Type-Checking (Pyright)
41
+
42
+ Python files now get **real type-checking** via pyright, not just linting:
43
+
44
+ ```python
45
+ # ruff won't catch this, but pyright will:
46
+ def add(x: int, y: int) -> int:
47
+ return x + y
48
+
49
+ result: str = add(1, 2) # ❌ pyright: Type "int" not assignable to "str"
50
+ ```
51
+
52
+ Pyright runs alongside ruff — pyright catches type errors, ruff catches style issues.
53
+
54
+ ---
55
+
56
+ ## What's New (v2.1)
57
+
58
+ ### Content-Level Secret Scanning
59
+
60
+ Secrets are now blocked **before** they're saved, on **all file types**:
61
+
62
+ ```
63
+ 🔴 STOP — 1 potential secret(s) in src/config.ts:
64
+ L12: Possible Stripe or OpenAI API key (sk-*)
65
+ → Remove before continuing. Use env vars instead.
66
+ ```
67
+
68
+ Works on `.env`, `.yaml`, `.json`, `.md` — not just TypeScript. Catches `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
69
+
70
+ ### Compact Output
71
+
72
+ Inline feedback stripped to **blocking only** — no more warning noise:
73
+
74
+ ```
75
+ 🔴 STOP — 2 issue(s) must fixed:
76
+ L23: var total = sum(items); — use 'let' or 'const'
77
+ ```
78
+
79
+ Warnings are tracked and surfaced via `/lens-booboo`. booboo-fix and booboo-refactor output compacted to summaries with TSV files for full details.
80
+
81
+ ### Project Rules Integration
82
+
83
+ Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start. Project-specific rules are surfaced in the system prompt — the agent knows to read them when relevant. Works alongside pi-lens architect rules.
84
+
85
+ ---
86
+
19
87
  ## What's New (v2.0)
20
88
 
21
89
  ### Declarative Dispatch System
@@ -184,7 +252,7 @@ These files provide **general project guidance** (coding conventions, workflow r
184
252
 
185
253
  | Tool | Description |
186
254
  |---|---|
187
- | **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Example: `console.log($MSG)` |
255
+ | **`ast_grep_search`** | Search code patterns using AST-aware matching. Supports meta-variables: `$VAR` (single node), `$$$` (multiple). Optional: `selector` (extract specific AST node), `context` (show surrounding lines). Example: `console.log($MSG)` |
188
256
  | **`ast_grep_replace`** | Replace code patterns with AST-aware rewriting. Dry-run by default, use `apply=true` to apply changes. Example: `pattern='console.log($MSG)' rewrite='logger.info($MSG)'` |
189
257
 
190
258
  Supported languages: c, cpp, csharp, css, dart, elixir, go, haskell, html, java, javascript, json, kotlin, lua, php, python, ruby, rust, scala, sql, swift, tsx, typescript, yaml
@@ -327,6 +395,7 @@ Each rule includes a `message` and `note` that are shown in diagnostics, so the
327
395
  | `type-coverage` | `npm i -D type-coverage` | TypeScript `any` coverage percentage |
328
396
  | `madge` | `npm i -D madge` | Circular dependency detection |
329
397
  | `ruff` | `pip install ruff` | Python lint + format + autofix |
398
+ | `pyright` | `npx pyright` (auto-installed) | Python type-checking |
330
399
 
331
400
  ---
332
401
 
@@ -45,16 +45,16 @@ export class AstGrepClient {
45
45
  /**
46
46
  * Search for AST patterns in files
47
47
  */
48
- async search(pattern, lang, paths) {
49
- return this.runner.exec([
50
- "run",
51
- "-p",
52
- pattern,
53
- "--lang",
54
- lang,
55
- "--json=compact",
56
- ...paths,
57
- ]);
48
+ async search(pattern, lang, paths, options) {
49
+ const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
50
+ if (options?.selector) {
51
+ args.push("--selector", options.selector);
52
+ }
53
+ if (options?.context !== undefined) {
54
+ args.push("--context", String(options.context));
55
+ }
56
+ args.push(...paths);
57
+ return this.runner.exec(args);
58
58
  }
59
59
  /**
60
60
  * Search and replace AST patterns
@@ -165,8 +165,8 @@ message: found
165
165
  }
166
166
  return exports;
167
167
  }
168
- formatMatches(matches, isDryRun = false) {
169
- return this.runner.formatMatches(matches, isDryRun);
168
+ formatMatches(matches, isDryRun = false, showModeIndicator = false) {
169
+ return this.runner.formatMatches(matches, isDryRun, 50, showModeIndicator);
170
170
  }
171
171
  /**
172
172
  * Scan a file against all rules
@@ -92,16 +92,17 @@ export class AstGrepClient {
92
92
  pattern: string,
93
93
  lang: string,
94
94
  paths: string[],
95
+ options?: { selector?: string; context?: number },
95
96
  ): Promise<{ matches: AstGrepMatch[]; error?: string }> {
96
- return this.runner.exec([
97
- "run",
98
- "-p",
99
- pattern,
100
- "--lang",
101
- lang,
102
- "--json=compact",
103
- ...paths,
104
- ]);
97
+ const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
98
+ if (options?.selector) {
99
+ args.push("--selector", options.selector);
100
+ }
101
+ if (options?.context !== undefined) {
102
+ args.push("--context", String(options.context));
103
+ }
104
+ args.push(...paths);
105
+ return this.runner.exec(args);
105
106
  }
106
107
 
107
108
  /**
@@ -257,8 +258,17 @@ message: found
257
258
  return exports;
258
259
  }
259
260
 
260
- formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
261
- return this.runner.formatMatches(matches as SgMatch[], isDryRun);
261
+ formatMatches(
262
+ matches: AstGrepMatch[],
263
+ isDryRun = false,
264
+ showModeIndicator = false,
265
+ ): string {
266
+ return this.runner.formatMatches(
267
+ matches as SgMatch[],
268
+ isDryRun,
269
+ 50,
270
+ showModeIndicator,
271
+ );
262
272
  }
263
273
 
264
274
  /**
@@ -191,9 +191,9 @@ export async function dispatchForFile(ctx, groups) {
191
191
  const blockers = allDiagnostics.filter((d) => d.semantic === "blocking");
192
192
  const warnings = allDiagnostics.filter((d) => d.semantic === "warning" || d.semantic === "none");
193
193
  const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
194
- // Format output
194
+ // Format output — only blocking issues shown inline
195
+ // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
195
196
  let output = formatDiagnostics(blockers, "blocking");
196
- output += formatDiagnostics(warnings, "warning");
197
197
  output += formatDiagnostics(fixedItems, "fixed");
198
198
  return {
199
199
  diagnostics: allDiagnostics,
@@ -266,9 +266,9 @@ export async function dispatchForFile(
266
266
  );
267
267
  const fixedItems = allDiagnostics.filter((d) => d.semantic === "fixed");
268
268
 
269
- // Format output
269
+ // Format output — only blocking issues shown inline
270
+ // Warnings tracked but not shown (noise) — surfaced via /lens-booboo
270
271
  let output = formatDiagnostics(blockers, "blocking");
271
- output += formatDiagnostics(warnings, "warning");
272
272
  output += formatDiagnostics(fixedItems, "fixed");
273
273
 
274
274
  return {
@@ -7,12 +7,14 @@ import architectRunner from "./architect.js";
7
7
  import astGrepRunner from "./ast-grep.js";
8
8
  import biomeRunner from "./biome.js";
9
9
  import goVetRunner from "./go-vet.js";
10
+ import pyrightRunner from "./pyright.js";
10
11
  import ruffRunner from "./ruff.js";
11
12
  import rustClippyRunner from "./rust-clippy.js";
12
13
  import tsLspRunner from "./ts-lsp.js";
13
14
  import typeSafetyRunner from "./type-safety.js";
14
15
  // Register all runners (ordered by priority)
15
- registerRunner(tsLspRunner);
16
+ registerRunner(tsLspRunner); // TypeScript type-checking
17
+ registerRunner(pyrightRunner); // Python type-checking
16
18
  registerRunner(biomeRunner);
17
19
  registerRunner(ruffRunner);
18
20
  registerRunner(typeSafetyRunner);
@@ -8,13 +8,15 @@ import architectRunner from "./architect.js";
8
8
  import astGrepRunner from "./ast-grep.js";
9
9
  import biomeRunner from "./biome.js";
10
10
  import goVetRunner from "./go-vet.js";
11
+ import pyrightRunner from "./pyright.js";
11
12
  import ruffRunner from "./ruff.js";
12
13
  import rustClippyRunner from "./rust-clippy.js";
13
14
  import tsLspRunner from "./ts-lsp.js";
14
15
  import typeSafetyRunner from "./type-safety.js";
15
16
 
16
17
  // Register all runners (ordered by priority)
17
- registerRunner(tsLspRunner);
18
+ registerRunner(tsLspRunner); // TypeScript type-checking
19
+ registerRunner(pyrightRunner); // Python type-checking
18
20
  registerRunner(biomeRunner);
19
21
  registerRunner(ruffRunner);
20
22
  registerRunner(typeSafetyRunner);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pyright runner for dispatch system
3
+ *
4
+ * Provides real Python type-checking (not just linting).
5
+ * Catches type errors like: result: str = add(1, 2) # Type "int" not assignable to "str"
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ const pyrightRunner = {
9
+ id: "pyright",
10
+ appliesTo: ["python"],
11
+ priority: 5, // Higher priority than ruff (10) - type errors are more important
12
+ enabledByDefault: true,
13
+ async run(ctx) {
14
+ // Run pyright with JSON output
15
+ const result = spawnSync("npx", ["pyright", "--outputjson", ctx.filePath], {
16
+ encoding: "utf-8",
17
+ timeout: 60000, // Pyright can be slower on first run
18
+ shell: true,
19
+ });
20
+ // Pyright returns non-zero when errors found, that's OK
21
+ if (result.error) {
22
+ return { status: "skipped", diagnostics: [], semantic: "none" };
23
+ }
24
+ const output = (result.stdout || "").trim();
25
+ if (!output) {
26
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
27
+ }
28
+ try {
29
+ const data = JSON.parse(output);
30
+ const diagnostics = parsePyrightOutput(data, ctx.filePath);
31
+ if (diagnostics.length === 0) {
32
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
33
+ }
34
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
35
+ return {
36
+ status: hasErrors ? "failed" : "succeeded",
37
+ diagnostics,
38
+ semantic: "warning",
39
+ };
40
+ }
41
+ catch {
42
+ // JSON parse failed, skip
43
+ return { status: "skipped", diagnostics: [], semantic: "none" };
44
+ }
45
+ },
46
+ };
47
+ function parsePyrightOutput(data, filePath) {
48
+ if (!data.generalDiagnostics)
49
+ return [];
50
+ return data.generalDiagnostics
51
+ .filter((d) => {
52
+ // Only include errors and warnings, skip informational
53
+ return d.severity === "error" || d.severity === "warning";
54
+ })
55
+ .map((d) => ({
56
+ id: `pyright-${d.range.start.line}-${d.rule}`,
57
+ message: d.message.split("\n")[0], // First line only (pyright has multi-line messages)
58
+ filePath,
59
+ line: d.range.start.line + 1, // Pyright is 0-indexed, we're 1-indexed
60
+ column: d.range.start.character + 1,
61
+ severity: d.severity === "error" ? "error" : "warning",
62
+ semantic: d.severity === "error" ? "blocking" : "warning",
63
+ tool: "pyright",
64
+ rule: d.rule,
65
+ fixable: false, // Pyright can't auto-fix, only suggest
66
+ }));
67
+ }
68
+ export default pyrightRunner;
@@ -0,0 +1,84 @@
1
+ import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ function createMockContext(filePath) {
6
+ return {
7
+ filePath,
8
+ cwd: process.cwd(),
9
+ kind: "python",
10
+ autofix: false,
11
+ deltaMode: false,
12
+ baselines: { get: () => [], add: () => { }, save: () => { } },
13
+ pi: {},
14
+ hasTool: async () => false,
15
+ log: () => { },
16
+ };
17
+ }
18
+ describe("pyright runner", () => {
19
+ const require = createRequire(import.meta.url);
20
+ it("should have correct runner definition", async () => {
21
+ const pyrightModule = await import("./pyright.js");
22
+ const runner = pyrightModule.default;
23
+ expect(runner.id).toBe("pyright");
24
+ expect(runner.appliesTo).toEqual(["python"]);
25
+ expect(runner.priority).toBe(5); // Higher priority than ruff
26
+ expect(runner.enabledByDefault).toBe(true);
27
+ });
28
+ it("should detect pyright availability", () => {
29
+ const { spawnSync } = require("node:child_process");
30
+ const result = spawnSync("npx", ["pyright", "--version"], {
31
+ encoding: "utf-8",
32
+ timeout: 10000,
33
+ shell: true,
34
+ });
35
+ expect(result.error || result.status !== 0 ? "not available" : "available").toBe("available");
36
+ });
37
+ it("should type-check Python files and find errors", async () => {
38
+ const tmpFile = path.join(process.env.TEMP || "/tmp", `pyright_test_${Date.now()}.py`);
39
+ fs.writeFileSync(tmpFile, `def add(x: int, y: int) -> int:
40
+ return x + y
41
+
42
+ result: str = add(1, 2)
43
+
44
+ def greet(name: str) -> str:
45
+ return "Hello " + name
46
+
47
+ greet(123)
48
+ `);
49
+ try {
50
+ const pyrightModule = await import("./pyright.js");
51
+ const runner = pyrightModule.default;
52
+ const result = await runner.run(createMockContext(tmpFile));
53
+ expect(result.diagnostics.length).toBeGreaterThanOrEqual(2);
54
+ expect(result.diagnostics.some((d) => d.tool === "pyright")).toBe(true);
55
+ expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true);
56
+ }
57
+ finally {
58
+ fs.unlinkSync(tmpFile);
59
+ }
60
+ });
61
+ it("should pass valid Python files", async () => {
62
+ const tmpFile = path.join(process.env.TEMP || "/tmp", `pyright_test_ok_${Date.now()}.py`);
63
+ fs.writeFileSync(tmpFile, `def add(x: int, y: int) -> int:
64
+ return x + y
65
+
66
+ result: str = str(add(1, 2))
67
+
68
+ def greet(name: str) -> str:
69
+ return "Hello " + name
70
+
71
+ greet("world")
72
+ `);
73
+ try {
74
+ const pyrightModule = await import("./pyright.js");
75
+ const runner = pyrightModule.default;
76
+ const result = await runner.run(createMockContext(tmpFile));
77
+ expect(result.status).toBe("succeeded");
78
+ expect(result.diagnostics.length).toBe(0);
79
+ }
80
+ finally {
81
+ fs.unlinkSync(tmpFile);
82
+ }
83
+ });
84
+ });
@@ -0,0 +1,109 @@
1
+ import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { DispatchContext } from "../types.js";
6
+
7
+ function createMockContext(filePath: string): DispatchContext {
8
+ return {
9
+ filePath,
10
+ cwd: process.cwd(),
11
+ kind: "python" as any,
12
+ autofix: false,
13
+ deltaMode: false,
14
+ baselines: { get: () => [], add: () => {}, save: () => {} } as any,
15
+ pi: {} as any,
16
+ hasTool: async () => false,
17
+ log: () => {},
18
+ };
19
+ }
20
+
21
+ describe("pyright runner", () => {
22
+ const require = createRequire(import.meta.url);
23
+
24
+ it("should have correct runner definition", async () => {
25
+ const pyrightModule = await import("./pyright.js");
26
+ const runner = pyrightModule.default;
27
+
28
+ expect(runner.id).toBe("pyright");
29
+ expect(runner.appliesTo).toEqual(["python"]);
30
+ expect(runner.priority).toBe(5); // Higher priority than ruff
31
+ expect(runner.enabledByDefault).toBe(true);
32
+ });
33
+
34
+ it("should detect pyright availability", () => {
35
+ const { spawnSync } =
36
+ require("node:child_process") as typeof import("node:child_process");
37
+ const result = spawnSync("npx", ["pyright", "--version"], {
38
+ encoding: "utf-8",
39
+ timeout: 10000,
40
+ shell: true,
41
+ });
42
+ expect(
43
+ result.error || result.status !== 0 ? "not available" : "available",
44
+ ).toBe("available");
45
+ });
46
+
47
+ it("should type-check Python files and find errors", async () => {
48
+ const tmpFile = path.join(
49
+ process.env.TEMP || "/tmp",
50
+ `pyright_test_${Date.now()}.py`,
51
+ );
52
+ fs.writeFileSync(
53
+ tmpFile,
54
+ `def add(x: int, y: int) -> int:
55
+ return x + y
56
+
57
+ result: str = add(1, 2)
58
+
59
+ def greet(name: str) -> str:
60
+ return "Hello " + name
61
+
62
+ greet(123)
63
+ `,
64
+ );
65
+
66
+ try {
67
+ const pyrightModule = await import("./pyright.js");
68
+ const runner = pyrightModule.default;
69
+ const result = await runner.run(createMockContext(tmpFile));
70
+
71
+ expect(result.diagnostics.length).toBeGreaterThanOrEqual(2);
72
+ expect(result.diagnostics.some((d) => d.tool === "pyright")).toBe(true);
73
+ expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true);
74
+ } finally {
75
+ fs.unlinkSync(tmpFile);
76
+ }
77
+ });
78
+
79
+ it("should pass valid Python files", async () => {
80
+ const tmpFile = path.join(
81
+ process.env.TEMP || "/tmp",
82
+ `pyright_test_ok_${Date.now()}.py`,
83
+ );
84
+ fs.writeFileSync(
85
+ tmpFile,
86
+ `def add(x: int, y: int) -> int:
87
+ return x + y
88
+
89
+ result: str = str(add(1, 2))
90
+
91
+ def greet(name: str) -> str:
92
+ return "Hello " + name
93
+
94
+ greet("world")
95
+ `,
96
+ );
97
+
98
+ try {
99
+ const pyrightModule = await import("./pyright.js");
100
+ const runner = pyrightModule.default;
101
+ const result = await runner.run(createMockContext(tmpFile));
102
+
103
+ expect(result.status).toBe("succeeded");
104
+ expect(result.diagnostics.length).toBe(0);
105
+ } finally {
106
+ fs.unlinkSync(tmpFile);
107
+ }
108
+ });
109
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Pyright runner for dispatch system
3
+ *
4
+ * Provides real Python type-checking (not just linting).
5
+ * Catches type errors like: result: str = add(1, 2) # Type "int" not assignable to "str"
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import type {
10
+ Diagnostic,
11
+ DispatchContext,
12
+ RunnerDefinition,
13
+ RunnerResult,
14
+ } from "../types.js";
15
+
16
+ const pyrightRunner: RunnerDefinition = {
17
+ id: "pyright",
18
+ appliesTo: ["python"],
19
+ priority: 5, // Higher priority than ruff (10) - type errors are more important
20
+ enabledByDefault: true,
21
+
22
+ async run(ctx: DispatchContext): Promise<RunnerResult> {
23
+ // Run pyright with JSON output
24
+ const result = spawnSync("npx", ["pyright", "--outputjson", ctx.filePath], {
25
+ encoding: "utf-8",
26
+ timeout: 60000, // Pyright can be slower on first run
27
+ shell: true,
28
+ });
29
+
30
+ // Pyright returns non-zero when errors found, that's OK
31
+ if (result.error) {
32
+ return { status: "skipped", diagnostics: [], semantic: "none" };
33
+ }
34
+
35
+ const output = (result.stdout || "").trim();
36
+ if (!output) {
37
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
38
+ }
39
+
40
+ try {
41
+ const data = JSON.parse(output);
42
+ const diagnostics = parsePyrightOutput(data, ctx.filePath);
43
+
44
+ if (diagnostics.length === 0) {
45
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
46
+ }
47
+
48
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
49
+
50
+ return {
51
+ status: hasErrors ? "failed" : "succeeded",
52
+ diagnostics,
53
+ semantic: "warning",
54
+ };
55
+ } catch {
56
+ // JSON parse failed, skip
57
+ return { status: "skipped", diagnostics: [], semantic: "none" };
58
+ }
59
+ },
60
+ };
61
+
62
+ interface PyrightDiagnostic {
63
+ file: string;
64
+ severity: "error" | "warning" | "information";
65
+ message: string;
66
+ range: {
67
+ start: { line: number; character: number };
68
+ end: { line: number; character: number };
69
+ };
70
+ rule: string;
71
+ }
72
+
73
+ interface PyrightResult {
74
+ generalDiagnostics: PyrightDiagnostic[];
75
+ }
76
+
77
+ function parsePyrightOutput(
78
+ data: PyrightResult,
79
+ filePath: string,
80
+ ): Diagnostic[] {
81
+ if (!data.generalDiagnostics) return [];
82
+
83
+ return data.generalDiagnostics
84
+ .filter((d) => {
85
+ // Only include errors and warnings, skip informational
86
+ return d.severity === "error" || d.severity === "warning";
87
+ })
88
+ .map((d) => ({
89
+ id: `pyright-${d.range.start.line}-${d.rule}`,
90
+ message: d.message.split("\n")[0], // First line only (pyright has multi-line messages)
91
+ filePath,
92
+ line: d.range.start.line + 1, // Pyright is 0-indexed, we're 1-indexed
93
+ column: d.range.start.character + 1,
94
+ severity: d.severity === "error" ? "error" : "warning",
95
+ semantic: d.severity === "error" ? "blocking" : "warning",
96
+ tool: "pyright",
97
+ rule: d.rule,
98
+ fixable: false, // Pyright can't auto-fix, only suggest
99
+ }));
100
+ }
101
+
102
+ export default pyrightRunner;