pi-lens 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +16 -12
  3. package/clients/ast-grep-client.js +8 -1
  4. package/clients/ast-grep-client.ts +9 -1
  5. package/clients/biome-client.js +51 -38
  6. package/clients/biome-client.ts +60 -58
  7. package/clients/dependency-checker.js +30 -1
  8. package/clients/dependency-checker.ts +35 -1
  9. package/clients/dispatch/__tests__/runner-registration.test.ts +286 -282
  10. package/clients/dispatch/bus-dispatcher.js +15 -14
  11. package/clients/dispatch/bus-dispatcher.ts +32 -25
  12. package/clients/dispatch/dispatcher.js +18 -25
  13. package/clients/dispatch/dispatcher.test.ts +2 -1
  14. package/clients/dispatch/dispatcher.ts +17 -28
  15. package/clients/dispatch/plan.js +77 -32
  16. package/clients/dispatch/plan.ts +78 -32
  17. package/clients/dispatch/runners/ast-grep-napi.js +36 -376
  18. package/clients/dispatch/runners/ast-grep-napi.ts +60 -433
  19. package/clients/dispatch/runners/index.js +8 -4
  20. package/clients/dispatch/runners/index.ts +8 -4
  21. package/clients/dispatch/runners/lsp.js +65 -0
  22. package/clients/dispatch/runners/lsp.ts +125 -0
  23. package/clients/dispatch/runners/oxlint.js +2 -2
  24. package/clients/dispatch/runners/oxlint.ts +2 -2
  25. package/clients/dispatch/runners/pyright.js +24 -8
  26. package/clients/dispatch/runners/pyright.ts +28 -14
  27. package/clients/dispatch/runners/rust-clippy.js +2 -2
  28. package/clients/dispatch/runners/rust-clippy.ts +2 -4
  29. package/clients/dispatch/runners/tree-sitter.js +14 -2
  30. package/clients/dispatch/runners/tree-sitter.ts +15 -2
  31. package/clients/dispatch/runners/ts-lsp.js +3 -3
  32. package/clients/dispatch/runners/ts-lsp.ts +8 -5
  33. package/clients/dispatch/runners/yaml-rule-parser.js +292 -0
  34. package/clients/dispatch/runners/yaml-rule-parser.ts +338 -0
  35. package/clients/dispatch/types.js +3 -0
  36. package/clients/dispatch/types.ts +3 -0
  37. package/clients/formatters.js +67 -14
  38. package/clients/formatters.ts +68 -15
  39. package/clients/installer/index.js +78 -10
  40. package/clients/installer/index.ts +519 -426
  41. package/clients/jscpd-client.js +28 -0
  42. package/clients/jscpd-client.ts +41 -3
  43. package/clients/knip-client.js +30 -1
  44. package/clients/knip-client.ts +34 -2
  45. package/clients/lsp/__tests__/client.test.ts +64 -41
  46. package/clients/lsp/__tests__/config.test.ts +25 -17
  47. package/clients/lsp/__tests__/launch.test.ts +108 -43
  48. package/clients/lsp/__tests__/service.test.ts +76 -48
  49. package/clients/lsp/client.js +87 -2
  50. package/clients/lsp/client.ts +150 -6
  51. package/clients/lsp/config.js +8 -11
  52. package/clients/lsp/config.ts +24 -21
  53. package/clients/lsp/index.js +69 -0
  54. package/clients/lsp/index.ts +82 -0
  55. package/clients/lsp/interactive-install.js +19 -8
  56. package/clients/lsp/interactive-install.ts +52 -27
  57. package/clients/lsp/launch.js +182 -32
  58. package/clients/lsp/launch.ts +241 -38
  59. package/clients/lsp/path-utils.js +3 -46
  60. package/clients/lsp/path-utils.ts +11 -51
  61. package/clients/lsp/server.js +93 -71
  62. package/clients/lsp/server.ts +173 -131
  63. package/clients/path-utils.js +142 -0
  64. package/clients/path-utils.ts +153 -0
  65. package/clients/ruff-client.js +33 -4
  66. package/clients/ruff-client.ts +44 -13
  67. package/clients/safe-spawn.js +3 -1
  68. package/clients/safe-spawn.ts +3 -1
  69. package/clients/services/effect-integration.js +11 -7
  70. package/clients/services/effect-integration.ts +34 -26
  71. package/clients/sg-runner.js +51 -9
  72. package/clients/sg-runner.ts +58 -15
  73. package/clients/tree-sitter-client.js +12 -0
  74. package/clients/tree-sitter-client.ts +12 -0
  75. package/clients/typescript-client.js +6 -2
  76. package/clients/typescript-client.ts +9 -2
  77. package/commands/booboo.js +2 -4
  78. package/commands/booboo.ts +2 -4
  79. package/index.ts +377 -93
  80. package/package.json +2 -1
  81. package/rules/tree-sitter-queries/tsx/no-nested-links.yml +45 -0
  82. package/rules/tree-sitter-queries/typescript/constructor-super.yml +55 -0
  83. package/rules/tree-sitter-queries/typescript/debugger.yml +1 -1
  84. package/rules/tree-sitter-queries/typescript/no-dupe-class-members.yml +47 -0
  85. package/tsconfig.json +1 -1
  86. package/clients/__tests__/file-time.test.js +0 -216
  87. package/clients/__tests__/format-service.test.js +0 -245
  88. package/clients/__tests__/formatters.test.js +0 -271
  89. package/clients/agent-behavior-client.test.js +0 -94
  90. package/clients/ast-grep-client.test.js +0 -129
  91. package/clients/ast-grep-client.test.ts +0 -155
  92. package/clients/biome-client.test.js +0 -144
  93. package/clients/cache-manager.test.js +0 -197
  94. package/clients/complexity-client.test.js +0 -234
  95. package/clients/dependency-checker.test.js +0 -60
  96. package/clients/dispatch/__tests__/autofix-integration.test.js +0 -245
  97. package/clients/dispatch/__tests__/runner-registration.test.js +0 -236
  98. package/clients/dispatch/dispatcher.edge.test.js +0 -82
  99. package/clients/dispatch/dispatcher.format.test.js +0 -46
  100. package/clients/dispatch/dispatcher.inline.test.js +0 -74
  101. package/clients/dispatch/dispatcher.test.js +0 -115
  102. package/clients/dispatch/runners/architect.test.js +0 -138
  103. package/clients/dispatch/runners/ast-grep-napi.test.js +0 -106
  104. package/clients/dispatch/runners/oxlint.test.js +0 -230
  105. package/clients/dispatch/runners/pyright.test.js +0 -98
  106. package/clients/dispatch/runners/python-slop.test.js +0 -203
  107. package/clients/dispatch/runners/scan_codebase.test.js +0 -89
  108. package/clients/dispatch/runners/shellcheck.test.js +0 -98
  109. package/clients/dispatch/runners/spellcheck.test.js +0 -158
  110. package/clients/dispatch/runners/ts-slop.test.js +0 -180
  111. package/clients/dispatch/runners/ts-slop.test.ts +0 -230
  112. package/clients/dogfood.test.js +0 -201
  113. package/clients/file-kinds.test.js +0 -169
  114. package/clients/go-client.test.js +0 -127
  115. package/clients/jscpd-client.test.js +0 -127
  116. package/clients/knip-client.test.js +0 -112
  117. package/clients/lsp/__tests__/client.test.js +0 -325
  118. package/clients/lsp/__tests__/config.test.js +0 -166
  119. package/clients/lsp/__tests__/error-recovery.test.js +0 -213
  120. package/clients/lsp/__tests__/integration.test.js +0 -127
  121. package/clients/lsp/__tests__/launch.test.js +0 -260
  122. package/clients/lsp/__tests__/server.test.js +0 -259
  123. package/clients/lsp/__tests__/service.test.js +0 -417
  124. package/clients/metrics-client.test.js +0 -141
  125. package/clients/ruff-client.test.js +0 -132
  126. package/clients/rust-client.test.js +0 -108
  127. package/clients/sanitize.test.js +0 -177
  128. package/clients/secrets-scanner.test.js +0 -100
  129. package/clients/services/__tests__/effect-integration.test.js +0 -86
  130. package/clients/test-runner-client.test.js +0 -192
  131. package/clients/todo-scanner.test.js +0 -301
  132. package/clients/type-coverage-client.test.js +0 -105
  133. package/clients/typescript-client.codefix.test.js +0 -157
  134. package/clients/typescript-client.test.js +0 -105
  135. package/commands/clients/ast-grep-client.js +0 -250
  136. package/commands/clients/ast-grep-parser.js +0 -86
  137. package/commands/clients/ast-grep-rule-manager.js +0 -91
  138. package/commands/clients/ast-grep-types.js +0 -9
  139. package/commands/clients/biome-client.js +0 -380
  140. package/commands/clients/complexity-client.js +0 -667
  141. package/commands/clients/file-kinds.js +0 -177
  142. package/commands/clients/file-utils.js +0 -40
  143. package/commands/clients/jscpd-client.js +0 -169
  144. package/commands/clients/knip-client.js +0 -211
  145. package/commands/clients/ruff-client.js +0 -297
  146. package/commands/clients/safe-spawn.js +0 -88
  147. package/commands/clients/scan-utils.js +0 -83
  148. package/commands/clients/sg-runner.js +0 -190
  149. package/commands/clients/types.js +0 -11
  150. package/commands/clients/typescript-client.js +0 -505
  151. package/commands/rate.test.js +0 -119
  152. package/rules/ast-grep-rules/rules/no-dangerously-set-inner-html.yml +0 -13
  153. package/rules/ast-grep-rules/rules/no-debugger.yml +0 -12
  154. package/rules/ast-grep-rules/rules/no-eval.yml +0 -13
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as nodeFs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ // RELOADED: Testing format/lsp flow on large file
4
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
6
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
6
7
  import { Type } from "@sinclair/typebox";
