pi-lens 3.8.39 → 3.8.41

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 (65) hide show
  1. package/CHANGELOG.md +84 -5
  2. package/README.md +37 -1
  3. package/clients/biome-client.ts +5 -4
  4. package/clients/cache/rule-cache.ts +1 -1
  5. package/clients/complexity-client.ts +1 -1
  6. package/clients/dependency-checker.ts +1 -1
  7. package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
  8. package/clients/dispatch/dispatcher.ts +9 -0
  9. package/clients/dispatch/fact-scheduler.ts +1 -1
  10. package/clients/dispatch/integration.ts +58 -3
  11. package/clients/dispatch/runners/index.ts +2 -0
  12. package/clients/dispatch/runners/semgrep.ts +269 -0
  13. package/clients/dispatch/runners/shellcheck.ts +2 -8
  14. package/clients/dispatch/runners/tree-sitter.ts +32 -11
  15. package/clients/dispatch/tool-profile.ts +1 -0
  16. package/clients/format-service.ts +10 -0
  17. package/clients/formatters.ts +22 -8
  18. package/clients/installer/index.ts +3 -3
  19. package/clients/knip-client.ts +360 -362
  20. package/clients/lsp/aggregation.ts +91 -0
  21. package/clients/lsp/client.ts +91 -38
  22. package/clients/lsp/index.ts +88 -72
  23. package/clients/lsp/launch.ts +107 -34
  24. package/clients/lsp/server-strategies.ts +71 -0
  25. package/clients/lsp/server.ts +76 -57
  26. package/clients/path-utils.ts +17 -0
  27. package/clients/pipeline.ts +23 -5
  28. package/clients/production-readiness.ts +2 -2
  29. package/clients/read-guard-logger.ts +41 -1
  30. package/clients/read-guard-tool-lines.ts +17 -4
  31. package/clients/read-guard.ts +95 -46
  32. package/clients/runtime-agent-end.ts +3 -0
  33. package/clients/runtime-session.ts +5 -0
  34. package/clients/runtime-tool-result.ts +48 -1
  35. package/clients/runtime-turn.ts +48 -4
  36. package/clients/sanitize.ts +1 -1
  37. package/clients/semgrep-config.ts +213 -0
  38. package/clients/tool-policy.ts +1982 -1936
  39. package/clients/tree-sitter-client.ts +1 -1
  40. package/clients/widget-state.ts +283 -0
  41. package/commands/booboo.ts +34 -2
  42. package/index.ts +231 -17
  43. package/package.json +3 -2
  44. package/rules/rule-catalog.json +25 -1
  45. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  46. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  47. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  48. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  49. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  50. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  51. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  52. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  53. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  54. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  55. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  56. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  57. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  58. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  59. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  60. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  61. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  62. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  63. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  64. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  65. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
@@ -402,7 +402,19 @@ 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 " +
406
+ context +
407
+ ": spawn-error command=" +
408
+ logContext.command +
409
+ " args=" +
410
+ JSON.stringify(logContext.args) +
411
+ " cwd=" +
412
+ logContext.cwd +
413
+ " pid=" +
414
+ (logContext.pid ?? 0) +
415
+ " error=" +
416
+ err.message +
417
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
406
418
  );
407
419
  }
408
420
 
@@ -422,12 +434,37 @@ function _attachErrorHandler(
422
434
  if (code !== 0 && code !== null) {
423
435
  if (logContext) {
424
436
  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) : ""),
437
+ "lsp process " +
438
+ context +
439
+ ": closed code=" +
440
+ code +
441
+ (signal ? " signal=" + signal : "") +
442
+ " command=" +
443
+ logContext.command +
444
+ " args=" +
445
+ JSON.stringify(logContext.args) +
446
+ " cwd=" +
447
+ logContext.cwd +
448
+ " pid=" +
449
+ (logContext.pid ?? 0) +
450
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
426
451
  );
427
452
  }
428
453
  } else if (signal && logContext) {
429
454
  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) : ""),
