pi-lens 3.8.38 → 3.8.39

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 (80) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/clients/cache-manager.ts +4 -1
  4. package/clients/dispatch/fact-scheduler.ts +2 -1
  5. package/clients/dispatch/fact-store.ts +4 -6
  6. package/clients/dispatch/integration.ts +2 -5
  7. package/clients/dispatch/rules/quality-rules.ts +10 -15
  8. package/clients/dispatch/runners/biome-check.ts +1 -1
  9. package/clients/dispatch/runners/eslint.ts +1 -1
  10. package/clients/dispatch/runners/lsp.ts +1 -5
  11. package/clients/dispatch/runners/psscriptanalyzer.ts +1 -1
  12. package/clients/dispatch/runners/similarity.ts +1 -4
  13. package/clients/dispatch/utils/lsp-diagnostics.ts +3 -9
  14. package/clients/formatters.ts +12 -7
  15. package/clients/lsp/index.ts +8 -10
  16. package/clients/lsp/launch.ts +5 -8
  17. package/clients/lsp/server.ts +1 -1
  18. package/clients/pipeline.ts +6 -11
  19. package/clients/read-guard.ts +5 -6
  20. package/clients/runtime-coordinator.ts +2 -2
  21. package/clients/runtime-tool-result.ts +1 -3
  22. package/clients/secrets-scanner.ts +1 -1
  23. package/clients/session-summary.ts +1 -1
  24. package/clients/tree-sitter-query-loader.ts +3 -2
  25. package/commands/booboo.ts +15 -14
  26. package/index.ts +5 -5
  27. package/package.json +1 -1
  28. package/rules/rule-catalog.json +52 -1
  29. package/rules/tree-sitter-queries/abap/delete-where.yml +46 -0
  30. package/rules/tree-sitter-queries/c/case-range-multiple-values.yml +65 -0
  31. package/rules/tree-sitter-queries/c/goto-into-block.yml +72 -0
  32. package/rules/tree-sitter-queries/c/goto-label-order.yml +65 -0
  33. package/rules/tree-sitter-queries/cobol/alter-statement.yml +39 -0
  34. package/rules/tree-sitter-queries/cpp/no-auto-ptr.yml +49 -0
  35. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +59 -0
  36. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +57 -0
  37. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +52 -0
  38. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +58 -0
  39. package/rules/tree-sitter-queries/csharp/async-await-identifiers.yml +50 -0
  40. package/rules/tree-sitter-queries/csharp/is-with-this.yml +52 -0
  41. package/rules/tree-sitter-queries/csharp/no-dangerous-get-handle.yml +59 -0
  42. package/rules/tree-sitter-queries/csharp/no-operator-eq-reference.yml +60 -0
  43. package/rules/tree-sitter-queries/csharp/no-thread-resume-suspend.yml +60 -0
  44. package/rules/tree-sitter-queries/css/calc-spacing.yml +50 -0
  45. package/rules/tree-sitter-queries/java/junit-call-super.yml +63 -0
  46. package/rules/tree-sitter-queries/java/main-should-not-throw.yml +63 -0
  47. package/rules/tree-sitter-queries/java/no-clone-override.yml +60 -0
  48. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +74 -0
  49. package/rules/tree-sitter-queries/java/no-exit-methods.yml +50 -0
  50. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +66 -0
  51. package/rules/tree-sitter-queries/java/no-future-keywords.yml +49 -0
  52. package/rules/tree-sitter-queries/java/no-threadgroup.yml +52 -0
  53. package/rules/tree-sitter-queries/java/no-threads-in-constructors.yml +73 -0
  54. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +58 -0
  55. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +55 -0
  56. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +75 -0
  57. package/rules/tree-sitter-queries/java/springboot-default-package.yml +69 -0
  58. package/rules/tree-sitter-queries/java/switch-fall-through.yml +70 -0
  59. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +62 -0
  60. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +52 -0
  61. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +49 -0
  62. package/rules/tree-sitter-queries/php/no-exit-die.yml +52 -0
  63. package/rules/tree-sitter-queries/php/this-in-static-context.yml +75 -0
  64. package/rules/tree-sitter-queries/plsql/delete-update-where.yml +56 -0
  65. package/rules/tree-sitter-queries/plsql/end-loop-semicolon.yml +51 -0
  66. package/rules/tree-sitter-queries/plsql/fetch-bulk-collect-limit.yml +47 -0
  67. package/rules/tree-sitter-queries/plsql/forallsave-exceptions.yml +51 -0
  68. package/rules/tree-sitter-queries/plsql/no-synchronize.yml +47 -0
  69. package/rules/tree-sitter-queries/plsql/not-null-initialization.yml +59 -0
  70. package/rules/tree-sitter-queries/plsql/raise-application-error-codes.yml +50 -0
  71. package/rules/tree-sitter-queries/python/exit-signature-check.yml +66 -0
  72. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +57 -0
  73. package/rules/tree-sitter-queries/python/iter-return-iterator.yml +59 -0
  74. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +67 -0
  75. package/rules/tree-sitter-queries/python/return-in-generator.yml +58 -0
  76. package/rules/tree-sitter-queries/python/return-in-init.yml +59 -0
  77. package/rules/tree-sitter-queries/python/send-file-mimetype.yml +53 -0
  78. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +57 -0
  79. package/tools/lsp-navigation.js +14 -16
  80. package/tools/lsp-navigation.ts +14 -18
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.39] - 2026-05-02
8
+
9
+ ### Fixed
10
+
11
+ - **Context injection now prepends guidance before the user prompt** — pi-lens previously appended session guidance after the user's message; provider bridges that treat the last message as the active user action would demote the real request. Guidance is now prepended so the user's prompt stays last. (PR #48 by @tifandotme)
12
+ - **jscpd no longer runs on YAML/JSON/Markdown files** — `getFilesForJscpd` now filters to source code extensions only, preventing multi-second delays at `turn_end` when editing rule YAMLs or config files.
13
+ - **ReDoS S5852 final (gleam/zig parsers)** — rewrote `gleamRe` and `zigRe` as line-by-line parsers, eliminating the multiline flag that SonarCloud continued to flag despite `[ \t]*` substitution.
14
+ - **SonarCloud MAJOR code smells (batch 1 & 2)** — `readonly` members, `void` operator removals, nested ternaries, nested template literals, optional chains, duplicate branches, and redundant type alias across 15+ files.
15
+ - **9 tree-sitter query bugs in new rule files** — predicate outside outermost parens (`cpp/no-auto-ptr`); false-positive `post_filter` gate added (`cpp/no-confused-move-forward`); leaf-node child match removed (`php/this-in-static-context`); invalid node name `class_hereditary` replaced (`java/no-field-shadowing`); field order corrected (`java/no-wait-notify-on-thread`); duplicate `modifiers` blocks merged (`java/spring-session-attributes-setcomplete`); invalid anonymous-node field label removed (`csharp/is-with-this`); inline alternation replaced with two patterns (`python/in-operator-unsupported`); adjacent sibling requirement removed, delegated to `post_filter` (`python/return-in-generator`).
16
+
7
17
  ## [3.8.38] - 2026-05-02
8
18
 
9
19
  ### Added
package/README.md CHANGED
@@ -41,7 +41,7 @@ At `session_start`, pi-lens:
41
41
  - applies language-aware startup defaults for tool preinstall
42
42
  - warms caches and optional indexes (with overlap/session guardrails)
43
43
  - emits missing-tool install hints for detected languages when relevant
44
- - injects session guidance through internal context (non-user channel) to reduce acknowledgement-only first responses
44
+ - prepends session guidance before the user's prompt so provider bridges keep the real prompt active
45
45
  - opens `warmFiles` (if configured in `.pi-lens/lsp.json`) to seed lazy-indexing language servers like clangd before the first symbol query
46
46
 
47
47
  For one-shot print sessions (for example `pi --print ...`), pi-lens auto-uses a quick startup path that skips heavy bootstrap work to reduce startup latency. Override with `PI_LENS_STARTUP_MODE=full|minimal|quick`.
@@ -307,10 +307,13 @@ export class CacheManager {
307
307
 
308
308
  /**
309
309
  * Get files that need jscpd re-scan (any edit).
310
+ * Only returns source code files jscpd can meaningfully analyse.
310
311
  */
311
312
  getFilesForJscpd(cwd: string): string[] {
312
313
  const state = this.readTurnState(cwd);
313
- return Object.keys(state.files);
314
+ return Object.keys(state.files).filter((f) =>
315
+ /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|cs|php|cpp|c|h|hpp|swift|kt)$/.test(f),
316
+ );
314
317
  }
315
318
 
316
319
  /**
@@ -61,7 +61,8 @@ export function scheduleProviders(providers: FactProvider[]): FactProvider[] {
61
61
  }
62
62
  }
63
63
  }
64
- wave = nextWave.sort((a, b) => a.id.localeCompare(b.id));
64
+ nextWave.sort((a, b) => a.id.localeCompare(b.id));
65
+ wave = nextWave;
65
66
  }
66
67
 
67
68
  if (result.length < providers.length) {
@@ -1,7 +1,5 @@
1
1
  import { normalizeMapKey } from "../path-utils.js";
2
2
 
3
- type FactValue = unknown;
4
-
5
3
  export interface ReadonlyFactStore {
6
4
  getFileFact<T>(filePath: string, factId: string): T | undefined;
7
5
  hasFileFact(filePath: string, factId: string): boolean;
@@ -10,8 +8,8 @@ export interface ReadonlyFactStore {
10
8
  }
11
9
 
12
10
  export class FactStore implements ReadonlyFactStore {
13
- private readonly fileFacts = new Map<string, Map<string, FactValue>>();
14
- private readonly sessionFacts = new Map<string, FactValue>();
11
+ private readonly fileFacts = new Map<string, Map<string, unknown>>();
12
+ private readonly sessionFacts = new Map<string, unknown>();
15
13
 
16
14
  // All file-keyed methods normalize the path internally via normalizeMapKey().
17
15
  // Callers always pass raw/resolved paths — normalization is not their concern.
@@ -20,7 +18,7 @@ export class FactStore implements ReadonlyFactStore {
20
18
  return this.fileFacts.get(normalizeMapKey(filePath))?.get(factId) as T | undefined;
21
19
  }
22
20
 
23
- setFileFact(filePath: string, factId: string, value: FactValue): void {
21
+ setFileFact(filePath: string, factId: string, value: unknown): void {
24
22
  const key = normalizeMapKey(filePath);
25
23
  let facts = this.fileFacts.get(key);
26
24
  if (!facts) {
@@ -51,7 +49,7 @@ export class FactStore implements ReadonlyFactStore {
51
49
  return this.sessionFacts.get(factId) as T | undefined;
52
50
  }
53
51
 
54
- setSessionFact(factId: string, value: FactValue): void {
52
+ setSessionFact(factId: string, value: unknown): void {
55
53
  this.sessionFacts.set(factId, value);
56
54
  }
57
55
 
@@ -479,7 +479,7 @@ export async function computeCascadeForFile(
479
479
  .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
480
480
  .sort((a, b) => {
481
481
  const rank = (p: string) =>
482
- importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
482
+ importerSet.has(p) ? 0 : (callerSet.has(p) ? 1 : 2);
483
483
  return rank(a) - rank(b);
484
484
  })
485
485
  .slice(0, MAX_FILES);
@@ -584,10 +584,7 @@ export async function computeCascadeForFile(
584
584
  // write sequence. A new write (higher writeSeq) invalidates the cache entry.
585
585
  const cached =
586
586
  writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
587
- if (
588
- cached != null &&
589
- cached.turnSeq === turnSeq
590
- ) {
587
+ if (cached?.turnSeq === turnSeq) {
591
588
  producedLspData = true;
592
589
  const durationMs = Date.now() - neighborStart;
593
590
  logCascade({
@@ -149,21 +149,16 @@ export const noBooleanParamsRule: FactRule = {
149
149
  ts.isIdentifier(param.name) ? param.name.text : "";
150
150
  if (BOOLEAN_PREFIX_OK.test(name)) continue;
151
151
 
152
- let isBoolean = false;
153
- if (param.type.kind === ts.SyntaxKind.BooleanKeyword) {
154
- isBoolean = true;
155
- } else if (
156
- ts.isUnionTypeNode(param.type) &&
157
- param.type.types.every(
158
- (t) =>
159
- t.kind === ts.SyntaxKind.BooleanKeyword ||
160
- (ts.isLiteralTypeNode(t) &&
161
- (t.literal.kind === ts.SyntaxKind.TrueKeyword ||
162
- t.literal.kind === ts.SyntaxKind.FalseKeyword)),
163
- )
164
- ) {
165
- isBoolean = true;
166
- }
152
+ const isBoolean =
153
+ param.type.kind === ts.SyntaxKind.BooleanKeyword ||
154
+ (ts.isUnionTypeNode(param.type) &&
155
+ param.type.types.every(
156
+ (t) =>
157
+ t.kind === ts.SyntaxKind.BooleanKeyword ||
158
+ (ts.isLiteralTypeNode(t) &&
159
+ (t.literal.kind === ts.SyntaxKind.TrueKeyword ||
160
+ t.literal.kind === ts.SyntaxKind.FalseKeyword)),
161
+ ));
167
162
 
168
163
  if (!isBoolean) continue;
169
164
  const { line, character } = sf.getLineAndCharacterOfPosition(param.getStart(sf));
@@ -134,7 +134,7 @@ const biomeCheckJsonRunner: RunnerDefinition = {
134
134
  diagnostics: [
135
135
  {
136
136
  id: "biome:parse-error:1",
137
- message: `Biome JSON parse failed: ${parsed.parseError}${preview ? ` (output preview: ${preview})` : ""}`,
137
+ message: "Biome JSON parse failed: " + parsed.parseError + (preview ? " (output preview: " + preview + ")" : ""),
138
138
  filePath: ctx.filePath,
139
139
  line: 1,
140
140
  column: 1,
@@ -125,7 +125,7 @@ const eslintRunner: RunnerDefinition = {
125
125
  diagnostics: [
126
126
  {
127
127
  id: "eslint:parse-error:1",
128
- message: `ESLint JSON parse failed: ${parsed.parseError}${preview ? ` (output preview: ${preview})` : ""}`,
128
+ message: "ESLint JSON parse failed: " + parsed.parseError + (preview ? " (output preview: " + preview + ")" : ""),
129
129
  filePath: ctx.filePath,
130
130
  line: 1,
131
131
  column: 1,
@@ -212,11 +212,7 @@ const lspRunner: RunnerDefinition = {
212
212
  );
213
213
 
214
214
  const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
215
- const resultSemantic = hasErrors
216
- ? "blocking"
217
- : diagnostics.length > 0
218
- ? "warning"
219
- : "none";
215
+ const resultSemantic = hasErrors ? "blocking" : (diagnostics.length > 0 ? "warning" : "none");
220
216
 
221
217
  return {
222
218
  status: hasErrors ? "failed" : "succeeded",
@@ -86,7 +86,7 @@ function parsePSAnalyzerOutput(raw: string, filePath: string): Diagnostic[] {
86
86
  .map((item) => {
87
87
  const sev = (item.Severity ?? "Warning").toLowerCase();
88
88
  const severity: "error" | "warning" | "info" =
89
- sev === "error" || sev === "parseerror" ? "error" : sev === "information" ? "info" : "warning";
89
+ (sev === "error" || sev === "parseerror") ? "error" : (sev === "information" ? "info" : "warning");
90
90
  const rule = item.RuleName ?? "PSScriptAnalyzer";
91
91
  return {
92
92
  id: `psscriptanalyzer-${rule}-${item.Line}`,
@@ -394,10 +394,7 @@ function getArrowSignature(
394
394
  | import("typescript").ArrowFunction
395
395
  | import("typescript").FunctionExpression,
396
396
  ): string {
397
- const params = node.parameters
398
- .map((p) => (tsModule.isIdentifier(p.name) ? p.name.text : "param"))
399
- .join(", ");
400
- return `(${params})`;
397
+ return getSignature(tsModule, node as unknown as import("typescript").FunctionDeclaration);
401
398
  }
402
399
 
403
400
  // ============================================================================
@@ -16,16 +16,10 @@ export function convertLspDiagnostics(
16
16
  return diags
17
17
  .filter((d) => d.range?.start?.line !== undefined)
18
18
  .map((d, idx) => {
19
- const severity =
20
- d.severity === 1
21
- ? "error"
22
- : d.severity === 2
23
- ? "warning"
24
- : d.severity === 4
25
- ? "hint"
26
- : "info";
19
+ const severityMap: Record<number, "error" | "warning" | "hint"> = { 1: "error", 2: "warning", 4: "hint" };
20
+ const severity: "error" | "warning" | "info" | "hint" = severityMap[d.severity] ?? "info";
27
21
  const semantic =
28
- d.severity === 1 ? "blocking" : d.severity === 2 ? "warning" : "none";
22
+ d.severity === 1 ? "blocking" : (d.severity === 2 ? "warning" : "none");
29
23
  const code = String(d.code ?? "unknown");
30
24
  const source = options.source ?? d.source ?? tool;
31
25
  const hasSuggestion = options.fixSuggestionByIndex?.has(idx) ?? false;
@@ -54,7 +54,7 @@ async function tryLazyInstallFormatterTool(
54
54
  const ok = !res.error && res.status === 0;
55
55
  if (!ok) {
56
56
  console.error(
57
- `[format] lazy-install rubocop failed: ${res.error?.message ?? res.stderr ?? `exit ${res.status}`}`,
57
+ `[format] lazy-install rubocop failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`,
58
58
  );
59
59
  }
60
60
  return ok;
@@ -67,7 +67,7 @@ async function tryLazyInstallFormatterTool(
67
67
  const ok = !res.error && res.status === 0;
68
68
  if (!ok) {
69
69
  console.error(
70
- `[format] lazy-install rustfmt failed: ${res.error?.message ?? res.stderr ?? `exit ${res.status}`}`,
70
+ `[format] lazy-install rustfmt failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`,
71
71
  );
72
72
  }
73
73
  return ok;
@@ -919,11 +919,16 @@ export async function getFormattersForFile(
919
919
 
920
920
  const enabled = selected ? [selected] : [];
921
921
 
922
- const selectionReason = selected
923
- ? (formatterPolicy
924
- ? (candidateFormatters.some((f) => hasExplicitFormatterConfig(f.name, cwd)) ? "explicit-config" : "smart-default")
925
- : "detect")
926
- : "none";
922
+ let selectionReason: string;
923
+ if (!selected) {
924
+ selectionReason = "none";
925
+ } else if (!formatterPolicy) {
926
+ selectionReason = "detect";
927
+ } else {
928
+ selectionReason = candidateFormatters.some((f) => hasExplicitFormatterConfig(f.name, cwd))
929
+ ? "explicit-config"
930
+ : "smart-default";
931
+ }
927
932
  logLatency({
928
933
  type: "phase",
929
934
  phase: "formatter_selected",
@@ -135,13 +135,13 @@ export interface LSPTouchFileOptions {
135
135
 
136
136
  export class LSPService {
137
137
  private state: LSPState;
138
- private workspaceProbeLogged = new Set<string>();
139
- private warmStartLogged = new Set<string>();
140
- private optionalFailureLogged = new Set<string>();
141
- private optionalDisabled = new Set<string>();
138
+ private readonly workspaceProbeLogged = new Set<string>();
139
+ private readonly warmStartLogged = new Set<string>();
140
+ private readonly optionalFailureLogged = new Set<string>();
141
+ private readonly optionalDisabled = new Set<string>();
142
142
  /** Consecutive failure counts for exponential backoff circuit breaker */
143
- private failureCounts = new Map<string, number>();
144
- private recentTouches = new Map<
143
+ private readonly failureCounts = new Map<string, number>();
144
+ private readonly recentTouches = new Map<
145
145
  string,
146
146
  { fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
147
147
  >();
@@ -309,7 +309,7 @@ export class LSPService {
309
309
  if (!root) continue;
310
310
  const key = `${server.id}:${normalizeMapKey(root)}`;
311
311
  const existing = this.state.clients.get(key);
312
- if (existing && existing.isAlive()) {
312
+ if (existing?.isAlive()) {
313
313
  return { client: existing, info: server };
314
314
  }
315
315
  }
@@ -392,9 +392,7 @@ export class LSPService {
392
392
  }
393
393
  }
394
394
 
395
- private shouldAllowInstall(filePath: string, root: string): boolean {
396
- void filePath;
397
- void root;
395
+ private shouldAllowInstall(_filePath: string, _root: string): boolean {
398
396
  return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
399
397
  }
400
398
 
@@ -402,7 +402,7 @@ function _attachErrorHandler(
402
402
  proc.on("error", (err) => {
403
403
  if (logContext) {
404
404
  logSessionStart(
405
- `lsp process ${context}: spawn-error command=${logContext.command} args=${JSON.stringify(logContext.args)} cwd=${logContext.cwd} pid=${logContext.pid ?? 0} error=${err.message}${stderrPreview ? ` stderr=${compactLogValue(stderrPreview)}` : ""}`,
405
+ "lsp process " + context + ": spawn-error command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + " error=" + err.message + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
406
406
  );
407
407
  }
408
408
 
@@ -422,12 +422,12 @@ function _attachErrorHandler(
422
422
  if (code !== 0 && code !== null) {
423
423
  if (logContext) {
424
424
  logSessionStart(
425
- `lsp process ${context}: closed code=${code}${signal ? ` signal=${signal}` : ""} command=${logContext.command} args=${JSON.stringify(logContext.args)} cwd=${logContext.cwd} pid=${logContext.pid ?? 0}${stderrPreview ? ` stderr=${compactLogValue(stderrPreview)}` : ""}`,
425
+ "lsp process " + context + ": closed code=" + code + (signal ? " signal=" + signal : "") + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
426
426
  );
427
427
  }
428
428
  } else if (signal && logContext) {
429
429
  logSessionStart(
430
- `lsp process ${context}: closed signal=${signal} command=${logContext.command} args=${JSON.stringify(logContext.args)} cwd=${logContext.cwd} pid=${logContext.pid ?? 0}${stderrPreview ? ` stderr=${compactLogValue(stderrPreview)}` : ""}`,
430
+ "lsp process " + context + ": closed signal=" + signal + " command=" + logContext.command + " args=" + JSON.stringify(logContext.args) + " cwd=" + logContext.cwd + " pid=" + (logContext.pid ?? 0) + (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
431
431
  );
432
432
  }
433
433
  });
@@ -467,11 +467,8 @@ export async function launchLSP(
467
467
  // - If already absolute, use as-is
468
468
  // - If it's a simple command (no path separators), let system find it via PATH
469
469
  // - Otherwise, resolve relative to cwd
470
- const explicitCommand = path.isAbsolute(command)
471
- ? command
472
- : command.includes(path.sep) || command.includes("/")
473
- ? path.resolve(cwd, command)
474
- : command;
470
+ const isRelativePath = !path.isAbsolute(command) && (command.includes(path.sep) || command.includes("/"));
471
+ const explicitCommand = isRelativePath ? path.resolve(cwd, command) : command;
475
472
  const resolvedCommand =
476
473
  !path.isAbsolute(command) &&
477
474
  !command.includes(path.sep) &&
@@ -89,7 +89,7 @@ function logSessionStart(message: string): void {
89
89
  return;
90
90
  }
91
91
  const line = `[${new Date().toISOString()}] ${message}\n`;
92
- void mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
92
+ mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
93
93
  .then(() => appendFile(SESSIONSTART_LOG, line))
94
94
  .catch(() => {
95
95
  // best-effort logging
@@ -96,7 +96,7 @@ function diffProjectSnapshot(root: string, before: FileSnapshot): string[] {
96
96
  const changed = new Set<string>();
97
97
  for (const [filePath, next] of after) {
98
98
  const prev = before.get(filePath);
99
- if (!prev || prev.mtimeMs !== next.mtimeMs || prev.size !== next.size) {
99
+ if (prev?.mtimeMs !== next.mtimeMs || prev?.size !== next.size) {
100
100
  changed.add(filePath);
101
101
  }
102
102
  }
@@ -725,7 +725,7 @@ export async function runFormatPhase(
725
725
  if (result.anyChanged) {
726
726
  formatChanged = true;
727
727
  dbg(
728
- `autoformat: ${result.formatters.map((f) => `${f.name}(${f.changed ? "changed" : "unchanged"})`).join(", ")}`,
728
+ "autoformat: " + result.formatters.map((f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")").join(", "),
729
729
  );
730
730
  }
731
731
  if (!result.allSucceeded) {
@@ -734,7 +734,7 @@ export async function runFormatPhase(
734
734
  ...failures.map((f) => `${f.name}: ${f.error ?? "unknown error"}`),
735
735
  );
736
736
  dbg(
737
- `autoformat: ${failures.map((f) => `${f.name} failed: ${f.error ?? "unknown error"}`).join("; ")}`,
737
+ "autoformat: " + failures.map((f) => f.name + " failed: " + (f.error ?? "unknown error")).join("; "),
738
738
  );
739
739
  }
740
740
  } catch (err) {
@@ -946,14 +946,9 @@ export async function runPipeline(
946
946
  const changedList = [...piChangedFiles].map((changedFile) =>
947
947
  toRunnerDisplayPath(cwd, changedFile),
948
948
  );
949
- const fileList = changedList.length
950
- ? `\nModified files:\n${changedList
951
- .slice(0, 8)
952
- .map((f) => ` - ${f}`)
953
- .join(
954
- "\n",
955
- )}${changedList.length > 8 ? `\n - ... and ${changedList.length - 8} more` : ""}`
956
- : "";
949
+ const topFiles = changedList.slice(0, 8).map((f) => " - " + f).join("\n");
950
+ const overflow = changedList.length > 8 ? "\n - ... and " + (changedList.length - 8) + " more" : "";
951
+ const fileList = changedList.length ? "\nModified files:\n" + topFiles + overflow : "";
957
952
  output += `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
958
953
  }
959
954
  phase.end("dispatch_lint", {
@@ -210,9 +210,12 @@ export class ReadGuard {
210
210
  const lastRead = fileReads[fileReads.length - 1];
211
211
  const [editStart, editEnd] = touchedLines;
212
212
  const lastReadEnd = lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
213
+ const symbolCtx = lastRead.enclosingSymbol
214
+ ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)`
215
+ : "";
213
216
  const verdict = this.blockOrWarn(
214
217
  "out-of-range",
215
- `🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${lastRead.enclosingSymbol ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)` : ""}, but your edit touches lines ${editStart}-${editEnd}.\n\nThe edit target is outside the context you previously read.\nTo proceed:\n 1. Read the relevant section: \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\`\n 2. Or read the full file: \`read path="${filePath}"\``,
218
+ `🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${symbolCtx}, but your edit touches lines ${editStart}-${editEnd}.\n\nThe edit target is outside the context you previously read.\nTo proceed:\n 1. Read the relevant section: \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\`\n 2. Or read the full file: \`read path="${filePath}"\``,
216
219
  {
217
220
  editRange: touchedLines,
218
221
  readRanges: fileReads.map((r) => ({
@@ -470,11 +473,7 @@ export class ReadGuard {
470
473
  const reads = this.reads.get(filePath) ?? [];
471
474
  logReadGuardEvent({
472
475
  event:
473
- verdict.action === "allow"
474
- ? "edit_allowed"
475
- : verdict.action === "warn"
476
- ? "edit_warned"
477
- : "edit_blocked",
476
+ verdict.action === "allow" ? "edit_allowed" : (verdict.action === "warn" ? "edit_warned" : "edit_blocked"),
478
477
  sessionId: this.sessionId,
479
478
  filePath,
480
479
  metadata: {
@@ -44,7 +44,7 @@ export class RuntimeCoordinator {
44
44
  };
45
45
  private _complexityBaselines = new Map<string, FileComplexity>();
46
46
  private _fixedThisTurn = new Set<string>();
47
- private _reportedThisTurn = new Set<string>();
47
+ private readonly _reportedThisTurn = new Set<string>();
48
48
  private _projectRulesScan: RuleScanResult = {
49
49
  rules: [],
50
50
  hasCustomRules: false,
@@ -56,7 +56,7 @@ export class RuntimeCoordinator {
56
56
  private _gitGuardHasBlockers = false;
57
57
  private _gitGuardSummary = "";
58
58
  private _readGuard: ReadGuard | null = null;
59
- private _pendingDeferredFormatFiles = new Map<string, DeferredFormatRecord>();
59
+ private readonly _pendingDeferredFormatFiles = new Map<string, DeferredFormatRecord>();
60
60
  private readonly _lspReadWarmState = new Map<
61
61
  string,
62
62
  { status: "warming" | "ready"; ts: number }
@@ -108,9 +108,7 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
108
108
  const rawFilePath = (event.input as { path?: string }).path;
109
109
  const workspaceRoot = runtime.projectRoot || process.cwd();
110
110
  const filePath = rawFilePath
111
- ? path.isAbsolute(rawFilePath)
112
- ? rawFilePath
113
- : path.resolve(workspaceRoot, rawFilePath)
111
+ ? (path.isAbsolute(rawFilePath) ? rawFilePath : path.resolve(workspaceRoot, rawFilePath))
114
112
  : rawFilePath;
115
113
  const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath);
116
114
 
@@ -126,7 +126,7 @@ const SECRET_PATTERNS: SecretPattern[] = [
126
126
  },
127
127
  {
128
128
  pattern:
129
- /\b(secret|api_?key|token|access_?key)\b\s*[:=]\s*["']([a-zA-Z0-9_\-/.]{8,})["']/gi,
129
+ /\b(secret|api_?key|token|access_?key)\b\s*[:=]\s*["']([a-zA-Z0-9_./-]{8,})["']/gi,
130
130
  name: "hardcoded-secret",
131
131
  message: "Possible hardcoded secret or API key",
132
132
  },
@@ -46,7 +46,7 @@ export function formatSlopScoreSummary(summary: SlopScoreSummary): string {
46
46
  const topRules = summary.ruleCounts.slice(0, 3);
47
47
  const detail =
48
48
  topRules.length > 0
49
- ? ` (${topRules.map((entry) => `${entry.ruleId} ×${entry.count}`).join(", ")})`
49
+ ? " (" + topRules.map((entry) => entry.ruleId + " ×" + entry.count).join(", ") + ")"
50
50
  : "";
51
51
 
52
52
  return `Slop score: ${summary.scorePerKloc.toFixed(1)}/KLOC${detail}`;
@@ -243,8 +243,9 @@ export class TreeSitterQueryLoader {
243
243
  const nestedMatch = nextLine.match(/^\s+(\w+):\s*(.+)$/);
244
244
  if (nestedMatch) {
245
245
  let nv = nestedMatch[2].trim();
246
- if (nv.startsWith('"') && nv.endsWith('"')) nv = nv.slice(1, -1);
247
- else if (nv.startsWith("'") && nv.endsWith("'")) nv = nv.slice(1, -1);
246
+ if ((nv.startsWith('"') && nv.endsWith('"')) || (nv.startsWith("'") && nv.endsWith("'"))) {
247
+ nv = nv.slice(1, -1);
248
+ }
248
249
  nestedObj[nestedMatch[1]] = nv;
249
250
  }
250
251
  }
@@ -1149,9 +1149,7 @@ export async function handleBooboo(
1149
1149
  inner.spans?.[0];
1150
1150
  if (!span) continue;
1151
1151
  const absFile = span.file_name
1152
- ? path.isAbsolute(span.file_name)
1153
- ? span.file_name
1154
- : path.join(targetPath, span.file_name)
1152
+ ? (path.isAbsolute(span.file_name) ? span.file_name : path.join(targetPath, span.file_name))
1155
1153
  : targetPath;
1156
1154
  issues.push({
1157
1155
  file: path.relative(targetPath, absFile),
@@ -1183,9 +1181,7 @@ export async function handleBooboo(
1183
1181
  for (const diag of json?.generalDiagnostics ?? []) {
1184
1182
  if (!["error", "warning"].includes(diag.severity)) continue;
1185
1183
  const absFile = diag.file
1186
- ? path.isAbsolute(diag.file)
1187
- ? diag.file
1188
- : path.join(targetPath, diag.file)
1184
+ ? (path.isAbsolute(diag.file) ? diag.file : path.join(targetPath, diag.file))
1189
1185
  : targetPath;
1190
1186
  if (shouldIncludeFile(absFile)) {
1191
1187
  issues.push({
@@ -1331,7 +1327,7 @@ export async function handleBooboo(
1331
1327
  );
1332
1328
  const output = (result.stdout || "") + (result.stderr || "");
1333
1329
  const csRe =
1334
- /^([^\s(]+\.cs)\((\d+),(\d+)\):\s+(error|warning)\s+([A-Z]+\d+):\s+([^\[]+)/gm;
1330
+ /^([^\s(]+\.cs)\((\d+),(\d+)\):\s+(error|warning)\s+([A-Z]+\d+):\s+([^[]+)/gm;
1335
1331
  for (const m of output.matchAll(csRe)) {
1336
1332
  const [, file, line, col, sev, code, msg] = m;
1337
1333
  const absFile = path.isAbsolute(file)
@@ -1394,10 +1390,13 @@ export async function handleBooboo(
1394
1390
  timeout: 60_000,
1395
1391
  });
1396
1392
  const output = (result.stdout || "") + (result.stderr || "");
1397
- const gleamRe =
1398
- /^([^:\n]+):(\d+):(\d+)[ \t]*(?:error|warning)[^\n]*\n([^\n]+)/gm;
1399
- for (const m of output.matchAll(gleamRe)) {
1400
- const [, file, line, col, msg] = m;
1393
+ const gleamLines = output.split("\n");
1394
+ const gleamHeaderRe = /^([^:]+):(\d+):(\d+)[ \t]*(?:error|warning)/;
1395
+ for (let i = 0; i < gleamLines.length; i++) {
1396
+ const m = gleamHeaderRe.exec(gleamLines[i]);
1397
+ if (!m) continue;
1398
+ const [, file, line, col] = m;
1399
+ const msg = gleamLines[i + 1]?.trim() ?? "";
1401
1400
  const absFile = path.isAbsolute(file)
1402
1401
  ? file
1403
1402
  : path.join(targetPath, file);
@@ -1408,7 +1407,7 @@ export async function handleBooboo(
1408
1407
  col: parseInt(col, 10),
1409
1408
  severity: "error",
1410
1409
  code: "gleam",
1411
- message: msg.trim(),
1410
+ message: msg,
1412
1411
  compiler: "gleam check",
1413
1412
  });
1414
1413
  }
@@ -1426,8 +1425,10 @@ export async function handleBooboo(
1426
1425
  timeout: 120_000,
1427
1426
  });
1428
1427
  const output = (result.stdout || "") + (result.stderr || "");
1429
- const zigRe = /^([^:\n]+):(\d+):(\d+):[ \t]*(error|warning|note):[ \t]*([^\n]+)/gm;
1430
- for (const m of output.matchAll(zigRe)) {
1428
+ const zigLineRe = /^([^:]+):(\d+):(\d+):[ \t]*(error|warning|note):[ \t]*(.+)/;
1429
+ for (const zigLine of output.split("\n")) {
1430
+ const m = zigLineRe.exec(zigLine);
1431
+ if (!m) continue;
1431
1432
  const [, file, line, col, sev, msg] = m;
1432
1433
  const absFile = path.isAbsolute(file)
1433
1434
  ? file
package/index.ts CHANGED
@@ -1148,7 +1148,7 @@ export default function (pi: ExtensionAPI) {
1148
1148
  if (dupeWarnings.length > 0) {
1149
1149
  return {
1150
1150
  block: true,
1151
- reason: `🔴 STOP - Redefining existing export(s). Import instead:\n${dupeWarnings.map((w) => `${w}`).join("\n")}`,
1151
+ reason: "🔴 STOP - Redefining existing export(s). Import instead:\n" + dupeWarnings.map((w) => "" + w).join("\n"),
1152
1152
  };
1153
1153
  }
1154
1154
 
@@ -1321,9 +1321,9 @@ export default function (pi: ExtensionAPI) {
1321
1321
  // --- Inject turn-end findings into next agent turn ---
1322
1322
  // jscpd, madge, and turn-end delta results are cached at turn_end and consumed here
1323
1323
  // via the context event, which fires before each provider request.
1324
- // Important: context handlers must APPEND to the existing message list, not replace it.
1325
- // Replacing `event.messages` can drop the user's first prompt entirely, which causes
1326
- // OpenAI Responses requests to fail with: "One of input/previous_response_id/prompt/conversation_id must be provided."
1324
+ // Important: keep the user's prompt as the trailing message. Some provider bridges
1325
+ // treat the final message as the active user action, so pi-lens context must be
1326
+ // prepended instead of appended.
1327
1327
  // biome-ignore lint/suspicious/noExplicitAny: pi.on("context") overload has TS resolution bug
1328
1328
  (pi as any).on(
1329
1329
  "context",
@@ -1348,7 +1348,7 @@ export default function (pi: ExtensionAPI) {
1348
1348
  ?.messages ?? [];
1349
1349
 
1350
1350
  return {
1351
- messages: [...existingMessages, ...injectedMessages],
1351
+ messages: [...injectedMessages, ...existingMessages],
1352
1352
  };
1353
1353
  } catch (err) {
1354
1354
  dbg(`context event error: ${err}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.38",
3
+ "version": "3.8.39",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi \u2014 LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {