pi-lens 3.7.1 → 3.8.2

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 (135) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +706 -619
  3. package/clients/architect-client.ts +7 -2
  4. package/clients/ast-grep-client.ts +7 -1
  5. package/clients/dispatch/plan.ts +10 -4
  6. package/clients/dispatch/runners/architect.ts +20 -7
  7. package/clients/dispatch/runners/ast-grep-napi.ts +5 -2
  8. package/clients/dispatch/runners/ast-grep.ts +29 -18
  9. package/clients/dispatch/runners/biome.ts +4 -4
  10. package/clients/dispatch/runners/python-slop.ts +17 -7
  11. package/clients/dispatch/runners/ruff.ts +4 -4
  12. package/clients/dispatch/runners/tree-sitter.ts +30 -19
  13. package/clients/dispatch/runners/ts-slop.ts +17 -7
  14. package/clients/dispatch/runners/utils/runner-helpers.ts +76 -8
  15. package/clients/dispatch/utils/format-utils.ts +2 -1
  16. package/clients/fix-scanners.ts +8 -8
  17. package/clients/installer/index.ts +19 -1
  18. package/clients/lsp/index.ts +0 -40
  19. package/clients/lsp/launch.ts +5 -2
  20. package/clients/package-root.ts +44 -0
  21. package/clients/pipeline.ts +179 -8
  22. package/clients/scan-utils.ts +20 -32
  23. package/clients/sg-runner.ts +7 -5
  24. package/clients/source-filter.ts +222 -0
  25. package/clients/startup-scan.ts +142 -0
  26. package/clients/todo-scanner.ts +44 -55
  27. package/clients/tree-sitter-cache.ts +315 -0
  28. package/clients/tree-sitter-client.ts +208 -52
  29. package/clients/tree-sitter-fixer.ts +217 -0
  30. package/clients/tree-sitter-navigator.ts +329 -0
  31. package/clients/tree-sitter-query-loader.ts +55 -32
  32. package/commands/booboo.ts +47 -35
  33. package/default-architect.yaml +76 -87
  34. package/docs/ARCHITECTURE.md +74 -0
  35. package/docs/AST_GREP_RULES.md +266 -0
  36. package/docs/COMPLEXITY_METRICS.md +120 -0
  37. package/docs/EXCLUSIONS.md +83 -0
  38. package/docs/LSP_CONFIG.md +240 -0
  39. package/docs/TREE_SITTER_RULES.md +340 -0
  40. package/docs/WRITING_NEW_AST_GREP_RULES.md +200 -0
  41. package/index.ts +209 -86
  42. package/package.json +13 -4
  43. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +33 -0
  44. package/rules/ast-grep-rules/rules/array-callback-return.yml +1 -1
  45. package/rules/ast-grep-rules/rules/constructor-super-js.yml +22 -0
  46. package/rules/ast-grep-rules/rules/empty-catch-js.yml +45 -0
  47. package/rules/ast-grep-rules/rules/empty-catch.yml +1 -1
  48. package/rules/ast-grep-rules/rules/getter-return-js.yml +59 -0
  49. package/rules/ast-grep-rules/rules/getter-return.yml +1 -1
  50. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +12 -0
  51. package/rules/ast-grep-rules/rules/jsx-boolean-short-circuit.yml +1 -1
  52. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +14 -0
  53. package/rules/ast-grep-rules/rules/missed-concurrency-js.yml +25 -0
  54. package/rules/ast-grep-rules/rules/nested-ternary-js.yml +10 -0
  55. package/rules/ast-grep-rules/rules/no-alert-js.yml +6 -0
  56. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +21 -18
  57. package/rules/ast-grep-rules/rules/no-array-constructor-js.yml +10 -0
  58. package/rules/ast-grep-rules/rules/no-async-promise-executor-js.yml +15 -0
  59. package/rules/ast-grep-rules/rules/no-async-promise-executor.yml +1 -1
  60. package/rules/ast-grep-rules/rules/no-await-in-loop-js.yml +30 -0
  61. package/rules/ast-grep-rules/rules/no-await-in-promise-all-js.yml +20 -0
  62. package/rules/ast-grep-rules/rules/no-await-in-promise-all.yml +1 -1
  63. package/rules/ast-grep-rules/rules/no-bare-except.yml +1 -1
  64. package/rules/ast-grep-rules/rules/no-case-declarations-js.yml +16 -0
  65. package/rules/ast-grep-rules/rules/no-compare-neg-zero-js.yml +13 -0
  66. package/rules/ast-grep-rules/rules/no-compare-neg-zero.yml +1 -1
  67. package/rules/ast-grep-rules/rules/no-comparison-to-none.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-cond-assign-js.yml +36 -0
  69. package/rules/ast-grep-rules/rules/no-cond-assign.yml +1 -1
  70. package/rules/ast-grep-rules/rules/no-constant-condition-js.yml +25 -0
  71. package/rules/ast-grep-rules/rules/no-constant-condition.yml +1 -1
  72. package/rules/ast-grep-rules/rules/no-constructor-return-js.yml +28 -0
  73. package/rules/ast-grep-rules/rules/no-constructor-return.yml +1 -1
  74. package/rules/ast-grep-rules/rules/no-discarded-error-js.yml +25 -0
  75. package/rules/ast-grep-rules/rules/no-discarded-error.yml +25 -0
  76. package/rules/ast-grep-rules/rules/no-dupe-args-js.yml +15 -0
  77. package/rules/ast-grep-rules/rules/no-dupe-keys-js.yml +73 -0
  78. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +25 -0
  79. package/rules/ast-grep-rules/rules/no-hardcoded-secrets-js.yml +17 -0
  80. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +15 -0
  81. package/rules/ast-grep-rules/rules/no-inner-html-js.yml +13 -0
  82. package/rules/ast-grep-rules/rules/no-insecure-randomness-js.yml +20 -0
  83. package/rules/ast-grep-rules/rules/no-insecure-randomness.yml +1 -1
  84. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +11 -0
  85. package/rules/ast-grep-rules/rules/no-nan-comparison-js.yml +22 -0
  86. package/rules/ast-grep-rules/rules/no-nan-comparison.yml +22 -0
  87. package/rules/ast-grep-rules/rules/no-new-symbol-js.yml +8 -0
  88. package/rules/ast-grep-rules/rules/no-new-wrappers-js.yml +13 -0
  89. package/rules/ast-grep-rules/rules/no-open-redirect-js.yml +15 -0
  90. package/rules/ast-grep-rules/rules/no-prototype-builtins-js.yml +15 -0
  91. package/rules/ast-grep-rules/rules/no-prototype-builtins.yml +1 -1
  92. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +13 -0
  93. package/rules/ast-grep-rules/rules/no-sql-in-code.yml +1 -1
  94. package/rules/ast-grep-rules/rules/no-throw-string-js.yml +12 -0
  95. package/rules/ast-grep-rules/rules/no-throw-string.yml +1 -1
  96. package/rules/ast-grep-rules/rules/strict-equality-js.yml +10 -0
  97. package/rules/ast-grep-rules/rules/strict-inequality-js.yml +10 -0
  98. package/rules/ast-grep-rules/rules/toctou-js.yml +112 -0
  99. package/rules/ast-grep-rules/rules/toctou.yml +1 -1
  100. package/rules/ast-grep-rules/rules/unchecked-sync-fs-js.yml +44 -0
  101. package/rules/ast-grep-rules/rules/unchecked-sync-fs.yml +44 -0
  102. package/rules/ast-grep-rules/rules/unchecked-throwing-call-js.yml +31 -0
  103. package/rules/ast-grep-rules/rules/unchecked-throwing-call-python.yml +48 -0
  104. package/rules/ast-grep-rules/rules/unchecked-throwing-call-ruby.yml +47 -0
  105. package/rules/ast-grep-rules/rules/unchecked-throwing-call.yml +31 -0
  106. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +15 -0
  107. package/rules/tree-sitter-queries/go/go-bare-error.yml +47 -0
  108. package/rules/tree-sitter-queries/go/go-defer-in-loop.yml +47 -0
  109. package/rules/tree-sitter-queries/go/go-hardcoded-secrets.yml +54 -0
  110. package/rules/tree-sitter-queries/python/is-vs-equals.yml +1 -1
  111. package/rules/tree-sitter-queries/python/python-debugger.yml +46 -0
  112. package/rules/tree-sitter-queries/python/python-empty-except.yml +48 -0
  113. package/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml +44 -0
  114. package/rules/tree-sitter-queries/python/python-mutable-class-attr.yml +57 -0
  115. package/rules/tree-sitter-queries/python/python-print-statement.yml +53 -0
  116. package/rules/tree-sitter-queries/python/python-raise-string.yml +38 -0
  117. package/rules/tree-sitter-queries/python/python-unsafe-regex.yml +58 -0
  118. package/rules/tree-sitter-queries/ruby/ruby-debugger.yml +44 -0
  119. package/rules/tree-sitter-queries/ruby/ruby-empty-rescue.yml +47 -0
  120. package/rules/tree-sitter-queries/ruby/ruby-eval.yml +43 -0
  121. package/rules/tree-sitter-queries/ruby/ruby-hardcoded-secrets.yml +40 -0
  122. package/rules/tree-sitter-queries/ruby/ruby-open-struct.yml +48 -0
  123. package/rules/tree-sitter-queries/ruby/ruby-puts-statement.yml +52 -0
  124. package/rules/tree-sitter-queries/ruby/ruby-rescue-exception.yml +51 -0
  125. package/rules/tree-sitter-queries/ruby/ruby-unsafe-regex.yml +49 -0
  126. package/rules/tree-sitter-queries/rust/rust-clone-in-loop.yml +49 -0
  127. package/rules/tree-sitter-queries/rust/rust-unwrap.yml +45 -0
  128. package/rules/tree-sitter-queries/typescript/console-statement.yml +3 -3
  129. package/rules/tree-sitter-queries/typescript/hardcoded-secrets.yml +13 -27
  130. package/rules/tree-sitter-queries/typescript/injections.scm +40 -0
  131. package/rules/tree-sitter-queries/typescript/no-console-in-tests.yml +52 -0
  132. package/rules/tree-sitter-queries/typescript/sql-injection.yml +55 -0
  133. package/rules/tree-sitter-queries/typescript/unsafe-regex.yml +71 -0
  134. package/rules/tree-sitter-queries/typescript/variable-shadowing.yml +51 -0
  135. package/scripts/download-grammars.ts +78 -0
@@ -12,6 +12,7 @@
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { minimatch } from "minimatch";
15
+ import { resolvePackagePath } from "./package-root.js";
15
16
 
16
17
  // --- Types ---
17
18
 
@@ -91,6 +92,8 @@ export class ArchitectClient {
91
92
  // Try multiple possible locations for the default config
92
93
  const possibleDefaultPaths = [
93
94
  path.join(projectRoot, "default-architect.yaml"),
95
+ path.join(projectRoot, ".pi-lens", "default-architect.yaml"),
96
+ resolvePackagePath(import.meta.url, "default-architect.yaml"),
94
97
  path.join(projectRoot, "..", "default-architect.yaml"),
95
98
  path.join(process.cwd(), "default-architect.yaml"),
96
99
  ];
@@ -170,7 +173,7 @@ export class ArchitectClient {
170
173
 
171
174
  for (const check of rule.must_not) {
172
175
  // We use 'g' to find all occurrences and correctly report line numbers
173
- const regex = new RegExp(check.pattern, "gi");
176
+ const regex = new RegExp(check.pattern, "gim");
174
177
  let match: RegExpExecArray | null;
175
178
 
176
179
  // biome-ignore lint/suspicious/noAssignInExpressions: RegExp.exec iteration
@@ -286,7 +289,9 @@ export class ArchitectClient {
286
289
  ) {
287
290
  // Extract everything after "pattern:" and unquote
288
291
  const raw = trimmed.replace(/^-?\s*pattern:\s*/, "").trim();
289
- const unquoted = raw.replace(/^["']|["']$/g, "");
292
+ let unquoted = raw.replace(/^["']|["']$/g, "");
293
+ // Single-quoted YAML: '' is an escaped single-quote
294
+ if (raw.startsWith("'")) unquoted = unquoted.split("''").join("'");
290
295
  if (unquoted) {
291
296
  violation = { pattern: unquoted, message: "" };
292
297
  }
@@ -19,6 +19,7 @@ import type {
19
19
  RuleDescription,
20
20
  SgMatch,
21
21
  } from "./ast-grep-types.js";
22
+ import { resolvePackagePath } from "./package-root.js";
22
23
  import { SgRunner } from "./sg-runner.js";
23
24
 
24
25
  const _getExtensionDir = () => {
@@ -38,7 +39,12 @@ export class AstGrepClient {
38
39
  private runner: SgRunner;
39
40
 
40
41
  constructor(ruleDir?: string, verbose = false) {
41
- this.ruleDir = ruleDir || path.join(process.cwd(), "rules");
42
+ const projectRuleDir = path.join(process.cwd(), "rules");
43
+ this.ruleDir =
44
+ ruleDir ||
45
+ (fs.existsSync(projectRuleDir)
46
+ ? projectRuleDir
47
+ : resolvePackagePath(import.meta.url, "rules"));
42
48
  this.log = verbose
43
49
  ? (msg: string) => console.error(`[ast-grep] ${msg}`)
44
50
  : () => {};
@@ -43,9 +43,11 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
43
43
  { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
44
44
  // ESLint: only fires when project has eslint config (skips Biome/OxLint projects)
45
45
  { mode: "fallback", runnerIds: ["eslint"], filterKinds: ["jsts"] },
46
+ // Architectural rules: warning-only, fast (pure regex). Needed per-write so the
47
+ // all-clear signal can report "N warnings -> /lens-booboo" accurately.
48
+ { mode: "fallback", runnerIds: ["architect"], filterKinds: ["jsts"] },
46
49
  // Note: ast-grep CLI kept for ast_grep_search/ast_grep_replace tools only
47
50
  // Note: biome, oxlint handled by direct auto-fix calls in index.ts (not in dispatch)
48
- // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
49
51
  ],
50
52
  },
51
53
 
@@ -60,8 +62,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
60
62
  { mode: "all", runnerIds: ["pyright"], filterKinds: ["python"] },
61
63
  // LSP type checking (unified) - when --lens-lsp enabled
62
64
  { mode: "all", runnerIds: ["lsp"], filterKinds: ["python"] },
65
+ { mode: "fallback", runnerIds: ["architect"], filterKinds: ["python"] },
63
66
  // Note: ruff handled by direct auto-fix calls in index.ts (not in dispatch)
64
- // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
65
67
  ],
66
68
  },
67
69
 
@@ -77,7 +79,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
77
79
  { mode: "fallback", runnerIds: ["go-vet"], filterKinds: ["go"] },
78
80
  // golangci-lint: only fires when project has .golangci.yml config
79
81
  { mode: "fallback", runnerIds: ["golangci-lint"], filterKinds: ["go"] },
80
- // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
82
+ // Structural analysis
83
+ { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["go"] },
81
84
  ],
82
85
  },
83
86
 
@@ -91,7 +94,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
91
94
  { mode: "all", runnerIds: ["lsp"], filterKinds: ["rust"] },
92
95
  // Cargo clippy for additional checks
93
96
  { mode: "fallback", runnerIds: ["rust-clippy"], filterKinds: ["rust"] },
94
- // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
97
+ // Structural analysis
98
+ { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["rust"] },
95
99
  ],
96
100
  },
97
101
 
@@ -102,6 +106,8 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
102
106
  name: "Ruby Linting",
103
107
  groups: [
104
108
  { mode: "fallback", runnerIds: ["rubocop"], filterKinds: ["ruby"] },
109
+ // Structural analysis
110
+ { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["ruby"] },
105
111
  ],
106
112
  },
107
113
 
@@ -18,6 +18,18 @@ import type {
18
18
  } from "../types.js";
19
19
  import { readFileContent } from "./utils.js";
20
20
 
21
+ // Module-level singleton — loadConfig once per cwd, not on every file write
22
+ let _client: ArchitectClient | null = null;
23
+ let _loadedCwd: string | null = null;
24
+
25
+ function getClient(cwd: string): ArchitectClient {
26
+ if (_client && _loadedCwd === cwd) return _client;
27
+ _client = new ArchitectClient();
28
+ _client.loadConfig(cwd);
29
+ _loadedCwd = cwd;
30
+ return _client;
31
+ }
32
+
21
33
  const architectRunner: RunnerDefinition = {
22
34
  id: "architect",
23
35
  appliesTo: ["jsts", "python", "go", "rust", "cxx", "shell", "cmake"],
@@ -33,8 +45,7 @@ const architectRunner: RunnerDefinition = {
33
45
  return { status: "skipped", diagnostics: [], semantic: "none" };
34
46
  }
35
47
 
36
- const architectClient = new ArchitectClient();
37
- architectClient.loadConfig(ctx.cwd);
48
+ const architectClient = getClient(ctx.cwd);
38
49
 
39
50
  if (!architectClient.hasConfig()) {
40
51
  return { status: "skipped", diagnostics: [], semantic: "none" };
@@ -47,16 +58,18 @@ const architectRunner: RunnerDefinition = {
47
58
  for (const v of violations) {
48
59
  // Build message with inline fix guidance
49
60
  let message = v.message;
50
- let fixSuggestion: string | undefined = v.fix;
51
-
61
+ const fixSuggestion: string | undefined = v.fix;
62
+
52
63
  if (v.fix) {
53
- const fixPreview = v.fix.length > 60 ? `${v.fix.substring(0, 60)}...` : v.fix;
64
+ const fixPreview =
65
+ v.fix.length > 60 ? `${v.fix.substring(0, 60)}...` : v.fix;
54
66
  message += `\n💡 Suggested fix: ${fixPreview}`;
55
67
  } else if (v.note) {
56
- const notePreview = v.note.length > 80 ? `${v.note.substring(0, 80)}...` : v.note;
68
+ const notePreview =
69
+ v.note.length > 80 ? `${v.note.substring(0, 80)}...` : v.note;
57
70
  message += `\n📝 ${notePreview}`;
58
71
  }
59
-
72
+
60
73
  diagnostics.push({
61
74
  id: `architect-${v.line || 0}-${v.pattern}`,
62
75
  message,
@@ -9,6 +9,7 @@
9
9
 
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { resolvePackagePath } from "../../package-root.js";
12
13
  import type {
13
14
  Diagnostic,
14
15
  DispatchContext,
@@ -356,8 +357,10 @@ const astGrepNapiRunner: RunnerDefinition = {
356
357
  const diagnostics: Diagnostic[] = [];
357
358
 
358
359
  const ruleDirs = [
359
- path.join(process.cwd(), "rules/ast-grep-rules/rules"),
360
- path.join(process.cwd(), "rules/ast-grep-rules"),
360
+ path.join(process.cwd(), "rules", "ast-grep-rules", "rules"),
361
+ path.join(process.cwd(), "rules", "ast-grep-rules"),
362
+ resolvePackagePath(import.meta.url, "rules", "ast-grep-rules", "rules"),
363
+ resolvePackagePath(import.meta.url, "rules", "ast-grep-rules"),
361
364
  ];
362
365
 
363
366
  for (const ruleDir of ruleDirs) {
@@ -9,6 +9,7 @@
9
9
 
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { resolvePackagePath } from "../../package-root.js";
12
13
  import { safeSpawnAsync } from "../../safe-spawn.js";
13
14
  import type {
14
15
  Diagnostic,
@@ -16,6 +17,7 @@ import type {
16
17
  RunnerDefinition,
17
18
  RunnerResult,
18
19
  } from "../types.js";
20
+ import { getSgCommand, isSgAvailable } from "./utils/runner-helpers.js";
19
21
 
20
22
  // Simple YAML fix: field extractor
21
23
  function extractFixFromRule(
@@ -49,12 +51,8 @@ const astGrepRunner: RunnerDefinition = {
49
51
  skipTestFiles: true, // Many rules are noisy in tests
50
52
 
51
53
  async run(ctx: DispatchContext): Promise<RunnerResult> {
52
- // Check if ast-grep is available (use npx for local installs)
53
- const check = await safeSpawnAsync("npx", ["sg", "--version"], {
54
- timeout: 5000,
55
- });
56
-
57
- if (check.error || check.status !== 0) {
54
+ // Check if ast-grep is available (local bin preferred over npx)
55
+ if (!isSgAvailable()) {
58
56
  return { status: "skipped", diagnostics: [], semantic: "none" };
59
57
  }
60
58
 
@@ -64,10 +62,18 @@ const astGrepRunner: RunnerDefinition = {
64
62
  return { status: "skipped", diagnostics: [], semantic: "none" };
65
63
  }
66
64
 
67
- // Run ast-grep scan on the file (use npx for local installs)
68
- const args = ["sg", "scan", "--config", configPath, "--json", ctx.filePath];
69
-
70
- const result = await safeSpawnAsync("npx", args, {
65
+ const { cmd: sgCmd, args: sgPre } = getSgCommand();
66
+ const args = [
67
+ ...sgPre,
68
+ "sg",
69
+ "scan",
70
+ "--config",
71
+ configPath,
72
+ "--json",
73
+ ctx.filePath,
74
+ ];
75
+
76
+ const result = await safeSpawnAsync(sgCmd, args, {
71
77
  timeout: 30000,
72
78
  });
73
79
 
@@ -94,15 +100,20 @@ const astGrepRunner: RunnerDefinition = {
94
100
 
95
101
  function findAstGrepConfig(cwd: string): string | undefined {
96
102
  const candidates = [
97
- "rules/ast-grep-rules/.sgconfig.yml",
98
- ".sgconfig.yml",
99
- "sgconfig.yml",
103
+ path.join(cwd, "rules", "ast-grep-rules", ".sgconfig.yml"),
104
+ path.join(cwd, ".sgconfig.yml"),
105
+ path.join(cwd, "sgconfig.yml"),
106
+ resolvePackagePath(
107
+ import.meta.url,
108
+ "rules",
109
+ "ast-grep-rules",
110
+ ".sgconfig.yml",
111
+ ),
100
112
  ];
101
113
 
102
114
  for (const candidate of candidates) {
103
- const fullPath = `${cwd}/${candidate}`;
104
- if (fs.existsSync(fullPath)) {
105
- return fullPath;
115
+ if (fs.existsSync(candidate)) {
116
+ return candidate;
106
117
  }
107
118
  }
108
119
 
@@ -119,8 +130,8 @@ function parseAstGrepOutput(
119
130
  // Try to parse as JSON
120
131
  // Determine rule directory for fix: extraction
121
132
  const ruleDir = _configPath
122
- ? path.dirname(_configPath).replace("/.sgconfig.yml", "/rules")
123
- : path.join(process.cwd(), "rules", "ast-grep-rules", "rules");
133
+ ? path.join(path.dirname(_configPath), "rules")
134
+ : resolvePackagePath(import.meta.url, "rules", "ast-grep-rules", "rules");
124
135
 
125
136
  try {
126
137
  const parsed = JSON.parse(raw);
@@ -37,10 +37,10 @@ const biomeRunner: RunnerDefinition = {
37
37
  }
38
38
  }
39
39
 
40
- // IMPORTANT: Never use --write in dispatch runner to prevent infinite loops.
41
- // Writing to the file would trigger another tool_result event, which would
42
- // call dispatchLint again, creating a feedback loop.
43
- // Auto-format handles formatting on write; this runner only checks.
40
+ // No --write here: dispatch runners report issues for agent understanding,
41
+ // not silent correction. Auto-format (biome --write) already runs in the
42
+ // format phase before dispatch, handling all safe style transforms.
43
+ // Silently rewriting here would leave the agent's context window stale.
44
44
  const args = useNpx
45
45
  ? ["biome", "check", ctx.filePath]
46
46
  : ["check", ctx.filePath];
@@ -12,16 +12,17 @@
12
12
 
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { safeSpawn } from "../../safe-spawn.js";
15
- import {
16
- createConfigFinder,
17
- isSgAvailable,
18
- } from "./utils/runner-helpers.js";
19
15
  import type {
20
16
  Diagnostic,
21
17
  DispatchContext,
22
18
  RunnerDefinition,
23
19
  RunnerResult,
24
20
  } from "../types.js";
21
+ import {
22
+ createConfigFinder,
23
+ getSgCommand,
24
+ isSgAvailable,
25
+ } from "./utils/runner-helpers.js";
25
26
 
26
27
  const findSlopConfig = createConfigFinder("python-slop-rules");
27
28
 
@@ -45,9 +46,18 @@ const pythonSlopRunner: RunnerDefinition = {
45
46
  }
46
47
 
47
48
  // Run ast-grep scan
48
- const args = ["sg", "scan", "--config", configPath, "--json", ctx.filePath];
49
-
50
- const result = safeSpawn("npx", args, {
49
+ const { cmd: sgCmd, args: sgPre } = getSgCommand();
50
+ const args = [
51
+ ...sgPre,
52
+ "sg",
53
+ "scan",
54
+ "--config",
55
+ configPath,
56
+ "--json",
57
+ ctx.filePath,
58
+ ];
59
+
60
+ const result = safeSpawn(sgCmd, args, {
51
61
  timeout: 30000,
52
62
  });
53
63
 
@@ -35,10 +35,10 @@ const ruffRunner: RunnerDefinition = {
35
35
  }
36
36
  }
37
37
 
38
- // IMPORTANT: Never use --fix in dispatch runner to prevent infinite loops.
39
- // Writing to the file would trigger another tool_result event, which would
40
- // call dispatchLint again, creating a feedback loop.
41
- // Fixes should be applied through explicit commands or user edits.
38
+ // No --fix here: dispatch runners report issues for agent understanding,
39
+ // not silent correction. Auto-fix (ruff --fix) already runs in the
40
+ // format phase before dispatch, handling all safe style transforms.
41
+ // Silently rewriting here would leave the agent's context window stale.
42
42
  const args = ["check", ctx.filePath];
43
43
 
44
44
  const result = await safeSpawnAsync(ruff.getCommand()!, args, {
@@ -26,6 +26,7 @@ import type {
26
26
  // Creating a new TreeSitterClient() on every write resets TRANSFER_BUFFER (a module-level
27
27
  // WASM pointer) — concurrent writes race on _ts_init() and corrupt shared WASM state → crash.
28
28
  let _sharedClient: TreeSitterClient | null = null;
29
+
29
30
  function getSharedClient(): TreeSitterClient {
30
31
  if (!_sharedClient) {
31
32
  _sharedClient = new TreeSitterClient();
@@ -117,7 +118,7 @@ function matchesSecretPattern(varName: string): boolean {
117
118
 
118
119
  const treeSitterRunner: RunnerDefinition = {
119
120
  id: "tree-sitter",
120
- appliesTo: ["jsts", "python"],
121
+ appliesTo: ["jsts", "python", "go", "rust", "ruby"],
121
122
  priority: 14, // Between oxlint (12) and ast-grep-napi (15)
122
123
  enabledByDefault: true,
123
124
  skipTestFiles: false, // Run on test files too (structural issues matter there)
@@ -136,21 +137,23 @@ const treeSitterRunner: RunnerDefinition = {
136
137
 
137
138
  // Determine language from file extension
138
139
  const filePath = ctx.filePath;
139
- const isPython = filePath.endsWith(".py");
140
- const isTypeScript = filePath.endsWith(".ts");
141
- const isTSX = filePath.endsWith(".tsx");
142
- const isJavaScript = filePath.endsWith(".js") || filePath.endsWith(".jsx");
143
-
144
- let languageId: string;
145
- if (isPython) {
146
- languageId = "python";
147
- } else if (isTSX) {
148
- languageId = "tsx";
149
- } else if (isTypeScript) {
150
- languageId = "typescript";
151
- } else if (isJavaScript) {
152
- languageId = "javascript";
153
- } else {
140
+ const ext = filePath.slice(filePath.lastIndexOf("."));
141
+ const EXT_TO_LANG: Record<string, string> = {
142
+ ".ts": "typescript",
143
+ ".mts": "typescript",
144
+ ".cts": "typescript",
145
+ ".tsx": "tsx",
146
+ ".js": "javascript",
147
+ ".mjs": "javascript",
148
+ ".cjs": "javascript",
149
+ ".jsx": "javascript",
150
+ ".py": "python",
151
+ ".go": "go",
152
+ ".rs": "rust",
153
+ ".rb": "ruby",
154
+ };
155
+ const languageId = EXT_TO_LANG[ext];
156
+ if (!languageId) {
154
157
  return { status: "skipped", diagnostics: [], semantic: "none" };
155
158
  }
156
159
 
@@ -197,7 +200,7 @@ const treeSitterRunner: RunnerDefinition = {
197
200
  languageQueries = allQueries.filter(
198
201
  (q) =>
199
202
  q.language === languageId ||
200
- (isJavaScript && q.language === "typescript"),
203
+ (languageId === "javascript" && q.language === "typescript"),
201
204
  );
202
205
 
203
206
  // Save to cache
@@ -245,8 +248,9 @@ const treeSitterRunner: RunnerDefinition = {
245
248
  continue; // Skip this match - filter didn't pass
246
249
  }
247
250
 
248
- // For hardcoded-secrets, also check variable name pattern
249
- if (query.id === "hardcoded-secrets") {
251
+ // check_secret_pattern post-filter is handled in tree-sitter-client.ts
252
+ // Legacy: hardcoded-secrets id check (kept for backward compat)
253
+ if (query.id === "hardcoded-secrets" && !query.post_filter) {
250
254
  // Extract variable name from captures
251
255
  const varName = match.captures?.VARNAME || "";
252
256
  if (!varName || !matchesSecretPattern(varName)) {
@@ -276,6 +280,13 @@ const treeSitterRunner: RunnerDefinition = {
276
280
  semantic,
277
281
  tool: "tree-sitter",
278
282
  rule: query.id,
283
+ // Surface fix intent to agent — tree-sitter never auto-applies;
284
+ // linters (biome/ruff/eslint) own the autofix phase.
285
+ fixable: query.has_fix,
286
+ fixSuggestion:
287
+ query.has_fix && query.fix_action
288
+ ? `${query.fix_action} this statement`
289
+ : undefined,
279
290
  });
280
291
  }
281
292
  } catch (err) {
@@ -12,16 +12,17 @@
12
12
 
13
13
  import { spawnSync } from "node:child_process";
14
14
  import { safeSpawn } from "../../safe-spawn.js";
15
- import {
16
- createConfigFinder,
17
- isSgAvailable,
18
- } from "./utils/runner-helpers.js";
19
15
  import type {
20
16
  Diagnostic,
21
17
  DispatchContext,
22
18
  RunnerDefinition,
23
19
  RunnerResult,
24
20
  } from "../types.js";
21
+ import {
22
+ createConfigFinder,
23
+ getSgCommand,
24
+ isSgAvailable,
25
+ } from "./utils/runner-helpers.js";
25
26
 
26
27
  const findSlopConfig = createConfigFinder("ts-slop-rules");
27
28
 
@@ -47,9 +48,18 @@ const tsSlopRunner: RunnerDefinition = {
47
48
  }
48
49
 
49
50
  // Run ast-grep scan
50
- const args = ["sg", "scan", "--config", configPath, "--json", ctx.filePath];
51
-
52
- const result = safeSpawn("npx", args, {
51
+ const { cmd: sgCmd, args: sgPre } = getSgCommand();
52
+ const args = [
53
+ ...sgPre,
54
+ "sg",
55
+ "scan",
56
+ "--config",
57
+ configPath,
58
+ "--json",
59
+ ctx.filePath,
60
+ ];
61
+
62
+ const result = safeSpawn(sgCmd, args, {
53
63
  timeout: 30000,
54
64
  });
55
65
 
@@ -41,9 +41,7 @@ export function createVenvFinder(
41
41
  for (const venvPath of venvPaths) {
42
42
  const fullPath = path.join(cwd, venvPath);
43
43
  if (fs.existsSync(fullPath)) {
44
- return quoteWindows && windowsExt
45
- ? `"${fullPath}"`
46
- : fullPath;
44
+ return quoteWindows && windowsExt ? `"${fullPath}"` : fullPath;
47
45
  }
48
46
  }
49
47
 
@@ -142,21 +140,91 @@ export function createConfigFinder(
142
140
 
143
141
  // Shared sg availability cache across all slop runners
144
142
  let sgAvailable: boolean | null = null;
143
+ let sgCmd: string | null = null;
144
+ let sgCmdArgs: string[] = [];
145
145
 
146
146
  /**
147
- * Check if ast-grep CLI is available (shared cache)
147
+ * Check if ast-grep CLI (sg) is available.
148
+ * Prefers local node_modules/.bin/sg, then global sg, then npx --no sg (cache-only).
148
149
  */
149
150
  export function isSgAvailable(): boolean {
150
151
  if (sgAvailable !== null) return sgAvailable;
151
152
 
152
- const check = safeSpawn("npx", ["sg", "--version"], {
153
+ // 1. Local node_modules/.bin/sg
154
+ const isWin = process.platform === "win32";
155
+ const localSg = path.join(
156
+ process.cwd(),
157
+ "node_modules",
158
+ ".bin",
159
+ isWin ? "sg.cmd" : "sg",
160
+ );
161
+ if (fs.existsSync(localSg)) {
162
+ const check = safeSpawn(localSg, ["--version"], { timeout: 5000 });
163
+ if (!check.error && check.status === 0) {
164
+ sgCmd = localSg;
165
+ sgCmdArgs = [];
166
+ sgAvailable = true;
167
+ return true;
168
+ }
169
+ }
170
+
171
+ // 2. Global sg
172
+ const globalCheck = safeSpawn("sg", ["--version"], { timeout: 5000 });
173
+ if (!globalCheck.error && globalCheck.status === 0) {
174
+ sgCmd = "sg";
175
+ sgCmdArgs = [];
176
+ sgAvailable = true;
177
+ return true;
178
+ }
179
+
180
+ // 3. npx --no (cache-only, no silent download)
181
+ const npxCheck = safeSpawn("npx", ["--no", "sg", "--version"], {
153
182
  timeout: 5000,
154
183
  });
155
-
156
- sgAvailable = !check.error && check.status === 0;
184
+ sgAvailable = !npxCheck.error && npxCheck.status === 0;
185
+ if (sgAvailable) {
186
+ sgCmd = "npx";
187
+ sgCmdArgs = ["--no"];
188
+ }
157
189
  return sgAvailable;
158
190
  }
159
191
 
192
+ export function getSgCommand(): { cmd: string; args: string[] } {
193
+ return { cmd: sgCmd ?? "npx", args: sgCmdArgs.length ? sgCmdArgs : ["--no"] };
194
+ }
195
+
196
+ // =============================================================================
197
+ // LOCAL-FIRST BINARY RESOLUTION
198
+ // =============================================================================
199
+
200
+ /**
201
+ * Find a tool binary preferring local node_modules/.bin over global PATH.
202
+ * Only falls back to npx as a last resort (avoids silent network downloads).
203
+ *
204
+ * Returns: { cmd, args } where args may include ["npx", toolName] preamble.
205
+ */
206
+ export function resolveLocalFirst(
207
+ toolName: string,
208
+ cwd: string,
209
+ windowsExt = ".cmd",
210
+ ): { cmd: string; args: string[] } {
211
+ const isWin = process.platform === "win32";
212
+ const binName = isWin ? `${toolName}${windowsExt}` : toolName;
213
+
214
+ // 1. Local node_modules/.bin (project-installed)
215
+ const local = path.join(cwd, "node_modules", ".bin", binName);
216
+ if (fs.existsSync(local)) return { cmd: local, args: [] };
217
+
218
+ // 2. Global PATH (already installed system-wide)
219
+ const globalCheck = safeSpawn(toolName, ["--version"], { timeout: 3000 });
220
+ if (!globalCheck.error && globalCheck.status === 0) {
221
+ return { cmd: toolName, args: [] };
222
+ }
223
+
224
+ // 3. npx fallback — only for already-cached packages (no silent download)
225
+ return { cmd: "npx", args: ["--no", toolName] };
226
+ }
227
+
160
228
  // =============================================================================
161
229
  // PRE-BUILT CHECKERS FOR COMMON TOOLS
162
230
  // =============================================================================
@@ -164,4 +232,4 @@ export function isSgAvailable(): boolean {
164
232
  export const pyright = createAvailabilityChecker("pyright", ".exe");
165
233
  export const ruff = createAvailabilityChecker("ruff", ".exe");
166
234
  export const biome = createAvailabilityChecker("biome");
167
- export const sg = { isAvailable: isSgAvailable, getCommand: () => "npx" };
235
+ export const sg = { isAvailable: isSgAvailable, getCommand: getSgCommand };
@@ -19,7 +19,8 @@ export const EMOJI: Record<string, string> = {
19
19
  export function formatDiagnostic(d: Diagnostic): string {
20
20
  const line = d.line ? `L${d.line}: ` : "";
21
21
  const indented = d.message.split("\n").join("\n ");
22
- return ` ${line}${indented}`;
22
+ const fix = d.fixSuggestion ? `\n 💡 Fix: ${d.fixSuggestion}` : "";
23
+ return ` ${line}${indented}${fix}`;
23
24
  }
24
25
 
25
26
  /**
@@ -14,6 +14,10 @@ import * as nodeFs from "node:fs";
14
14
  import * as path from "node:path";
15
15
  import type { BiomeClient } from "./biome-client.js";
16
16
  import type { ComplexityClient } from "./complexity-client.js";
17
+ import {
18
+ getSgCommand,
19
+ isSgAvailable,
20
+ } from "./dispatch/runners/utils/runner-helpers.js";
17
21
  import { EXCLUDED_DIRS } from "./file-utils.js";
18
22
  import type { JscpdClient } from "./jscpd-client.js";
19
23
  import type { KnipClient } from "./knip-client.js";
@@ -119,17 +123,13 @@ export function scanAstGrep(
119
123
  isTsProject: boolean,
120
124
  configPath: string,
121
125
  ): AstIssue[] {
122
- const hasSg =
123
- nodeFs.existsSync(path.join(targetPath, "node_modules", ".bin", "sg")) ||
124
- safeSpawn("npx", ["sg", "--version"], {
125
- timeout: 5000,
126
- }).status === 0;
127
-
128
- if (!hasSg) return [];
126
+ if (!isSgAvailable()) return [];
129
127
 
128
+ const { cmd: sgCmd, args: sgPre } = getSgCommand();
130
129
  const result = safeSpawn(
131
- "npx",
130
+ sgCmd,
132
131
  [
132
+ ...sgPre,
133
133
  "sg",
134
134
  "scan",
135
135
  "--config",