455
+ "lsp process " +
456
+ context +
457
+ ": closed signal=" +
458
+ signal +
459
+ " command=" +
460
+ logContext.command +
461
+ " args=" +
462
+ JSON.stringify(logContext.args) +
463
+ " cwd=" +
464
+ logContext.cwd +
465
+ " pid=" +
466
+ (logContext.pid ?? 0) +
467
+ (stderrPreview ? " stderr=" + compactLogValue(stderrPreview) : ""),
431
468
  );
432
469
  }
433
470
  });
@@ -451,7 +488,7 @@ export async function launchLSP(
451
488
  command: string,
452
489
  args: string[] = [],
453
490
  options: SpawnOptions & {
454
- startupFailureWindowMs?: number,
491
+ startupFailureWindowMs?: number;
455
492
  } = {},
456
493
  ): Promise<LSPProcess> {
457
494
  const cwd = String(options.cwd ?? process.cwd());
@@ -467,7 +504,9 @@ export async function launchLSP(
467
504
  // - If already absolute, use as-is
468
505
  // - If it's a simple command (no path separators), let system find it via PATH
469
506
  // - Otherwise, resolve relative to cwd
470
- const isRelativePath = !path.isAbsolute(command) && (command.includes(path.sep) || command.includes("/"));
507
+ const isRelativePath =
508
+ !path.isAbsolute(command) &&
509
+ (command.includes(path.sep) || command.includes("/"));
471
510
  const explicitCommand = isRelativePath ? path.resolve(cwd, command) : command;
472
511
  const resolvedCommand =
473
512
  !path.isAbsolute(command) &&
@@ -645,7 +684,7 @@ export async function launchLSP(
645
684
  return DEFAULT_STARTUP_FAILURE_WINDOW_MS;
646
685
  }
647
686
  })();
648
-
687
+
649
688
  // Give shell-backed Windows launches a slightly longer window because
650
689
  // npm/cmd shims can fail asynchronously after the initial spawn succeeds.