@@ -20,6 +21,7 @@ import { ComplexityClient } from "./clients/complexity-client.js";
20
21
  import { DependencyChecker } from "./clients/dependency-checker.js";
21
22
  import { dispatchLintWithBus } from "./clients/dispatch/bus-dispatcher.js";
22
23
  import { dispatchLint } from "./clients/dispatch/integration.js";
24
+ import { createFileTime, FileTimeError } from "./clients/file-time.js";
23
25
  import {
24
26
  getFormatService,
25
27
  resetFormatService,
@@ -584,6 +586,172 @@ export default function (pi: ExtensionAPI) {
584
586
  },
585
587
  });
586
588
 
589
+ // --- LSP Navigation Tool (requires --lens-lsp) ---
590
+ // Exposes go-to-definition, find-references, hover, documentSymbol, workspaceSymbol, goToImplementation
591
+ pi.registerTool({
592
+ name: "lsp_navigation",
593
+ label: "LSP Navigate",
594
+ description:
595
+ "Navigate code using LSP (Language Server Protocol). Requires --lens-lsp flag.\n" +
596
+ "Operations:\n" +
597
+ "- definition: Jump to where a symbol is defined\n" +
598
+ "- references: Find all usages of a symbol\n" +
599
+ "- hover: Get type/doc info at a position\n" +
600
+ "- documentSymbol: List all symbols (functions/classes/vars) in a file\n" +
601
+ "- workspaceSymbol: Search symbols across the whole project\n" +
602
+ "- implementation: Jump to interface implementations\n\n" +
603
+ "Line and character are 1-based (as shown in editors).",
604
+ promptSnippet:
605
+ "Use lsp_navigation to find definitions, references, and hover info via LSP",
606
+ parameters: Type.Object({
607
+ operation: Type.Union(
608
+ [
609
+ Type.Literal("definition"),
610
+ Type.Literal("references"),
611
+ Type.Literal("hover"),
612
+ Type.Literal("documentSymbol"),
613
+ Type.Literal("workspaceSymbol"),
614
+ Type.Literal("implementation"),
615
+ ],
616
+ { description: "LSP operation to perform" },
617
+ ),
618
+ filePath: Type.String({
619
+ description: "Absolute or relative path to the file",
620
+ }),
621
+ line: Type.Optional(
622
+ Type.Number({
623
+ description:
624
+ "Line number (1-based). Required for definition/references/hover/implementation",
625
+ }),
626
+ ),
627
+ character: Type.Optional(
628
+ Type.Number({
629
+ description:
630
+ "Character offset (1-based). Required for definition/references/hover/implementation",
631
+ }),
632
+ ),
633
+ query: Type.Optional(
634
+ Type.String({
635
+ description: "Symbol name to search. Used by workspaceSymbol",
636
+ }),
637
+ ),
638
+ }),
639
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
640
+ if (!pi.getFlag("lens-lsp")) {
641
+ return {
642
+ content: [
643
+ {
644
+ type: "text" as const,
645
+ text: "lsp_navigation requires the --lens-lsp flag. Start pi with --lens-lsp to enable.",
646
+ },
647
+ ],
648
+ isError: true,
649
+ details: {},
650
+ };
651
+ }
652
+
653
+ const {
654
+ operation,
655
+ filePath: rawPath,
656
+ line,
657
+ character,
658
+ query,
659
+ } = params as {
660
+ operation: string;
661
+ filePath: string;
662
+ line?: number;
663
+ character?: number;
664
+ query?: string;
665
+ };
666
+
667
+ const filePath = path.isAbsolute(rawPath)
668
+ ? rawPath
669
+ : path.resolve(ctx.cwd || ".", rawPath);
670
+
671
+ const lspService = getLSPService();
672
+ const hasLSP = await lspService.hasLSP(filePath);
673
+ if (!hasLSP) {
674
+ return {
675
+ content: [
676
+ {
677
+ type: "text" as const,
678
+ text: `No LSP server available for ${path.basename(filePath)}. Check that the language server is installed.`,
679
+ },
680
+ ],
681
+ isError: true,
682
+ details: {},
683
+ };
684
+ }
685
+
686
+ // Ensure file is open in LSP before querying
687
+ let fileContent: string | undefined;
688
+ try {
689
+ fileContent = nodeFs.readFileSync(filePath, "utf-8");
690
+ } catch {
691
+ /* ignore */
692
+ }
693
+ if (fileContent) await lspService.openFile(filePath, fileContent);
694
+
695
+ // Convert 1-based editor coords to 0-based LSP coords
696
+ const lspLine = (line ?? 1) - 1;
697
+ const lspChar = (character ?? 1) - 1;
698
+
699
+ let result: unknown;
700
+ try {
701
+ switch (operation) {
702
+ case "definition":
703
+ result = await lspService.definition(filePath, lspLine, lspChar);
704
+ break;
705
+ case "references":
706
+ result = await lspService.references(filePath, lspLine, lspChar);
707
+ break;
708
+ case "hover":
709
+ result = await lspService.hover(filePath, lspLine, lspChar);
710
+ break;
711
+ case "documentSymbol":
712
+ result = await lspService.documentSymbol(filePath);
713
+ break;
714
+ case "workspaceSymbol":
715
+ result = await lspService.workspaceSymbol(query ?? "");
716
+ break;
717
+ case "implementation":
718
+ result = await lspService.implementation(
719
+ filePath,
720
+ lspLine,
721
+ lspChar,
722
+ );
723
+ break;
724
+ default:
725
+ result = [];
726
+ }
727
+ } catch (err) {
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text" as const,
732
+ text: `LSP error: ${err instanceof Error ? err.message : String(err)}`,
733
+ },
734
+ ],
735
+ isError: true,
736
+ details: {},
737
+ };
738
+ }
739
+
740
+ const isEmpty = !result || (Array.isArray(result) && result.length === 0);
741
+ const output = isEmpty
742
+ ? `No results for ${operation} at ${path.basename(filePath)}${line ? `:${line}:${character}` : ""}`
743
+ : JSON.stringify(result, null, 2);
744
+
745
+ return {
746
+ content: [{ type: "text" as const, text: output }],
747
+ details: {
748
+ operation,
749
+ resultCount: Array.isArray(result) ? result.length : result ? 1 : 0,
750
+ },
751
+ };
752
+ },
753
+ });
754
+
587
755
  let _cachedJscpdClones: import("./clients/jscpd-client.js").DuplicateClone[] =
