pi-lens 3.3.0 → 3.6.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 (53) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -13
  3. package/clients/cache/rule-cache.js +72 -0
  4. package/clients/cache/rule-cache.ts +104 -0
  5. package/clients/dispatch/integration.js +48 -1
  6. package/clients/dispatch/integration.ts +60 -2
  7. package/clients/dispatch/plan.js +5 -2
  8. package/clients/dispatch/plan.ts +5 -2
  9. package/clients/dispatch/runners/ast-grep-napi.js +175 -56
  10. package/clients/dispatch/runners/ast-grep-napi.test.js +2 -1
  11. package/clients/dispatch/runners/ast-grep-napi.test.ts +2 -1
  12. package/clients/dispatch/runners/ast-grep-napi.ts +191 -79
  13. package/clients/dispatch/runners/similarity.js +1 -1
  14. package/clients/dispatch/runners/similarity.ts +2 -2
  15. package/clients/dispatch/runners/tree-sitter.js +137 -10
  16. package/clients/dispatch/runners/tree-sitter.ts +168 -13
  17. package/clients/dispatch/runners/ts-lsp.js +3 -2
  18. package/clients/dispatch/runners/ts-lsp.ts +3 -2
  19. package/clients/dispatch/runners/yaml-rule-parser.js +70 -2
  20. package/clients/dispatch/runners/yaml-rule-parser.ts +71 -2
  21. package/clients/dispatch/types.js +1 -1
  22. package/clients/dispatch/types.ts +1 -1
  23. package/clients/lsp/__tests__/service.test.js +3 -0
  24. package/clients/lsp/__tests__/service.test.ts +3 -0
  25. package/clients/lsp/client.js +42 -0
  26. package/clients/lsp/client.ts +79 -0
  27. package/clients/lsp/index.js +27 -0
  28. package/clients/lsp/index.ts +35 -0
  29. package/clients/lsp/launch.js +11 -6
  30. package/clients/lsp/launch.ts +11 -6
  31. package/clients/metrics-client.js +3 -160
  32. package/clients/metrics-client.tdr.test.js +78 -0
  33. package/clients/metrics-client.test.js +30 -43
  34. package/clients/metrics-client.test.ts +30 -54
  35. package/clients/metrics-client.ts +5 -219
  36. package/clients/metrics-history.js +33 -7
  37. package/clients/metrics-history.ts +47 -10
  38. package/clients/pipeline.js +272 -0
  39. package/clients/pipeline.ts +371 -0
  40. package/clients/sg-runner.js +21 -3
  41. package/clients/sg-runner.ts +22 -3
  42. package/clients/tree-sitter-client.js +23 -2
  43. package/clients/tree-sitter-client.ts +27 -2
  44. package/index.ts +604 -771
  45. package/package.json +1 -1
  46. package/rules/ast-grep-rules/rules/no-architecture-violation.yml +7 -4
  47. package/rules/ast-grep-rules/rules/no-single-char-var.yml +3 -3
  48. package/rules/ast-grep-rules/slop-patterns.yml +85 -62
  49. package/skills/ast-grep/SKILL.md +42 -1
  50. package/skills/lsp-navigation/SKILL.md +62 -0
  51. package/tsconfig.json +1 -1
  52. package/rules/ast-grep-rules/rules/no-console-log.yml +0 -10
  53. package/rules/ast-grep-rules/rules/no-default-export.yml +0 -19
@@ -15,7 +15,7 @@ import {
15
15
  getLatencyReports,
16
16
  type RunnerLatency,
17
17
  } from "./dispatcher.js";
18
- import type { PiAgentAPI } from "./types.js";
18
+ import type { BaselineStore, DispatchResult, PiAgentAPI } from "./types.js";
19
19
 
20
20
  export type { DispatchLatencyReport, RunnerLatency };
21
21
  // Re-export latency tracking types and functions
@@ -24,6 +24,21 @@ export { clearLatencyReports, formatLatencyReport, getLatencyReports };
24
24
  // Import runners to register them
25
25
  import "./runners/index.js";
26
26
 
27
+ // --- Persistent Baseline Store ---
28
+ // Survives across dispatchLint calls within a session.
29
+ // Without this, delta mode is a no-op: every call creates a fresh empty
30
+ // store, so baselines.get() always returns undefined and every issue
31
+ // looks "new" every time.
32
+ const sessionBaselines: BaselineStore = createBaselineStore();
33
+
34
+ /**
35
+ * Reset baselines — call on session_start so a new session
36
+ * starts with a clean slate.
37
+ */
38
+ export function resetDispatchBaselines(): void {
39
+ sessionBaselines.clear();
40
+ }
41
+
27
42
  /**
28
43
  * Run linting for a file using the declarative dispatch system
29
44
  *
@@ -38,7 +53,9 @@ export async function dispatchLint(
38
53
  pi: PiAgentAPI,
39
54
  ): Promise<string> {
40
55
  // By default, only run BLOCKING rules for fast feedback on file write
41
- const ctx = createDispatchContext(filePath, cwd, pi, undefined, true);
56
+ // Uses persistent sessionBaselines so delta mode actually filters
57
+ // pre-existing issues after the first write.
58
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionBaselines, true);
42
59
 
43
60
  // Import dispatchForFile dynamically to avoid circular deps
44
61
  const { dispatchForFile } = await import("./dispatcher.js");
@@ -55,6 +72,47 @@ export async function dispatchLint(
55
72
  return result.output;
56
73
  }
57
74
 
75
+ /**
76
+ * Run linting and return full result (including diagnostics)
77
+ */
78
+ export async function dispatchLintWithResult(
79
+ filePath: string,
80
+ cwd: string,
81
+ pi: PiAgentAPI,
82
+ ): Promise<DispatchResult> {
83
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionBaselines, true);
84
+
85
+ const { dispatchForFile } = await import("./dispatcher.js");
86
+ const { getRunnersForKind } = await import("./dispatcher.js");
87
+ const { TOOL_PLANS } = await import("./plan.js");
88
+
89
+ const kind = ctx.kind;
90
+ if (!kind) {
91
+ return {
92
+ diagnostics: [],
93
+ blockers: [],
94
+ warnings: [],
95
+ fixed: [],
96
+ output: "",
97
+ hasBlockers: false,
98
+ };
99
+ }
100
+
101
+ const plan = TOOL_PLANS[kind];
102
+ if (!plan) {
103
+ return {
104
+ diagnostics: [],
105
+ blockers: [],
106
+ warnings: [],
107
+ fixed: [],
108
+ output: "",
109
+ hasBlockers: false,
110
+ };
111
+ }
112
+
113
+ return await dispatchForFile(ctx, plan.groups);
114
+ }
115
+
58
116
  /**
59
117
  * Create a baseline store for delta mode tracking
60
118
  */
@@ -31,10 +31,12 @@ export const TOOL_PLANS = {
31
31
  // Tree-sitter native structural analysis (blocking rules: constructor-super, dangerouslySetInnerHTML, etc.)
32
32
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["jsts"] },
33
33
  // AST structural analysis (blocking: no-dupe-keys, no-hardcoded-secrets, jwt-no-verify, etc.)
34
- // DISABLED in post-write - ast-grep-napi can crash. Runs via /lens-booboo only.
35
- // { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
34
+ // Only error-severity rules fire inline (blockingOnly=true). Warnings are booboo-only.
35
+ { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
36
36
  // Type safety checks (has some blocking errors)
37
37
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
38
+ // Similarity detection — warns about duplicated/reusable code
39
+ { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
38
40
  // Note: ast-grep CLI kept for ast_grep_search/ast_grep_replace tools only
39
41
  // Note: biome, oxlint handled by direct auto-fix calls in index.ts (not in dispatch)
40
42
  // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
@@ -163,6 +165,7 @@ export const FULL_LINT_PLANS = {
163
165
  filterKinds: ["jsts"],
164
166
  },
165
167
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
168
+ { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
166
169
  { mode: "fallback", runnerIds: ["architect"], filterKinds: ["jsts"] },
167
170
  ],
168
171
  },
@@ -35,10 +35,12 @@ export const TOOL_PLANS: Record<string, ToolPlan> = {
35
35
  // Tree-sitter native structural analysis (blocking rules: constructor-super, dangerouslySetInnerHTML, etc.)
36
36
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["jsts"] },
37
37
  // AST structural analysis (blocking: no-dupe-keys, no-hardcoded-secrets, jwt-no-verify, etc.)
38
- // DISABLED in post-write - ast-grep-napi can crash. Runs via /lens-booboo only.
39
- // { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
38
+ // Only error-severity rules fire inline (blockingOnly=true). Warnings are booboo-only.
39
+ { mode: "all", runnerIds: ["ast-grep-napi"], filterKinds: ["jsts"] },
40
40
  // Type safety checks (has some blocking errors)
41
41
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
42
+ // Similarity detection — warns about duplicated/reusable code
43
+ { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
42
44
  // Note: ast-grep CLI kept for ast_grep_search/ast_grep_replace tools only
43
45
  // Note: biome, oxlint handled by direct auto-fix calls in index.ts (not in dispatch)
44
46
  // Architectural rules (guidance only, not blocking) - runs via /lens-booboo only
@@ -178,6 +180,7 @@ export const FULL_LINT_PLANS: Record<string, ToolPlan> = {
178
180
  filterKinds: ["jsts"],
179
181
  },
180
182
  { mode: "fallback", runnerIds: ["type-safety"], filterKinds: ["jsts"] },
183
+ { mode: "fallback", runnerIds: ["similarity"], filterKinds: ["jsts"] },
181
184
  { mode: "fallback", runnerIds: ["architect"], filterKinds: ["jsts"] },
182
185
  ],
183
186
  },
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
- import { calculateRuleComplexity, isStructuredRule, loadYamlRules, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
11
+ import { calculateRuleComplexity, hasUnsupportedConditions, isOverlyBroadPattern, isStructuredRule, loadYamlRules, MAX_BLOCKING_RULE_COMPLEXITY, } from "./yaml-rule-parser.js";
12
12
  // Lazy load the napi package
13
13
  let sg;
14
14
  let sgLoadAttempted = false;
@@ -32,6 +32,14 @@ const SUPPORTED_EXTS = [".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".htm"];
32
32
  const MAX_MATCHES_PER_RULE = 10;
33
33
  /** Maximum total diagnostics per file to prevent output spam */
34
34
  const MAX_TOTAL_DIAGNOSTICS = 50;
35
+ /** Rules already covered by tree-sitter runner (priority 14, runs first) */
36
+ const TREE_SITTER_OVERLAP = new Set([
37
+ "constructor-super",
38
+ "empty-catch",
39
+ "long-parameter-list",
40
+ "nested-ternary",
41
+ "no-dupe-class-members",
42
+ ]);
35
43
  /** Maximum AST depth to traverse to prevent stack overflow on deeply nested files */
36
44
  const MAX_AST_DEPTH = 50;
37
45
  /** Maximum recursion depth for structured rule execution */
@@ -59,76 +67,170 @@ function getLang(filePath, sgModule) {
59
67
  }
60
68
  }
61
69
  /**
62
- * Execute a structured rule using manual AST traversal
70
+ * Check if a single node matches a condition (without searching descendants).
71
+ * In ast-grep semantics:
72
+ * - pattern/kind/regex: check the node itself
73
+ * - all: node must match ALL sub-conditions
74
+ * - any: node must match at least ONE sub-condition
75
+ * - not: node must NOT match the sub-condition
76
+ * - has: node must have a DESCENDANT matching the sub-condition
63
77
  */
64
- function executeStructuredRule(rootNode, condition, matches = [], depth = 0) {
78
+ function nodeMatchesCondition(node, condition, depth = 0) {
65
79
  if (depth > MAX_RULE_DEPTH)
66
- return matches;
67
- let candidates = [];
80
+ return false;
81
+ // Check kind constraint
82
+ if (condition.kind && node.kind() !== condition.kind)
83
+ return false;
84
+ // Check pattern constraint (node itself must match)
68
85
  if (condition.pattern) {
86
+ try {
87
+ const matches = node.findAll(condition.pattern);
88
+ // Check if the node itself is among the matches (same start position)
89
+ const nodeRange = node.range();
90
+ let selfMatch = false;
91
+ for (const m of matches) {
92
+ const mr = m.range();
93
+ if (mr.start.line === nodeRange.start.line &&
94
+ mr.start.column === nodeRange.start.column &&
95
+ mr.end.line === nodeRange.end.line &&
96
+ mr.end.column === nodeRange.end.column) {
97
+ selfMatch = true;
98
+ break;
99
+ }
100
+ }
101
+ if (!selfMatch)
102
+ return false;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ // Check regex constraint
109
+ if (condition.regex) {
110
+ try {
111
+ const text = node.text();
112
+ if (!new RegExp(condition.regex).test(text))
113
+ return false;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ // Check has (descendant must match)
120
+ if (condition.has) {
121
+ const descendants = findMatchingNodes(node, condition.has, depth + 1);
122
+ if (descendants.length === 0)
123
+ return false;
124
+ }
125
+ // Check not (node must NOT match this condition)
126
+ if (condition.not) {
127
+ if (nodeMatchesCondition(node, condition.not, depth + 1))
128
+ return false;
129
+ }
130
+ // Check all (node must match ALL sub-conditions)
131
+ if (condition.all) {
132
+ for (const sub of condition.all) {
133
+ if (!nodeMatchesCondition(node, sub, depth + 1))
134
+ return false;
135
+ }
136
+ }
137
+ // Check any (node must match at least one sub-condition)
138
+ if (condition.any) {
139
+ let anyMatch = false;
140
+ for (const sub of condition.any) {
141
+ if (nodeMatchesCondition(node, sub, depth + 1)) {
142
+ anyMatch = true;
143
+ break;
144
+ }
145
+ }
146
+ if (!anyMatch)
147
+ return false;
148
+ }
149
+ return true;
150
+ }
151
+ /**
152
+ * Find all nodes in the tree that match a condition.
153
+ * This is the "search" function - traverses the tree and checks each node.
154
+ */
155
+ function findMatchingNodes(rootNode, condition, depth = 0) {
156
+ if (depth > MAX_RULE_DEPTH)
157
+ return [];
158
+ const matches = [];
159
+ // Optimization: if the condition has a kind, only check nodes of that kind
160
+ // If it has a pattern, use findAll for initial candidates
161
+ let candidates;
162
+ if (condition.pattern && !condition.all && !condition.any) {
163
+ // Use findAll for pattern-only conditions (fast path)
69
164
  try {
70
165
  candidates = rootNode.findAll(condition.pattern);
71
166
  }
72
167
  catch {
73
- return matches;
168
+ return [];
74
169
  }
75
170
  }
76
- else if (condition.kind) {
171
+ else if (condition.kind && !condition.all && !condition.any) {
172
+ // Use findByKind for kind-only conditions (fast path)
77
173
  candidates = findByKind(rootNode, condition.kind, 0);
78
174
  }
175
+ else if (condition.all) {
176
+ // For `all`, find the narrowest sub-condition to generate candidates
177
+ candidates = getCandidatesForAll(rootNode, condition.all);
178
+ }
179
+ else if (condition.any) {
180
+ // For `any`, union candidates from all sub-conditions
181
+ const seen = new Set();
182
+ candidates = [];
183
+ for (const sub of condition.any) {
184
+ const subMatches = findMatchingNodes(rootNode, sub, depth + 1);
185
+ for (const m of subMatches) {
186
+ const r = m.range();
187
+ const key = `${r.start.line}:${r.start.column}`;
188
+ if (!seen.has(key)) {
189
+ seen.add(key);
190
+ candidates.push(m);
191
+ }
192
+ }
193
+ }
194
+ }
79
195
  else {
196
+ // Fallback: traverse all nodes
80
197
  candidates = getAllNodes(rootNode, 0);
81
198
  }
82
199
  for (const candidate of candidates) {
83
- const node = candidate;
84
- let matchesCondition = true;
85
- if (condition.has && matchesCondition) {
86
- const subMatches = executeStructuredRule(node, condition.has, [], depth + 1);
87
- if (subMatches.length === 0)
88
- matchesCondition = false;
89
- }
90
- if (condition.not && matchesCondition) {
91
- const subMatches = executeStructuredRule(node, condition.not, [], depth + 1);
92
- if (subMatches.length > 0)
93
- matchesCondition = false;
94
- }
95
- if (condition.any && matchesCondition) {
96
- let anyMatches = false;
97
- for (const subCondition of condition.any) {
98
- const subMatches = executeStructuredRule(node, subCondition, [], depth + 1);
99
- if (subMatches.length > 0) {
100
- anyMatches = true;
101
- break;
102
- }
103
- }
104
- if (!anyMatches)
105
- matchesCondition = false;
200
+ if (nodeMatchesCondition(candidate, condition, depth)) {
201
+ matches.push(candidate);
106
202
  }
107
- if (condition.all && matchesCondition) {
108
- for (const subCondition of condition.all) {
109
- const subMatches = executeStructuredRule(node, subCondition, [], depth + 1);
110
- if (subMatches.length === 0) {
111
- matchesCondition = false;
112
- break;
113
- }
114
- }
203
+ }
204
+ return matches;
205
+ }
206
+ /**
207
+ * For an `all` condition, find the narrowest sub-condition to generate
208
+ * initial candidates. This avoids scanning all nodes when one sub-condition
209
+ * has a specific kind or pattern.
210
+ */
211
+ function getCandidatesForAll(rootNode, subs) {
212
+ // Prefer kind-based narrowing first, then pattern-based
213
+ for (const sub of subs) {
214
+ if (sub.kind) {
215
+ return findByKind(rootNode, sub.kind, 0);
115
216
  }
116
- if (condition.regex && matchesCondition) {
217
+ }
218
+ for (const sub of subs) {
219
+ if (sub.pattern) {
117
220
  try {
118
- const text = node.text();
119
- const regex = new RegExp(condition.regex);
120
- if (!regex.test(text))
121
- matchesCondition = false;
122
- }
123
- catch {
124
- matchesCondition = false;
221
+ return rootNode.findAll(sub.pattern);
125
222
  }
126
- }
127
- if (matchesCondition) {
128
- matches.push(node);
223
+ catch { }
129
224
  }
130
225
  }
131
- return matches;
226
+ // No narrowing possible, scan all
227
+ return getAllNodes(rootNode, 0);
228
+ }
229
+ /**
230
+ * Legacy wrapper - execute a structured rule using the new two-phase approach.
231
+ */
232
+ function executeStructuredRule(rootNode, condition, matches = [], depth = 0) {
233
+ return findMatchingNodes(rootNode, condition, depth);
132
234
  }
133
235
  /**
134
236
  * Find all nodes of a specific kind with depth limit
@@ -161,9 +263,7 @@ const astGrepNapiRunner = {
161
263
  id: "ast-grep-napi",
162
264
  appliesTo: ["jsts"],
163
265
  priority: 15,
164
- // Post-write disabled in plan.ts (removed from TOOL_PLANS.jsts.groups).
165
- // Still enabled for /lens-booboo via FULL_LINT_PLANS.
166
- enabledByDefault: false,
266
+ enabledByDefault: true,
167
267
  skipTestFiles: true,
168
268
  async run(ctx) {
169
269
  if (!canHandle(ctx.filePath)) {
@@ -219,6 +319,21 @@ const astGrepNapiRunner = {
219
319
  continue;
220
320
  }
221
321
  for (const rule of rules) {
322
+ // Skip rules already handled by tree-sitter runner (priority 14)
323
+ if (TREE_SITTER_OVERLAP.has(rule.id))
324
+ continue;
325
+ // Skip rules using conditions we can't execute (inside, follows,
326
+ // precedes, stopBy, field, nthChild, constraints). Running these
327
+ // with only partial condition evaluation causes false positives.
328
+ if (hasUnsupportedConditions(rule))
329
+ continue;
330
+ // Skip rules whose top-level pattern is overly broad ($NAME, $X, etc.)
331
+ // without additional structural constraints to narrow matches.
332
+ if (rule.rule &&
333
+ isOverlyBroadPattern(rule.rule.pattern) &&
334
+ !isStructuredRule(rule)) {
335
+ continue;
336
+ }
222
337
  const lang = rule.language?.toLowerCase();
223
338
  if (lang && lang !== "typescript" && lang !== "javascript") {
224
339
  continue;
@@ -253,8 +368,7 @@ const astGrepNapiRunner = {
253
368
  break;
254
369
  const node = match;
255
370
  const range = node.range();
256
- const weight = rule.metadata?.weight || 3;
257
- const severity = weight >= 4 ? "error" : "warning";
371
+ const severity = rule.severity === "error" ? "error" : "warning";
258
372
  diagnostics.push({
259
373
  id: `ast-grep-napi-${range.start.line}-${rule.id}`,
260
374
  message: `[${rule.metadata?.category || "slop"}] ${rule.message || rule.id}`,
@@ -276,10 +390,15 @@ const astGrepNapiRunner = {
276
390
  }
277
391
  }
278
392
  }
393
+ const hasBlocking = diagnostics.some((d) => d.semantic === "blocking");
279
394
  return {
280
395
  status: "succeeded",
281
396
  diagnostics,
282
- semantic: diagnostics.length > 0 ? "warning" : "none",
397
+ semantic: hasBlocking
398
+ ? "blocking"
399
+ : diagnostics.length > 0
400
+ ? "warning"
401
+ : "none",
283
402
  };
284
403
  },
285
404
  };
@@ -64,7 +64,8 @@ function riskyOperation() {
64
64
  console.log("NAPI result diagnostics count:", napiResult.diagnostics?.length);
65
65
  // Should complete successfully (not skipped, not failed)
66
66
  expect(napiResult.status).toBe("succeeded");
67
- expect(napiResult.semantic).toBe("warning"); // Has findings, so marked as warning
67
+ // Semantic reflects the highest severity: "blocking" if any error-severity rules matched
68
+ expect(["warning", "blocking"]).toContain(napiResult.semantic);
68
69
  // Log findings
69
70
  console.log("NAPI found:", napiResult.diagnostics.length, "issues");
70
71
  console.log("\n=== NAPI FINDINGS ===");
@@ -83,7 +83,8 @@ function riskyOperation() {
83
83
 
84
84
  // Should complete successfully (not skipped, not failed)
85
85
  expect(napiResult.status).toBe("succeeded");
86
- expect(napiResult.semantic).toBe("warning"); // Has findings, so marked as warning
86
+ // Semantic reflects the highest severity: "blocking" if any error-severity rules matched
87
+ expect(["warning", "blocking"]).toContain(napiResult.semantic);
87
88
 
88
89
  // Log findings
89
90
  console.log("NAPI found:", napiResult.diagnostics.length, "issues");