651
690
  setTimeout(() => {
@@ -803,38 +842,72 @@ export async function launchViaPython(
803
842
  * Stop an LSP process gracefully
804
843
  */
805
844
  export async function stopLSP(handle: LSPProcess): Promise<void> {
845
+ if (handle.process.exitCode !== null || handle.process.signalCode !== null) {
846
+ return;
847
+ }
848
+
806
849
  return new Promise((resolve) => {
807
- // Send SIGTERM first
808
- handle.process.kill("SIGTERM");
809
-
810
- // Force kill after timeout
811
- const timeout = setTimeout(() => {
812
- if (!handle.process.killed) {
813
- if (isWindows && handle.pid > 0) {
814
- // SIGKILL is unreliable for cmd.exe/PowerShell child trees on Windows.
815
- // taskkill /F /T kills the process and all its children.
816
- try {
817
- nodeSpawn("taskkill", ["/F", "/T", "/PID", String(handle.pid)], {
818
- shell: false,
819
- windowsHide: true,
820
- });
821
- } catch {
822
- handle.process.kill("SIGKILL");
823
- }
824
- } else {
850
+ let settled = false;
851
+ let forceTimeout: ReturnType<typeof setTimeout> | undefined;
852
+ let giveUpTimeout: ReturnType<typeof setTimeout> | undefined;
853
+
854
+ const done = () => {
855
+ if (settled) return;
856
+ settled = true;
857
+ if (forceTimeout) clearTimeout(forceTimeout);
858
+ if (giveUpTimeout) clearTimeout(giveUpTimeout);
859
+ handle.process.off("exit", done);
860
+ handle.process.off("error", done);
861
+ resolve();
862
+ };
863
+
864
+ handle.process.once("exit", done);
865
+ handle.process.once("error", done);
866
+
867
+ const killWindowsTree = (): boolean => {
868
+ if (!isWindows || handle.pid <= 0) return false;
869
+ try {
870
+ // Absolute path avoids PATH-resolution substitution on Windows.
871
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
872
+ const killer = nodeSpawn(
873
+ taskkill,
874
+ ["/F", "/T", "/PID", String(handle.pid)],
875
+ {
876
+ shell: false,
877
+ windowsHide: true,
878
+ },
879
+ );
880
+ killer.once("error", done);
881
+ return true;
882
+ } catch {
883
+ return false;
884
+ }
885
+ };
886
+
887
+ try {
888
+ // On Windows, kill the tree first; killing the direct child can orphan
889
+ // grandchildren (e.g. tsserver.js behind a cmd/npm shim).
890
+ if (!killWindowsTree()) {
891
+ handle.process.kill("SIGTERM");
892
+ }
893
+ } catch {
894
+ done();
895
+ return;
896
+ }
897
+
898
+ forceTimeout = setTimeout(() => {
899
+ if (settled) return;
900
+ try {
901
+ if (!killWindowsTree()) {
825
902
  handle.process.kill("SIGKILL");
826
903
  }
904
+ } catch {
905
+ done();
906
+ return;
827
907
  }
908
+ // If the process had already exited before listeners were attached, no
909
+ // exit event will arrive. Resolve rather than hanging test cleanup forever.
910
+ giveUpTimeout = setTimeout(done, 500);
828
911
  }, 5000);
829
-
830
- handle.process.on("exit", () => {
831
- clearTimeout(timeout);
832
- resolve();
833
- });
834
-
835
- handle.process.on("error", () => {
836
- clearTimeout(timeout);
837
- resolve();
838
- });
839
912
  });
840
913
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Per-Server Diagnostic Strategies for pi-lens LSP
3
+ *
4
+ * Codifies known server behavior so timing decisions (debounce, retry budget,
5
+ * first-push seeding) are automatic rather than one-size-fits-all.
6
+ *
7
+ * Env var overrides (PI_LENS_LSP_*) always take precedence over strategy values.
8
+ */
9
+
10
+ export interface DiagnosticStrategy {
11
+ /** Seed the push cache on the very first publishDiagnostics notification.
12
+ * True for servers whose first push is known to be complete. */
13
+ seedFirstPush: boolean;
14
+ /** Maximum ms to spend retrying pull diagnostics when the first pull returns
15
+ * empty. 0 = skip pull retry entirely, rely on push. */
16
+ pullRetryBudgetMs: number;
17
+ /** Debounce window for push diagnostics (ms). Applied in both the notification
18
+ * handler and the waitForDiagnostics listener. */
19
+ debounceMs: number;
20
+ /** The aggregate timeout for waitForDiagnostics per this server (ms).
21
+ * Overrides the global DIAGNOSTICS_AGGREGATE_WAIT_MS in the service layer. */
22
+ aggregateWaitMs: number;
23
+ /** Whether this server benefits from a second pull after an empty fast first
24
+ * pull. TypeScript: no (rely on push). rust-analyzer: yes (incremental). */
25
+ expectSemanticSecondPush: boolean;
26
+ }
27
+
28
+ export const SERVER_DIAGNOSTIC_STRATEGIES: Record<string, DiagnosticStrategy> =
29
+ {
30
+ typescript: {
31
+ seedFirstPush: true,
32
+ pullRetryBudgetMs: 0,
33
+ debounceMs: 50,
34
+ aggregateWaitMs: 1000,
35
+ expectSemanticSecondPush: false,
36
+ },
37
+ "rust-analyzer": {
38
+ seedFirstPush: false,
39
+ pullRetryBudgetMs: 500,
40
+ debounceMs: 150,
41
+ aggregateWaitMs: 3000,
42
+ expectSemanticSecondPush: true,
43
+ },
44
+ pyright: {
45
+ seedFirstPush: false,
46
+ pullRetryBudgetMs: 250,
47
+ debounceMs: 100,
48
+ aggregateWaitMs: 1500,
49
+ expectSemanticSecondPush: false,
50
+ },
51
+ eslint: {
52
+ seedFirstPush: true,
53
+ pullRetryBudgetMs: 0,
54
+ debounceMs: 200,
55
+ aggregateWaitMs: 2000,
56
+ expectSemanticSecondPush: false,
57
+ },
58
+ };
59
+
60
+ /** Fallback for unknown servers. Conservative defaults. */
61
+ export const DEFAULT_STRATEGY: DiagnosticStrategy = {
62
+ seedFirstPush: false,
63
+ pullRetryBudgetMs: 250,
64
+ debounceMs: 150,
65
+ aggregateWaitMs: 1500,
66
+ expectSemanticSecondPush: false,
67
+ };
68
+
69
+ export function getStrategy(serverId: string): DiagnosticStrategy {
70
+ return SERVER_DIAGNOSTIC_STRATEGIES[serverId] ?? DEFAULT_STRATEGY;
71
+ }
@@ -12,10 +12,11 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";
12
12
  import { access, appendFile, mkdir, stat } from "node:fs/promises";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
- import { KIND_EXTENSIONS } from "../file-kinds.js"
15
+ import { KIND_EXTENSIONS } from "../file-kinds.js";
16
16
  import { ensureTool, getToolEnvironment } from "../installer/index.js";
17
17
  import { logLatency } from "../latency-logger.js";
18
18
  import { type LSPProcess, launchLSP } from "./launch.js";
19
+ import { normalizeMapKey } from "./path-utils.js";
19
20
 
20
21
  // --- Types ---
21
22
 
@@ -489,56 +490,90 @@ export function NearestRoot(
489
490
  excludePatterns?: string[],
490
491
  stopDir?: string,
491
492
  ): RootFunction {
493
+ // Per-instance caches — each NearestRoot(markers) call gets its own Map so
494
+ // different servers (e.g. TypeScript vs Go) with different marker sets never
495
+ // share entries. vi.resetModules() in tests resets module state between cases.
496
+ const cache = new Map<string, string>();
497
+ const inFlight = new Map<string, Promise<string | undefined>>();
498
+
492
499
  return async (file: string): Promise<string | undefined> => {
493
- let currentDir = path.resolve(path.dirname(file));
494
- const fsRoot = path.parse(currentDir).root;
495
- const stop = stopDir ? path.resolve(stopDir) : fsRoot;
500
+ // Cache key is the resolved directory — all files in the same dir share a root.
501
+ const startDir = path.resolve(path.dirname(file));
502
+ const dirKey = normalizeMapKey(startDir);
503
+
504
+ // Fast path: already resolved for this directory.
505
+ const cached = cache.get(dirKey);
506
+ if (cached !== undefined) return cached;
507
+
508
+ // In-flight deduplication: if N parallel pipelines edit files in the same
509
+ // directory simultaneously, only one stat-walk runs; the rest await the same
510
+ // promise. This is the main fix for parallel-turn LSP timeout spikes.
511
+ const flying = inFlight.get(dirKey);
512
+ if (flying) return flying;
513
+
514
+ const promise = (async (): Promise<string | undefined> => {
515
+ let currentDir = startDir;
516
+ const fsRoot = path.parse(currentDir).root;
517
+ const stop = stopDir ? path.resolve(stopDir) : fsRoot;
518
+
519
+ while (true) {
520
+ if (
521
+ stop !== fsRoot &&
522
+ currentDir.startsWith(stop + path.sep) === false &&
523
+ currentDir !== stop
524
+ ) {
525
+ break;
526
+ }
496
527
 
497
- while (true) {
498
- if (
499
- stop !== fsRoot &&
500
- currentDir.startsWith(stop + path.sep) === false &&
501
- currentDir !== stop
502
- ) {
503
- break;
504
- }
528
+ // Check exclude patterns — skip this dir (but keep walking up)
529
+ if (excludePatterns) {
530
+ let excluded = false;
531
+ for (const pattern of excludePatterns) {
532
+ try {
533
+ await stat(path.join(currentDir, pattern));
534
+ excluded = true;
535
+ break;
536
+ } catch {
537
+ /* not found */
538
+ }
539
+ }
540
+ if (excluded) {
541
+ currentDir = path.dirname(currentDir);
542
+ continue;
543
+ }
544
+ }
505
545
 
506
- // Check exclude patterns — skip this dir (but keep walking up)
507
- if (excludePatterns) {
508
- let excluded = false;
509
- for (const pattern of excludePatterns) {
546
+ // Check include patterns
547
+ for (const pattern of includePatterns) {
510
548
  try {
511
549
  await stat(path.join(currentDir, pattern));
512
- excluded = true;
513
- break;
550
+ return currentDir;
514
551
  } catch {
515
552
  /* not found */
516
553
  }
517
554
  }
518
- if (excluded) {
519
- currentDir = path.dirname(currentDir);
520
- continue;
521
- }
522
- }
523
555
 
524
- // Check include patterns
525
- for (const pattern of includePatterns) {
526
- try {
527
- await stat(path.join(currentDir, pattern));
528
- return currentDir;
529
- } catch {
530
- /* not found */
556
+ if (currentDir === stop || currentDir === fsRoot) {
557
+ break;
531
558
  }
532
- }
533
559
 
534
- if (currentDir === stop || currentDir === fsRoot) {
535
- break;
560
+ currentDir = path.dirname(currentDir);
536
561
  }
537
562
 
538
- currentDir = path.dirname(currentDir);
539
- }
563
+ return undefined;
564
+ })();
540
565
 
541
- return undefined;
566
+ inFlight.set(dirKey, promise);
567
+ try {
568
+ const result = await promise;
569
+ // Only cache successful hits. Undefined results are not cached so that
570
+ // a newly-created root marker (e.g. package.json added mid-session) is
571
+ // detected on the next call.
572
+ if (result !== undefined) cache.set(dirKey, result);
573
+ return result;
574
+ } finally {
575
+ inFlight.delete(dirKey);
576
+ }
542
577
  };
543
578
  }
544
579
 
@@ -1362,11 +1397,7 @@ export const NixServer = createInteractiveServer({
1362
1397
  export const BashServer: LSPServerInfo = {
1363
1398
  id: "bash",
1364
1399
  name: "Bash Language Server",
1365
- extensions: [
1366
- ".bash",
1367
- ".sh",
1368
- ".zsh",
1369
- ],
1400
+ extensions: [".bash", ".sh", ".zsh"],
1370
1401
  root: FileDirRoot,
1371
1402
  spawn(root, options) {
1372
1403
  return resolveAndLaunch(
@@ -1384,10 +1415,7 @@ export const BashServer: LSPServerInfo = {
1384
1415
  export const DockerServer: LSPServerInfo = {
1385
1416
  id: "docker",
1386
1417
  name: "Dockerfile Language Server",
1387
- extensions: [
1388
- ".dockerfile",
1389
- "Dockerfile",
1390
- ],
1418
+ extensions: [".dockerfile", "Dockerfile"],
1391
1419
  root: RootWithFallback(
1392
1420
  PriorityRoot([
1393
1421
  [
@@ -1525,9 +1553,7 @@ export const PrismaServer: LSPServerInfo = {
1525
1553
  export const VueServer: LSPServerInfo = {
1526
1554
  id: "vue",
1527
1555
  name: "Vue Language Server",
1528
- extensions: [
1529
- ".vue",
1530
- ],
1556
+ extensions: [".vue"],
1531
1557
  root: RootWithFallback(
1532
1558
  IgnoreHomeRoot(
1533
1559
  createRootDetector([
@@ -1577,9 +1603,7 @@ export const VueServer: LSPServerInfo = {
1577
1603
  export const SvelteServer: LSPServerInfo = {
1578
1604
  id: "svelte",
1579
1605
  name: "Svelte Language Server",
1580
- extensions: [
1581
- ".svelte",
1582
- ],
1606
+ extensions: [".svelte"],
1583
1607
  root: RootWithFallback(
1584
1608
  IgnoreHomeRoot(
1585
1609
  createRootDetector([
@@ -1621,12 +1645,7 @@ export const ESLintServer: LSPServerInfo = {
1621
1645
  id: "eslint",
1622
1646
  name: "ESLint Language Server",
1623
1647
  // Note: .ts/.tsx handled by TypeScript LSP + Biome
1624
- extensions: [
1625
- ".js",
1626
- ".jsx",
1627
- ".svelte",
1628
- ".vue",
1629
- ],
1648
+ extensions: [".js", ".jsx", ".svelte", ".vue"],
1630
1649
  root: IgnoreHomeRoot(
1631
1650
  createRootDetector([
1632
1651
  ".eslintrc",
@@ -151,3 +151,20 @@ export function isUnderDir(child: string, parent: string): boolean {
151
151
  const parentPrefix = normParent.endsWith("/") ? normParent : `${normParent}/`;
152
152
  return normChild === normParent || normChild.startsWith(parentPrefix);
153
153
  }
154
+
155
+ /**
156
+ * Returns true when a file should be treated as external/vendor and excluded
157
+ * from pipelines (LSP, diagnostics, complexity, read-guard, etc.).
158
+ *
159
+ * Two cases:
160
+ * 1. Outside the project root entirely (e.g. global npm packages, system files)
161
+ * 2. Inside the project but under node_modules (local deps, never user-editable)
162
+ */
163
+ export function isExternalOrVendorFile(
164
+ filePath: string,
165
+ projectRoot: string,
166
+ ): boolean {
167
+ if (!isUnderDir(filePath, projectRoot)) return true;
168
+ const normalized = normalizeFilePath(filePath);
169
+ return normalized.includes("/node_modules/");
170
+ }
@@ -15,6 +15,7 @@
15
15
  import * as nodeFs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { BiomeClient } from "./biome-client.js";
18
+ import { recordDiagnostics } from "./widget-state.js";
18
19
  import { getDiagnosticLogger } from "./diagnostic-logger.js";
19
20
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
20
21
  import {
@@ -725,7 +726,12 @@ export async function runFormatPhase(
725
726
  if (result.anyChanged) {
726
727
  formatChanged = true;
727
728
  dbg(
728
- "autoformat: " + result.formatters.map((f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")").join(", "),
729
+ "autoformat: " +
730
+ result.formatters
731
+ .map(
732
+ (f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")",
733
+ )
734
+ .join(", "),
729
735
  );
730
736
  }
731
737
  if (!result.allSucceeded) {
@@ -734,7 +740,10 @@ export async function runFormatPhase(
734
740
  ...failures.map((f) => `${f.name}: ${f.error ?? "unknown error"}`),
735
741
  );
736
742
  dbg(
737
- "autoformat: " + failures.map((f) => f.name + " failed: " + (f.error ?? "unknown error")).join("; "),
743
+ "autoformat: " +
744
+ failures
745
+ .map((f) => f.name + " failed: " + (f.error ?? "unknown error"))
746
+ .join("; "),
738
747
  );
739
748
  }
740
749
  } catch (err) {
@@ -889,6 +898,7 @@ export async function runPipeline(
889
898
  writeIndex: ctx.telemetry?.writeIndex ?? 0,
890
899
  },
891
900
  );
901
+ recordDiagnostics(filePath, dispatchResult.diagnostics);
892
902
  const hasBlockers = dispatchResult.hasBlockers;
893
903
 
894
904
  if (dispatchResult.diagnostics.length > 0) {
@@ -946,9 +956,17 @@ export async function runPipeline(
946
956
  const changedList = [...piChangedFiles].map((changedFile) =>
947
957
  toRunnerDisplayPath(cwd, changedFile),
948
958
  );
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 : "";
959
+ const topFiles = changedList
960
+ .slice(0, 8)
961
+ .map((f) => " - " + f)
962
+ .join("\n");
963
+ const overflow =
964
+ changedList.length > 8
965
+ ? "\n - ... and " + (changedList.length - 8) + " more"
966
+ : "";
967
+ const fileList = changedList.length
968
+ ? "\nModified files:\n" + topFiles + overflow
969
+ : "";
952
970
  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}`;
953
971
  }
954
972
  phase.end("dispatch_lint", {
@@ -256,9 +256,9 @@ function validateConfig(root: string): CategoryResult {
256
256
 
257
257
  const found: string[] = [];
258
258
 
259
- for (const { file, critical, dir } of checks) {
259
+ for (const { file, critical } of checks) {
260
260
  const filePath = path.join(root, file);
261
- const exists = dir ? fs.existsSync(filePath) : fs.existsSync(filePath);
261
+ const exists = fs.existsSync(filePath);
262
262
  if (exists) {
263
263
  found.push(file);
264
264
  } else if (critical) {
@@ -4,6 +4,19 @@ import * as path from "node:path";
4
4
 
5
5
  const READ_GUARD_LOG_DIR = path.join(os.homedir(), ".pi-lens");
6
6
  const READ_GUARD_LOG_FILE = path.join(READ_GUARD_LOG_DIR, "read-guard.log");
7
+ const READ_GUARD_LOG_BACKUP_FILE = path.join(
8
+ READ_GUARD_LOG_DIR,
9
+ "read-guard.log.1",
10
+ );
11
+ const MAX_LOG_BYTES = Math.max(
12
+ 128 * 1024,
13
+ Number.parseInt(process.env.PI_LENS_READ_GUARD_MAX_BYTES ?? "1048576", 10) ||
14
+ 1048576,
15
+ );
16
+ const VERBOSE_READ_GUARD_LOG =
17
+ process.env.PI_LENS_READ_GUARD_VERBOSE === "1" ||
18
+ process.env.PI_LENS_READ_GUARD_LOG === "verbose";
19
+ const LOG_ALLOWED_EDITS = process.env.PI_LENS_READ_GUARD_LOG_ALLOWS === "1";
7
20
 
8
21
  try {
9
22
  if (!fs.existsSync(READ_GUARD_LOG_DIR)) {
@@ -26,15 +39,42 @@ export interface ReadGuardLogEntry {
26
39
  metadata?: Record<string, unknown>;
27
40
  }
28
41
 
42
+ function shouldLogEvent(event: string): boolean {
43
+ if (VERBOSE_READ_GUARD_LOG) return true;
44
+ if (event === "edit_allowed") return LOG_ALLOWED_EDITS;
45
+ return (
46
+ event === "edit_blocked" ||
47
+ event === "edit_warned" ||
48
+ event === "exemption_added" ||
49
+ event === "oldtext_not_found" ||
50
+ event === "oldtext_duplicate" ||
51
+ event === "touched_lines_missing"
52
+ );
53
+ }
54
+
55
+ function rotateIfNeeded(): void {
56
+ try {
57
+ if (!fs.existsSync(READ_GUARD_LOG_FILE)) return;
58
+ const size = fs.statSync(READ_GUARD_LOG_FILE).size;
59
+ if (size < MAX_LOG_BYTES) return;
60
+ try {
61
+ fs.rmSync(READ_GUARD_LOG_BACKUP_FILE, { force: true });
62
+ } catch {}
63
+ fs.renameSync(READ_GUARD_LOG_FILE, READ_GUARD_LOG_BACKUP_FILE);
64
+ } catch {}
65
+ }
66
+
29
67
  export function logReadGuardEvent(entry: ReadGuardLogEntry): void {
30
68
  if (
31
69
  process.env.PI_LENS_TEST_MODE === "1" ||
32
- (process.env.VITEST && process.env.PI_LENS_TEST_MODE !== "0")
70
+ (process.env.VITEST && process.env.PI_LENS_TEST_MODE !== "0") ||
71
+ !shouldLogEvent(entry.event)
33
72
  ) {
34
73
  return;
35
74
  }
36
75
  const line = `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`;
37
76
  try {
77
+ rotateIfNeeded();
38
78
  fs.appendFileSync(READ_GUARD_LOG_FILE, line);
39
79
  } catch {}
40
80
  }