588
756
  [];
589
757
  const cachedExports = new Map<string, string>(); // function name -> file path
@@ -593,11 +761,11 @@ export default function (pi: ExtensionAPI) {
593
761
  > = new Map();
594
762
 
595
763
  // Delta baselines: store pre-write diagnostics to diff against post-write
596
- const astGrepBaselines = new Map<
764
+ const _astGrepBaselines = new Map<
597
765
  string,
598
766
  import("./clients/ast-grep-types.js").AstGrepDiagnostic[]
599
767
  >();
600
- const biomeBaselines = new Map<
768
+ const _biomeBaselines = new Map<
601
769
  string,
602
770
  import("./clients/biome-client.js").BiomeDiagnostic[]
603
771
  >();
@@ -709,8 +877,8 @@ export default function (pi: ExtensionAPI) {
709
877
  dbg(`session_start TODO scan: ${todoResult.items.length} items`);
710
878
  if (todoReport) parts.push(todoReport);
711
879
 
712
- // Dead code scan — use cache if fresh
713
- if (knipClient.isAvailable()) {
880
+ // Dead code scan — use cache if fresh, auto-install if needed
881
+ if (await knipClient.ensureAvailable()) {
714
882
  const cached = cacheManager.readCache<ReturnType<KnipClient["analyze"]>>(
715
883
  "knip",
716
884
  cwd,
@@ -735,8 +903,8 @@ export default function (pi: ExtensionAPI) {
735
903
  dbg(`session_start Knip: not available`);
736
904
  }
737
905
 
738
- // Duplicate code detection — use cache if fresh
739
- if (jscpdClient.isAvailable()) {
906
+ // Duplicate code detection — use cache if fresh, auto-install if needed
907
+ if (await jscpdClient.ensureAvailable()) {
740
908
  const cached = cacheManager.readCache<ReturnType<JscpdClient["scan"]>>(
741
909
  "jscpd",
742
910
  cwd,
@@ -764,7 +932,7 @@ export default function (pi: ExtensionAPI) {
764
932
  // Note: type-coverage runs on-demand via /lens-booboo only (not at session_start)
765
933
 
766
934
  // Scan for exported functions (cached for duplicate detection on write)
767
- if (astGrepClient.isAvailable()) {
935
+ if (await astGrepClient.ensureAvailable()) {
768
936
  const exports = await astGrepClient.scanExports(cwd, "typescript");
769
937
  dbg(`session_start exports scan: ${exports.size} functions found`);
770
938
  for (const [name, file] of exports) {
@@ -846,12 +1014,14 @@ export default function (pi: ExtensionAPI) {
846
1014
 
847
1015
  if (!filePath) return;
848
1016
 
1017
+ const _toolCallStart = Date.now();
849
1018
  dbg(
850
1019
  `tool_call fired for: ${filePath} (exists: ${nodeFs.existsSync(filePath)})`,
851
1020
  );
852
1021
  if (!nodeFs.existsSync(filePath)) return;
853
1022
 
854
1023
  // Record complexity baseline for TS/JS files + capture history snapshot
1024
+ const complexityBaselineStart = Date.now();
855
1025
  if (
856
1026
  complexityClient.isSupportedFile(filePath) &&
857
1027
  !complexityBaselines.has(filePath)
@@ -867,64 +1037,108 @@ export default function (pi: ExtensionAPI) {
867
1037
  linesOfCode: baseline.linesOfCode,
868
1038
  });
869
1039
  }
1040
+ logLatency({
1041
+ type: "phase",
1042
+ toolName: event.toolName,
1043
+ filePath,
1044
+ phase: "complexity_baseline",
1045
+ durationMs: Date.now() - complexityBaselineStart,
1046
+ metadata: { hasBaseline: complexityBaselines.has(filePath) },
1047
+ });
870
1048
  }
871
1049
 
872
1050
  const hints: string[] = [];
1051
+ let _preCheckDuration = 0;
873
1052
 
874
1053
  if (/\.(ts|tsx|js|jsx)$/.test(filePath) && !pi.getFlag("no-lsp")) {
1054
+ const tsPreCheckStart = Date.now();
875
1055
  tsClient.updateFile(filePath, nodeFs.readFileSync(filePath, "utf-8"));
876
1056
  const diags = tsClient.getDiagnostics(filePath);
877
- const fixes = tsClient.getAllCodeFixes(filePath);
878
- if (diags.length > 0) {
879
- const errorDiags = diags.filter((d) => d.severity === 1);
880
- if (errorDiags.length > 0) {
1057
+ // Pass pre-computed diags to avoid a second getSemanticDiagnostics call (~1s on large files)
1058
+ const fixes = tsClient.getAllCodeFixes(filePath, diags);
1059
+ const errorDiags = diags.filter((d) => d.severity === 1);
1060
+ if (errorDiags.length > 0) {
1061
+ hints.push(
1062
+ `⚠ Pre-write: file has ${errorDiags.length} TypeScript error(s):`,
1063
+ );
1064
+ // Show first 3 errors with quick fixes
1065
+ for (const d of errorDiags.slice(0, 3)) {
1066
+ const lineFixes = fixes.get(d.range.start.line);
1067
+ const fixHint = lineFixes?.[0]?.description
1068
+ ? ` šŸ’” ${lineFixes[0].description}`
1069
+ : "";
881
1070
  hints.push(
882
- `⚠ Pre-write: file has ${errorDiags.length} TypeScript error(s):`,
1071
+ ` L${d.range.start.line + 1}: ${d.message.split("\n")[0].substring(0, 80)}${fixHint}`,
883
1072
  );
884
- // Show first 3 errors with quick fixes
885
- for (const d of errorDiags.slice(0, 3)) {
886
- const lineFixes = fixes.get(d.range.start.line);
887
- const fixHint = lineFixes?.[0]?.description
888
- ? ` šŸ’” ${lineFixes[0].description}`
889
- : "";
890
- hints.push(
891
- ` L${d.range.start.line + 1}: ${d.message.split("\n")[0].substring(0, 80)}${fixHint}`,
892
- );
893
- }
894
- if (errorDiags.length > 3) {
895
- hints.push(` ... and ${errorDiags.length - 3} more errors`);
896
- }
1073
+ }
1074
+ if (errorDiags.length > 3) {
1075
+ hints.push(` ... and ${errorDiags.length - 3} more errors`);
897
1076
  }
898
1077
  }
1078
+ _preCheckDuration += Date.now() - tsPreCheckStart;
1079
+ logLatency({
1080
+ type: "phase",
1081
+ toolName: event.toolName,
1082
+ filePath,
1083
+ phase: "ts_pre_check",
1084
+ durationMs: Date.now() - tsPreCheckStart,
1085
+ metadata: { errorCount: errorDiags.length, hintCount: hints.length },
1086
+ });
899
1087
  }
900
1088
 
901
- // Snapshot baselines for delta mode (no pre-write hints — delta handles it)
902
- if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
903
- const baselineDiags = astGrepClient.scanFile(filePath);
904
- astGrepBaselines.set(filePath, baselineDiags);
905
-
906
- // Add to TDR baseline
907
- const initialTdr = baselineDiags
908
- .filter((d) => d.ruleDescription?.grade !== undefined)
909
- .reduce((acc, d) => acc + (d.ruleDescription?.grade ?? 0), 0);
910
-
911
- metricsClient.recordBaseline(filePath, initialTdr);
912
- } else {
913
- metricsClient.recordBaseline(filePath);
1089
+ // Unified LSP pre-write check (when --lens-lsp enabled)
1090
+ // This provides pre-write hints for all LSP languages (TypeScript, Python, Go, Rust, etc.)
1091
+ if (pi.getFlag("lens-lsp") && !pi.getFlag("no-lsp")) {
1092
+ const lspPreCheckStart = Date.now();
1093
+ const lspService = getLSPService();
1094
+ lspService
1095
+ .hasLSP(filePath)
1096
+ .then(async (hasLSP) => {
1097
+ if (hasLSP) {
1098
+ const content = nodeFs.readFileSync(filePath, "utf-8");
1099
+ await lspService.openFile(filePath, content);
1100
+ await new Promise((r) => setTimeout(r, 300));
1101
+ const diags = await lspService.getDiagnostics(filePath);
1102
+ const errorDiags = diags.filter((d) => d.severity === 1);
1103
+ if (errorDiags.length > 0) {
1104
+ hints.push(
1105
+ `⚠ Pre-write: ${errorDiags.length} LSP error(s) detected:`,
1106
+ );
1107
+ for (const d of errorDiags.slice(0, 3)) {
1108
+ hints.push(
1109
+ ` L${d.range?.start?.line ?? 0 + 1}: ${d.message.slice(0, 80)}`,
1110
+ );
1111
+ }
1112
+ if (errorDiags.length > 3) {
1113
+ hints.push(` ... and ${errorDiags.length - 3} more`);
1114
+ }
1115
+ }
1116
+ _preCheckDuration += Date.now() - lspPreCheckStart;
1117
+ logLatency({
1118
+ type: "phase",
1119
+ toolName: event.toolName,
1120
+ filePath,
1121
+ phase: "lsp_pre_check",
1122
+ durationMs: Date.now() - lspPreCheckStart,
1123
+ metadata: { hasLSP, errorCount: errorDiags?.length ?? 0 },
1124
+ });
1125
+ }
1126
+ })
1127
+ .catch((err) => {
1128
+ logLatency({
1129
+ type: "phase",
1130
+ toolName: event.toolName,
1131
+ filePath,
1132
+ phase: "lsp_pre_check",
1133
+ durationMs: Date.now() - lspPreCheckStart,
1134
+ status: "failed",
1135
+ metadata: { error: String(err) },
1136
+ });
1137
+ });
914
1138
  }
915
1139
 
916
- if (
917
- !pi.getFlag("no-biome") &&
918
- biomeClient.isAvailable() &&
919
- biomeClient.isSupportedFile(filePath)
920
- ) {
921
- biomeBaselines.set(
922
- filePath,
923
- biomeClient
924
- .checkFile(filePath)
925
- .filter((d) => d.category === "lint" || d.severity === "error"),
926
- );
927
- }
1140
+ // Note: ast-grep baseline removed - we use ast-grep-napi in dispatch instead
1141
+ // Note: biome baseline removed - auto-fix handles this in post-write
928
1142
 
929
1143
  // Architectural rules: Skip pre-write hints (too noisy)
930
1144
  // Post-write violations will be shown via architect runner in dispatch
@@ -989,6 +1203,22 @@ export default function (pi: ExtensionAPI) {
989
1203
  );
990
1204
  return;
991
1205
  }
1206
+
1207
+ // --- FileTime assert: prevent stale writes (file modified since agent read it) ---
1208
+ const sessionFileTime = createFileTime("default");
1209
+ try {
1210
+ sessionFileTime.assert(filePath);
1211
+ } catch (err: unknown) {
1212
+ if (err instanceof FileTimeError) {
1213
+ // File was modified externally or never read - warn but don't block (for now)
1214
+ // In strict mode this could block; currently we just surface the warning
1215
+ const warning = `āš ļø FileTime warning: ${err.message}`;
1216
+ dbg(warning);
1217
+ // Don't return - let the operation proceed with warning
1218
+ }
1219
+ }
1220
+ // Record this write so future assertions know the agent has the current state
1221
+ sessionFileTime.read(filePath);
992
1222
  dbg(
993
1223
  `tool_result: tracking turn state for ${event.toolName} on ${filePath}`,
994
1224
  );
@@ -1156,7 +1386,7 @@ export default function (pi: ExtensionAPI) {
1156
1386
  // Python: Ruff auto-fix (enabled by default)
1157
1387
  if (
1158
1388
  !noAutofixRuff &&
1159
- ruffClient.isAvailable() &&
1389
+ (await ruffClient.ensureAvailable()) &&
1160
1390
  ruffClient.isPythonFile(filePath)
1161
1391
  ) {
1162
1392
  const result = ruffClient.fixFile(filePath);
@@ -1266,56 +1496,110 @@ export default function (pi: ExtensionAPI) {
1266
1496
  }
1267
1497
  phaseEnd("test_runner", { found: testInfoFound, ran: testRunnerRan });
1268
1498
 
1269
- // --- TypeScript Language Service diagnostics (post-write) ---
1270
- phaseStart("typescript_lsp");
1271
- // Fast semantic analysis for type errors, unused vars, etc.
1272
- if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
1273
- try {
1274
- const tsClient = new TypeScriptClient();
1275
-
1276
- // Track the file in the language service
1277
- tsClient.addFile(filePath, fileContent || "");
1278
-
1279
- // Get diagnostics (syntactic + semantic)
1280
- const diagnostics = tsClient.getDiagnostics(filePath);
1281
-
1282
- if (diagnostics.length > 0) {
1283
- // Filter to most important diagnostics
1284
- const importantDiags = diagnostics.filter((d) => {
1285
- // Focus on errors and important warnings
1286
- // Skip noisy ones like "cannot find name" from missing imports
1287
- const code = (d as any).code;
1288
- if (code === 2304) return false; // "Cannot find name"
1289
- if (code === 2307) return false; // "Cannot find module"
1290
- // DiagnosticSeverity.Error = 1, Warning = 2
1291
- return d.severity === 1 || (d.severity === 2 && !code);
1292
- });
1293
-
1294
- if (importantDiags.length > 0) {
1295
- const tsOutput = importantDiags
1296
- .slice(0, 5)
1297
- .map((d) => {
1298
- // DiagnosticSeverity.Error = 1
1299
- const severity = d.severity === 1 ? "šŸ”“" : "🟔";
1300
- return ` ${severity} [TS${(d as any).code}] ${d.message.split("\n")[0]}`;
1301
- })
1302
- .join("\n");
1303
- lspOutput += `\n\nšŸ“ TypeScript Diagnostics:\n${tsOutput}`;
1304
- }
1499
+ // Note: TypeScript diagnostics are handled by the ts-lsp dispatch runner above.
1500
+ // No inline TypeScriptClient check here — dispatch already covers it.
1501
+
1502
+ // --- Complexity tracking (post-write) ---
1503
+ phaseStart("complexity_check");
1504
+ if (complexityClient.isSupportedFile(filePath)) {
1505
+ const oldBaseline = complexityBaselines.get(filePath);
1506
+ const newComplexity = complexityClient.analyzeFile(filePath);
1507
+ if (oldBaseline && newComplexity) {
1508
+ const complexityDelta = {
1509
+ cognitive:
1510
+ newComplexity.cognitiveComplexity - oldBaseline.cognitiveComplexity,
1511
+ maintainability:
1512
+ newComplexity.maintainabilityIndex -
1513
+ oldBaseline.maintainabilityIndex,
1514
+ lines: newComplexity.linesOfCode - oldBaseline.linesOfCode,
1515
+ };
1516
+ // Warn if complexity significantly increased
1517
+ if (
1518
+ complexityDelta.cognitive > 3 ||
1519
+ complexityDelta.maintainability < -5
1520
+ ) {
1521
+ lspOutput += `\n\nāš ļø Complexity increased: +${complexityDelta.cognitive} cognitive, ${complexityDelta.maintainability.toFixed(1)} maintainability`;
1305
1522
  }
1306
- } catch (err) {
1307
- dbg(`typescript-client error: ${err}`);
1523
+ phaseEnd("complexity_check", {
1524
+ delta: complexityDelta,
1525
+ warned:
1526
+ complexityDelta.cognitive > 3 ||
1527
+ complexityDelta.maintainability < -5,
1528
+ });
1529
+ } else {
1530
+ phaseEnd("complexity_check", { delta: null, warned: false });
1308
1531
  }
1532
+ } else {
1533
+ phaseEnd("complexity_check", { skipped: true });
1309
1534
  }
1310
- phaseEnd("typescript_lsp", {
1311
- isTsFile: filePath.endsWith(".ts") || filePath.endsWith(".tsx"),
1312
- });
1313
1535
 
1314
1536
  // Agent behavior warnings (blind writes, thrashing)
1315
1537
  if (behaviorWarnings.length > 0) {
1316
1538
  lspOutput += `\n\n${agentBehaviorClient.formatWarnings(behaviorWarnings)}`;
1317
1539
  }
1318
1540
 
1541
+ // --- Cascade diagnostics: check other files for errors (when --lens-lsp) ---
1542
+ if (pi.getFlag("lens-lsp") && !pi.getFlag("no-lsp")) {
1543
+ const MAX_CASCADE_FILES = 5;
1544
+ const MAX_DIAGNOSTICS_PER_FILE = 20;
1545
+ const cascadeStart = Date.now();
1546
+
1547
+ try {
1548
+ const lspService = getLSPService();
1549
+ const allDiags = await lspService.getAllDiagnostics();
1550
+ const normalizedEditedPath = path.resolve(filePath);
1551
+ const otherFileErrors: Array<{
1552
+ file: string;
1553
+ errors: import("./clients/lsp/client.js").LSPDiagnostic[];
1554
+ }> = [];
1555
+
1556
+ for (const [diagPath, diags] of allDiags) {
1557
+ if (path.resolve(diagPath) === normalizedEditedPath) continue; // Skip edited file (dispatch already covered it)
1558
+ const errors = diags.filter((d) => d.severity === 1);
1559
+ if (errors.length > 0) {
1560
+ otherFileErrors.push({ file: diagPath, errors });
1561
+ }
1562
+ }
1563
+
1564
+ if (otherFileErrors.length > 0) {
1565
+ lspOutput += `\n\nšŸ“ Cascade errors detected in ${otherFileErrors.length} other file(s):`;
1566
+ for (const { file, errors } of otherFileErrors.slice(
1567
+ 0,
1568
+ MAX_CASCADE_FILES,
1569
+ )) {
1570
+ const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE);
1571
+ const suffix =
1572
+ errors.length > MAX_DIAGNOSTICS_PER_FILE
1573
+ ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
1574
+ : "";
1575
+ // Structured XML format (like OpenCode) for cleaner parsing
1576
+ lspOutput += `\n<diagnostics file="${file}">`;
1577
+ for (const e of limited) {
1578
+ const line = (e.range?.start?.line ?? 0) + 1;
1579
+ const col = (e.range?.start?.character ?? 0) + 1;
1580
+ const code = e.code ? ` [${e.code}]` : "";
1581
+ lspOutput += `\n ${code} (${line}:${col}) ${e.message.split("\n")[0].slice(0, 100)}`;
1582
+ }
1583
+ lspOutput += `${suffix}\n</diagnostics>`;
1584
+ }
1585
+ if (otherFileErrors.length > MAX_CASCADE_FILES) {
1586
+ lspOutput += `\n... and ${otherFileErrors.length - MAX_CASCADE_FILES} more files with errors`;
1587
+ }
1588
+ }
1589
+
1590
+ logLatency({
1591
+ type: "phase",
1592
+ toolName,
1593
+ filePath,
1594
+ phase: "cascade_diagnostics",
1595
+ durationMs: Date.now() - cascadeStart,
1596
+ metadata: { filesWithErrors: otherFileErrors.length },
1597
+ });
1598
+ } catch (err) {
1599
+ dbg(`cascade diagnostics error: ${err}`);
1600
+ }
1601
+ }
1602
+
1319
1603
  // LATENCY TRACKING: Log timing before returning
1320
1604
  const elapsed = Date.now() - toolResultStart;
1321
1605
  phaseEnd("total", { hasOutput: !!lspOutput });
@@ -1419,7 +1703,7 @@ export default function (pi: ExtensionAPI) {
1419
1703
  }
1420
1704
 
1421
1705
  // madge: only check files where imports changed
1422
- if (depChecker.isAvailable()) {
1706
+ if (await depChecker.ensureAvailable()) {
1423
1707
  const madgeFiles = cacheManager.getFilesForMadge(cwd);
1424
1708
  if (madgeFiles.length > 0) {
1425
1709
  dbg(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "description": "pi extension for real-time code quality — 31 LSP servers, tree-sitter structural analysis, AST pattern matching, auto-install for TypeScript/Python tooling, duplicate detection, complexity metrics, and inline blockers with comprehensive /lens-booboo reports",
6
6
  "repository": {
@@ -56,6 +56,7 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@ast-grep/napi": "^0.42.0",
59
+ "@biomejs/biome": "^2.4.10",
59
60
  "@types/node": "^22.10.5",
60
61
  "js-yaml": "^4.1.1",
61
62
  "typescript": "^5.0.0",
@@ -0,0 +1,45 @@
1
+ # Nested Anchor Tags Detection
2
+ # Detects nested <a> tags which are invalid HTML
3
+ id: no-nested-links
4
+ name: Nested anchor tags
5
+ severity: error
6
+ category: correctness
7
+ language: tsx
8
+
9
+ message: "Nested <a> tags are invalid HTML and cause unexpected behavior"
10
+
11
+ description: |
12
+ HTML does not allow anchor tags nested inside other anchor tags.
13
+ This causes rendering issues and unpredictable click behavior.
14
+
15
+ query: |
16
+ (jsx_element
17
+ open_tag: (jsx_opening_element
18
+ (identifier) @OUTER
19
+ (#eq? @OUTER "a"))
20
+ (jsx_element
21
+ open_tag: (jsx_opening_element
22
+ (identifier) @INNER
23
+ (#eq? @INNER "a"))))
24
+
25
+ metavars:
26
+ - OUTER
27
+ - INNER
28
+
29
+ tags:
30
+ - correctness
31
+ - react
32
+ - jsx
33
+ - html
34
+
35
+ examples:
36
+ bad: |
37
+ <a href="/outer">
38
+ <a href="/inner">Click</a>
39
+ </a>
40
+
41
+ good: |
42
+ <a href="/outer">Outer link</a>
43
+ <a href="/inner">Inner link</a>
44
+
45
+ has_fix: false