pi-lens 3.8.70 → 3.8.71

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 (90) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +16 -0
  3. package/dist/clients/bus-events-logger.js +10 -8
  4. package/dist/clients/cache/rule-cache.js +9 -5
  5. package/dist/clients/cache-manager.js +19 -13
  6. package/dist/clients/diagnostic-dispositions.js +298 -0
  7. package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
  8. package/dist/clients/dispatch/dispatcher.js +6 -1
  9. package/dist/clients/dispatch/integration.js +19 -4
  10. package/dist/clients/dispatch/runners/lsp.js +5 -32
  11. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  12. package/dist/clients/dispatch/suppress-writer.js +68 -0
  13. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  14. package/dist/clients/disposition-logger.js +39 -0
  15. package/dist/clients/disposition-publish.js +109 -0
  16. package/dist/clients/file-utils.js +94 -33
  17. package/dist/clients/format-events-publish.js +299 -0
  18. package/dist/clients/git-tracked-ignore.js +208 -0
  19. package/dist/clients/instance-registry.js +74 -4
  20. package/dist/clients/json-cache-read.js +65 -0
  21. package/dist/clients/language-profile.js +4 -0
  22. package/dist/clients/lens-map.js +1079 -0
  23. package/dist/clients/lsp/aggregation.js +69 -2
  24. package/dist/clients/lsp/client.js +72 -7
  25. package/dist/clients/lsp/index.js +486 -14
  26. package/dist/clients/lsp/server.js +17 -1
  27. package/dist/clients/lsp/tsserver-sync.js +145 -0
  28. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  29. package/dist/clients/mcp/session.js +8 -0
  30. package/dist/clients/opengrep-config.js +2 -10
  31. package/dist/clients/path-utils.js +26 -0
  32. package/dist/clients/pipeline.js +4 -0
  33. package/dist/clients/project-diagnostics/cache.js +5 -12
  34. package/dist/clients/project-snapshot.js +3 -6
  35. package/dist/clients/read-guard.js +1 -1
  36. package/dist/clients/review-graph/builder.js +78 -52
  37. package/dist/clients/review-graph/import-resolvers.js +53 -19
  38. package/dist/clients/runtime-agent-end.js +26 -0
  39. package/dist/clients/runtime-coordinator.js +9 -1
  40. package/dist/clients/runtime-session.js +53 -11
  41. package/dist/clients/runtime-tool-call.js +789 -0
  42. package/dist/clients/runtime-tool-result.js +15 -2
  43. package/dist/clients/runtime-turn.js +9 -2
  44. package/dist/clients/session-state-store.js +6 -9
  45. package/dist/clients/sgconfig.js +2 -9
  46. package/dist/clients/source-filter.js +5 -0
  47. package/dist/clients/startup-scan.js +68 -1
  48. package/dist/clients/typos-config.js +2 -11
  49. package/dist/clients/widget-state.js +22 -0
  50. package/dist/clients/zizmor-config.js +2 -10
  51. package/dist/index.js +21089 -18685
  52. package/dist/tools/lens-diagnostic-mark.js +158 -0
  53. package/dist/tools/lens-diagnostics.js +41 -7
  54. package/dist/tools/lsp-diagnostics.js +99 -119
  55. package/docs/agent-tools.md +11 -4
  56. package/docs/dispositions.md +85 -0
  57. package/docs/environment-variables.md +16 -0
  58. package/docs/fable.md +10 -9
  59. package/docs/servercapabilities.md +1 -1
  60. package/docs/tools.md +3 -0
  61. package/package.json +6 -1
  62. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  63. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  64. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  65. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  66. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
  67. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  68. package/docs/agent-tooling-improvement-plan.md +0 -660
  69. package/docs/inspiration_gograph.md +0 -601
  70. package/docs/inspiration_neovim.md +0 -1795
  71. package/docs/inspiration_neovim2.md +0 -402
  72. package/docs/inspiration_tree_sitter_rules.md +0 -369
  73. package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
  74. package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
  75. package/docs/lsp-bridge-analysis.md +0 -444
  76. package/docs/newinspiration.md +0 -338
  77. package/grammars/tree-sitter-c.wasm +0 -0
  78. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  79. package/grammars/tree-sitter-cpp.wasm +0 -0
  80. package/grammars/tree-sitter-dart.wasm +0 -0
  81. package/grammars/tree-sitter-elixir.wasm +0 -0
  82. package/grammars/tree-sitter-kotlin.wasm +0 -0
  83. package/grammars/tree-sitter-lua.wasm +0 -0
  84. package/grammars/tree-sitter-ocaml.wasm +0 -0
  85. package/grammars/tree-sitter-php.wasm +0 -0
  86. package/grammars/tree-sitter-ruby.wasm +0 -0
  87. package/grammars/tree-sitter-swift.wasm +0 -0
  88. package/grammars/tree-sitter-toml.wasm +0 -0
  89. package/grammars/tree-sitter-vue.wasm +0 -0
  90. package/grammars/tree-sitter-zig.wasm +0 -0
@@ -15,6 +15,7 @@ import { isTestMode } from "../env-utils.js";
15
15
  import { getGlobalPiLensDir, getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
16
16
  import { recordLsp } from "../widget-state.js";
17
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
18
+ import { detectFileRole } from "../file-role.js";
18
19
  import { logLatency } from "../latency-logger.js";
19
20
  import { withDeadline } from "../deadline-utils.js";
20
21
  import { normalizeMapKey, uriToPath } from "../path-utils.js";
@@ -25,6 +26,9 @@ import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server
25
26
  import { getStrategy } from "./server-strategies.js";
26
27
  import { raceToCompletion } from "./aggregation.js";
27
28
  import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
29
+ import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
30
+ import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
31
+ import { classifyCascadeWaitTier } from "./cascade-tier.js";
28
32
  // --- Init override helpers ---
29
33
  /**
30
34
  * Recursively merges `override` onto `base`. Override wins on leaf conflicts
@@ -96,6 +100,42 @@ function readEnvDiagnosticsWaitMs() {
96
100
  return undefined;
97
101
  return parsed;
98
102
  }
103
+ /**
104
+ * #707: grace delay before the racing tsserver sync clean-confirm fires on a
105
+ * tier-3 silent primary. Short enough to beat the full push-wait budget by a
106
+ * wide margin (~300ms grace + sync RTT vs ~1000ms budget), long enough to give
107
+ * a genuinely dirty file's push a head start — a push that arrives within the
108
+ * grace costs zero extra requests. Read at call time (not memoized) so tests
109
+ * and users can tune without a rebuild.
110
+ */
111
+ function readTsserverSyncGraceMs() {
112
+ const raw = process.env.PI_LENS_TSSERVER_SYNC_GRACE_MS;
113
+ if (raw === undefined)
114
+ return 300;
115
+ const parsed = Number.parseInt(raw, 10);
116
+ if (!Number.isFinite(parsed) || parsed < 0)
117
+ return 300;
118
+ return parsed;
119
+ }
120
+ /**
121
+ * Read the `PI_LENS_AUX_GRACE_MS` env override at call time (not module
122
+ * load time) so tests can set it per-case. Controls how long auxiliary-role
123
+ * promises (opengrep, ast-grep, zizmor, …) are waited after all primary-role
124
+ * promises have settled in both getDiagnostics (raceToCompletion) and the
125
+ * touchFile push wait. Default 500ms — conservative enough to include
126
+ * auxiliaries that are nearly done while not blocking the primary result.
127
+ * Returns undefined when the var is absent (caller falls back to the
128
+ * raceToCompletion default of 500ms, keeping the two in sync).
129
+ */
130
+ function readEnvAuxGraceMs() {
131
+ const raw = process.env.PI_LENS_AUX_GRACE_MS;
132
+ if (raw === undefined)
133
+ return undefined;
134
+ const parsed = Number.parseInt(raw, 10);
135
+ if (!Number.isFinite(parsed) || parsed < 0)
136
+ return undefined;
137
+ return parsed;
138
+ }
99
139
  const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
100
140
  const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
101
141
  // Once the fastest client has diagnostics, remaining clients get this window before
@@ -295,6 +335,10 @@ function getMaxWorkspaceDiagnosticFiles() {
295
335
  async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceDiagnosticFiles(), signal) {
296
336
  const files = [];
297
337
  const ignoreMatcher = getProjectIgnoreMatcher(root);
338
+ // #703: prime the tracked-files set once before the walk so a tracked file
339
+ // matching a `.gitignore`/global pattern still gets its workspace
340
+ // diagnostics pulled. Fail-open on no-git/spawn failure.
341
+ await ignoreMatcher.ensureTrackedIndex();
298
342
  async function walk(current) {
299
343
  if (signal?.aborted || files.length >= maxFiles)
300
344
  return;
@@ -733,6 +777,19 @@ export class LSPService {
733
777
  recordLsp(server.id, root, "spawn_start");
734
778
  try {
735
779
  const spawned = await server.spawn(root, { allowInstall });
780
+ // Guard 1: service was shut down while we were waiting for the spawn.
781
+ // Kill the raw process — no LSPClient exists yet — and bail out without
782
+ // marking the key broken (this is not a server failure).
783
+ if (this.isDestroyed) {
784
+ try {
785
+ spawned?.process?.process?.kill();
786
+ }
787
+ catch {
788
+ // pi-lens-ignore: missing-error-propagation — best-effort kill on aborted spawn
789
+ }
790
+ logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-spawn)`);
791
+ return undefined;
792
+ }
736
793
  if (!spawned) {
737
794
  logSessionStart(`lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`);
738
795
  recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
@@ -766,6 +823,13 @@ export class LSPService {
766
823
  initializeTimeoutMs: server.initializeTimeoutMs,
767
824
  launchVariant: spawned.launchVariant,
768
825
  });
826
+ // Guard 2: service was shut down while we were completing the initialize
827
+ // handshake. Shut down the live client best-effort and do not register it.
828
+ if (this.isDestroyed) {
829
+ client.shutdown({ fast: true }).catch(() => { });
830
+ logSessionStart(`lsp spawn ${server.id}: aborted (service shut down mid-initialize)`);
831
+ return undefined;
832
+ }
769
833
  const wsDiag = typeof client.getWorkspaceDiagnosticsSupport === "function"
770
834
  ? client.getWorkspaceDiagnosticsSupport()
771
835
  : {
@@ -940,6 +1004,17 @@ export class LSPService {
940
1004
  }
941
1005
  }
942
1006
  let diagnosticsTimedOut = false;
1007
+ // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1008
+ // the aux grace window. Undefined when no aux was cut off (primary-only
1009
+ // paths never set this). Logged in lsp_touch_file metadata.
1010
+ let auxCutOffServerIds;
1011
+ // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1012
+ // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1013
+ // holds the sync commands' answer when the racing confirm won the wait
1014
+ // (undefined = the race didn't produce an answer; the end-of-wait
1015
+ // fallback below may still fill it in on a timed-out empty result).
1016
+ let tsserverSyncEligible = false;
1017
+ let tsserverSyncConfirmed = undefined;
943
1018
  if (diagnosticsMode !== "none") {
944
1019
  // Resolution: env wins so users can tune the cap without rebuilding.
945
1020
  // Otherwise, on the single-server hot path (primary scope), use that
@@ -1008,6 +1083,13 @@ export class LSPService {
1008
1083
  Math.min(300, strategyWait);
1009
1084
  }
1010
1085
  if (callerCap !== undefined) {
1086
+ // #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
1087
+ // to act as a FLOOR (give it at least this much, possibly more
1088
+ // if the strategy already wants more) rather than the normal
1089
+ // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1090
+ if (options.warmupOverride) {
1091
+ return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1092
+ }
1011
1093
  return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1012
1094
  }
1013
1095
  return strategyWait > 0 ? strategyWait : modeFloor;
@@ -1030,8 +1112,51 @@ export class LSPService {
1030
1112
  }
1031
1113
  // Detection deadline = the slowest individual server's budget.
1032
1114
  const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1115
+ // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1116
+ // starts. Cheap synchronous gates first (notify succeeded, collecting,
1117
+ // primary scope, strategy marked silentOnClean — only classic
1118
+ // typescript-language-server carries that flag today), then the live
1119
+ // capability-snapshot tier classification (`classifyCascadeWaitTier`,
1120
+ // which also excludes native-ts7 via `launchVariant`). Non-typescript
1121
+ // and pull-capable servers fail the synchronous `silentOnClean` gate and
1122
+ // pay ZERO extra work — not even the snapshot read.
1123
+ if (!notifyWriteTimedOut &&
1124
+ options.collectDiagnostics === true &&
1125
+ clientScope === "primary" &&
1126
+ spawned.length === 1 &&
1127
+ getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1128
+ try {
1129
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1130
+ tsserverSyncEligible =
1131
+ classifyCascadeWaitTier(this, filePath, snapshots) ===
1132
+ "tier3-silent";
1133
+ }
1134
+ catch {
1135
+ // Fail-safe: ineligible — today's full wait, no sync attempt.
1136
+ }
1137
+ }
1033
1138
  const waitStartedAt = Date.now();
1034
- await Promise.all(spawned.map((entry) => {
1139
+ // R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
1140
+ // slow auxiliary no longer holds the push wait to its own deadline.
1141
+ // Primary waits resolve on their own per-server budget; once ALL primaries
1142
+ // have settled the auxiliaries get at most auxGraceMs before we proceed.
1143
+ // Primary-only and "all"/"primary" scopes are completely unaffected —
1144
+ // they fall through to the original Promise.all path below.
1145
+ //
1146
+ // "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
1147
+ // In the with-auxiliary spawn list, `getClientForFile` returns the
1148
+ // language-primary entry first and `getAuxiliaryClientsForFile` appends
1149
+ // the rest — but we use info.role rather than position so the logic is
1150
+ // correct even if ordering shifts in the future.
1151
+ //
1152
+ // The #707 tsserver sync race operates exclusively on single-server
1153
+ // primary-scope touches (guarded by `clientScope === "primary" &&
1154
+ // spawned.length === 1`), so there is NO interaction with this path.
1155
+ const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
1156
+ spawned.some((e) => e.info.role === "auxiliary");
1157
+ // Per-server wait promises (each already bounded by its own
1158
+ // perServerTimeout — unchanged from before R8).
1159
+ const perServerWaits = spawned.map((entry) => {
1035
1160
  const serverTimeout = timeoutFor(entry.client.serverId);
1036
1161
  const baseline = diagnosticBaselines.get(entry.client);
1037
1162
  const wait = !notifySkipped && Number.isFinite(baseline)
@@ -1040,12 +1165,154 @@ export class LSPService {
1040
1165
  })
1041
1166
  : entry.client.waitForDiagnostics(filePath, serverTimeout);
1042
1167
  return wait.catch(() => undefined);
1043
- }));
1168
+ });
1169
+ // The push wait — same per-server budget composition as before #707;
1170
+ // only the awaiting changed (assigned so it can be raced below).
1171
+ let pushWaitSettled = false;
1172
+ const pushWait = hasTouchAuxiliaries
1173
+ ? (() => {
1174
+ // Primary waits: all non-auxiliary servers.
1175
+ const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
1176
+ // Aux waits: auxiliary servers (advisory).
1177
+ const auxWaits = perServerWaits
1178
+ .map((p, i) => spawned[i].info.role === "auxiliary"
1179
+ ? { promise: p, serverId: spawned[i].info.id }
1180
+ : null)
1181
+ .filter((x) => x !== null);
1182
+ const auxGraceMs = readEnvAuxGraceMs() ?? 500;
1183
+ // After all primaries settle, give auxiliaries at most auxGraceMs.
1184
+ // Late aux results are dropped from the wait (advisory only — they
1185
+ // land in the client cache and surface on the next edit); aux servers
1186
+ // that did answer within the grace are included automatically since
1187
+ // their waitForDiagnostics already resolved. The cut-off server ids
1188
+ // are logged in the latency metadata (lsp_touch_file phase, field
1189
+ // `auxCutOffServerIds`).
1190
+ return Promise.all(primaryWaits).then(async () => {
1191
+ if (auxWaits.length === 0)
1192
+ return;
1193
+ const auxTimeout = new Promise((resolve) => {
1194
+ const t = setTimeout(() => resolve("timeout"), auxGraceMs);
1195
+ if (typeof t === "object" && "unref" in t)
1196
+ t.unref?.();
1197
+ });
1198
+ const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
1199
+ const outcome = await Promise.race([auxAll, auxTimeout]);
1200
+ if (outcome === "timeout") {
1201
+ // Record which auxiliaries did NOT finish in time.
1202
+ const unfinished = [];
1203
+ for (const a of auxWaits) {
1204
+ let done = false;
1205
+ // Check synchronously if already resolved by racing against
1206
+ // a resolved promise.
1207
+ await Promise.race([
1208
+ a.promise.then(() => {
1209
+ done = true;
1210
+ }),
1211
+ Promise.resolve(),
1212
+ ]);
1213
+ if (!done)
1214
+ unfinished.push(a.serverId);
1215
+ }
1216
+ if (unfinished.length > 0)
1217
+ auxCutOffServerIds = unfinished;
1218
+ }
1219
+ });
1220
+ })()
1221
+ : Promise.all(perServerWaits).then(() => { });
1222
+ pushWait.then(() => {
1223
+ pushWaitSettled = true;
1224
+ });
1225
+ if (tsserverSyncEligible) {
1226
+ // #707 racing variant: rather than burning the full push-wait budget
1227
+ // on a silent-on-clean server (which by definition never answers on a
1228
+ // clean file), race the push wait against a grace-delayed sync
1229
+ // confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
1230
+ // gives a genuinely dirty file's push a head start: if diagnostics
1231
+ // arrive before the grace elapses, the sync request never goes out —
1232
+ // zero new latency or requests on the push-answers path.
1233
+ //
1234
+ // Race semantics:
1235
+ // - sync answers first → that's the confirmed result (clean OR
1236
+ // dirty — the sync commands return the file's real syntactic +
1237
+ // semantic state, so a dirty-file win is still correct and its
1238
+ // findings are surfaced, never discarded).
1239
+ // - push settles first → push wins; a still-in-flight sync outcome
1240
+ // is discarded (the racer checks `pushWaitSettled` after the
1241
+ // call returns and drops its own result).
1242
+ // - sync unavailable/fails → the racer parks on a never-resolving
1243
+ // promise so the race is decided by the push wait's own budget,
1244
+ // exactly today's behavior (the end-of-wait fallback below still
1245
+ // gets its shot on a timed-out empty result).
1246
+ // The racer never rejects (every failure path is caught), so the
1247
+ // losing promise can never surface as an unhandled rejection.
1248
+ const graceMs = readTsserverSyncGraceMs();
1249
+ const primaryClient = spawned[0].client;
1250
+ // Resolves with the sync commands' diagnostics when the confirm
1251
+ // succeeds; parks on a never-resolving promise on EVERY other path
1252
+ // (push already answered, sync unavailable/failed, push won while
1253
+ // in flight) so the race is then decided by the push wait's own
1254
+ // budget — exactly today's behavior.
1255
+ const syncRacer = (async () => {
1256
+ await new Promise((resolve) => {
1257
+ const timer = setTimeout(resolve, graceMs);
1258
+ timer.unref?.();
1259
+ });
1260
+ // Push already answered (settled, or published diagnostics that
1261
+ // its wait is about to settle on) — nothing to confirm, no sync
1262
+ // request goes out.
1263
+ if (pushWaitSettled ||
1264
+ primaryClient.getDiagnostics(filePath).length > 0) {
1265
+ return new Promise(() => { });
1266
+ }
1267
+ try {
1268
+ const result = await attemptTsserverSyncDiagnostics(filePath, this);
1269
+ if (result === undefined || pushWaitSettled) {
1270
+ // Sync unavailable/failed, or push won while the sync call
1271
+ // was in flight — drop the sync outcome and let the push
1272
+ // wait decide the race.
1273
+ return new Promise(() => { });
1274
+ }
1275
+ return result;
1276
+ }
1277
+ catch {
1278
+ return new Promise(() => { });
1279
+ }
1280
+ })();
1281
+ const raceOutcome = await Promise.race([
1282
+ pushWait.then(() => undefined),
1283
+ syncRacer,
1284
+ ]);
1285
+ if (raceOutcome !== undefined) {
1286
+ tsserverSyncConfirmed = raceOutcome;
1287
+ }
1288
+ }
1289
+ else {
1290
+ await pushWait;
1291
+ }
1044
1292
  const waitedMs = Date.now() - waitStartedAt;
1045
- // Within ~20 ms of the configured budget we treat it as a timeout;
1046
- // the LSP didn't beat the cap. Diagnostics that arrive late still
1047
- // land in the client's cache and surface on the next edit.
1048
- if (waitedMs + 20 >= timeoutMs) {
1293
+ if (tsserverSyncConfirmed !== undefined) {
1294
+ // #707: the racing sync confirm won — a definitive answer well under
1295
+ // the push-wait budget. Not a timeout, not inconclusive.
1296
+ logLatency({
1297
+ type: "phase",
1298
+ phase: "lsp_tsserver_sync_confirm",
1299
+ filePath: normalizedPath,
1300
+ durationMs: waitedMs,
1301
+ metadata: {
1302
+ source,
1303
+ clientScope,
1304
+ diagnosticsMode,
1305
+ mode: "race",
1306
+ confirmedDiagnosticCount: tsserverSyncConfirmed.length,
1307
+ budgetMs: timeoutMs,
1308
+ savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
1309
+ },
1310
+ });
1311
+ }
1312
+ else if (waitedMs + 20 >= timeoutMs) {
1313
+ // Within ~20 ms of the configured budget we treat it as a timeout;
1314
+ // the LSP didn't beat the cap. Diagnostics that arrive late still
1315
+ // land in the client's cache and surface on the next edit.
1049
1316
  diagnosticsTimedOut = true;
1050
1317
  logLatency({
1051
1318
  type: "phase",
@@ -1061,9 +1328,63 @@ export class LSPService {
1061
1328
  });
1062
1329
  }
1063
1330
  }
1064
- const collected = options.collectDiagnostics
1065
- ? mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
1331
+ // #707: when the racing sync confirm won the wait, its answer IS the
1332
+ // collected result — the file's real syntactic + semantic state straight
1333
+ // from tsserver (clean = [], dirty = real findings that a silentOnClean
1334
+ // server had computed but never published). Otherwise merge the push
1335
+ // diagnostics from the client cache as always.
1336
+ let collected = options.collectDiagnostics
1337
+ ? tsserverSyncConfirmed !== undefined
1338
+ ? mergeLspDiagnostics(tsserverSyncConfirmed)
1339
+ : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
1066
1340
  : undefined;
1341
+ // #707 end-of-wait fallback: when the racing confirm did NOT decide the
1342
+ // wait (sync unavailable/failed mid-race, or push resolved as a bare
1343
+ // timeout) and the wait timed out with an empty result on an eligible
1344
+ // touch, give the sync clean-confirm one last shot before reporting
1345
+ // inconclusive. `tsserverSyncEligible` already encodes every gate (notify
1346
+ // succeeded, collecting, primary scope, tier3-silent classic typescript —
1347
+ // native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
1348
+ // answers (even with an empty body, which is a confirmed clean), we use
1349
+ // those diagnostics as the confirmed result and clear the
1350
+ // `diagnosticsTimedOut` flag so the touch is no longer treated as
1351
+ // inconclusive. Sync diagnostics on a dirty file are surfaced, not
1352
+ // discarded. If the sync call fails or is unavailable, we fall through to
1353
+ // today's behavior: `inconclusive` = true, `collected` unchanged. This
1354
+ // turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
1355
+ // even when the race path couldn't answer.
1356
+ if (diagnosticsTimedOut &&
1357
+ tsserverSyncEligible &&
1358
+ collected !== undefined &&
1359
+ collected.length === 0) {
1360
+ try {
1361
+ const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
1362
+ if (syncResult !== undefined) {
1363
+ // Sync answered — confirmed result (clean or with diagnostics).
1364
+ // Clear the timed-out flag so the touch is no longer inconclusive.
1365
+ diagnosticsTimedOut = false;
1366
+ collected = syncResult.length > 0
1367
+ ? mergeLspDiagnostics(syncResult)
1368
+ : [];
1369
+ logLatency({
1370
+ type: "phase",
1371
+ phase: "lsp_tsserver_sync_confirm",
1372
+ filePath: normalizedPath,
1373
+ durationMs: Date.now() - startedAt,
1374
+ metadata: {
1375
+ source,
1376
+ clientScope,
1377
+ diagnosticsMode,
1378
+ mode: "end-of-wait",
1379
+ confirmedDiagnosticCount: collected.length,
1380
+ },
1381
+ });
1382
+ }
1383
+ }
1384
+ catch {
1385
+ // Any failure here falls through to today's inconclusive behavior.
1386
+ }
1387
+ }
1067
1388
  // A touch is inconclusive when EITHER the notify write or the
1068
1389
  // diagnostics wait hit their deadline for ANY of the spawned servers
1069
1390
  // (these flags are touch-wide, covering the whole `Promise.all` over
@@ -1138,6 +1459,11 @@ export class LSPService {
1138
1459
  notifyWriteTimedOut,
1139
1460
  diagnosticsTimedOut,
1140
1461
  inconclusive,
1462
+ // R8 (#714): server ids of auxiliaries whose push wait was cut off by
1463
+ // the aux grace window (primary settled clean + aux timed out in grace).
1464
+ // Absent when no aux was cut off. These servers' diagnostics are
1465
+ // advisory-only and will surface on the next edit from their cache.
1466
+ ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
1141
1467
  },
1142
1468
  });
1143
1469
  return collected ?? [];
@@ -1245,12 +1571,25 @@ export class LSPService {
1245
1571
  // Document mode: 0ms grace — return as soon as any client has results.
1246
1572
  // Full mode: 400ms grace — wait a bit for other clients to catch up.
1247
1573
  const graceMs = diagnosticsMode === "document" ? 0 : EARLY_UNBLOCK_GRACE_MS;
1574
+ // R8 (#714): per-promise role descriptors so raceToCompletion can apply
1575
+ // a bounded aux grace once all primary-role promises have settled.
1576
+ // Servers with role:"auxiliary" (opengrep, ast-grep, zizmor, …) get at
1577
+ // most PI_LENS_AUX_GRACE_MS (default 500ms) after the primary settles;
1578
+ // late arrivals are dropped (advisory only — they land in the client
1579
+ // cache and surface on the next edit). Primary-only callers have no
1580
+ // auxiliary descriptors, so this path is never entered and there is
1581
+ // zero behavior change for the single-server hot path.
1582
+ const diagDescriptors = spawned.map((entry) => ({
1583
+ role: entry.info.role === "auxiliary" ? "auxiliary" : "primary",
1584
+ }));
1248
1585
  // Result-aware racing: trigger early-unblock when any client has results,
1249
1586
  // OR when a seedFirstPush server returns (its first push is authoritative
1250
1587
  // even when empty — waiting longer yields nothing more).
1251
1588
  const perServer = await raceToCompletion(clientWaits, (results) => results.some((r) => r.diagnosticCount > 0 || getStrategy(r.serverId).seedFirstPush), {
1252
1589
  timeoutMs: Math.max(...spawned.map((entry) => getStrategy(entry.info.id).aggregateWaitMs)),
1253
1590
  graceMs,
1591
+ descriptors: diagDescriptors,
1592
+ auxGraceMs: readEnvAuxGraceMs(),
1254
1593
  });
1255
1594
  // Fill in any slots that timed out before producing results.
1256
1595
  const earlyUnblockedCount = spawned.length - perServer.length;
@@ -1736,6 +2075,12 @@ export class LSPService {
1736
2075
  source: "lsp_sweep_warmup",
1737
2076
  maxClientWaitMs: timeoutMs,
1738
2077
  maxDiagnosticsWaitMs: timeoutMs,
2078
+ // #669: the caller's cap here is the warm-up budget for a genuinely
2079
+ // COLD server — it must act as a floor, not the usual ceiling, or
2080
+ // `perServerTimeout` silently shrinks it to the strategy's normal
2081
+ // warm-state `aggregateWaitMs` (e.g. 1000ms for typescript) instead
2082
+ // of the requested 20000ms.
2083
+ warmupOverride: true,
1739
2084
  });
1740
2085
  await (options.signal
1741
2086
  ? Promise.race([
@@ -1793,6 +2138,43 @@ export class LSPService {
1793
2138
  let completed = 0;
1794
2139
  let timedOutFiles = 0;
1795
2140
  let lastHeartbeat = Date.now();
2141
+ // #671: reuse the last CONFIRMED per-file result instead of re-touching
2142
+ // every file through the language server(s) again when nothing relevant
2143
+ // changed since the last sweep. `createWorkspaceDiagnosticsCacheContext`
2144
+ // (`workspace-diagnostics-cache.ts`) is shared with `tools/lsp-
2145
+ // diagnostics.ts`'s batch/directory sweep so a file swept by either tool
2146
+ // benefits the other's next sweep under the SAME `scopeKey` — see that
2147
+ // module's doc comment for the invalidation rules (own-mtime +
2148
+ // best-effort cross-file dependency staleness) and why `scopeKey` exists
2149
+ // (this sweep's `excludeServerIds` differs from that tool's).
2150
+ const workspaceDiagnosticsCacheCtx = createWorkspaceDiagnosticsCacheContext(root);
2151
+ const workspaceSweepScopeKey = buildScopeKey("all", [
2152
+ ...WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS,
2153
+ ]);
2154
+ const cachedResults = [];
2155
+ const filesToTouch = [];
2156
+ for (const filePath of files) {
2157
+ const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
2158
+ if (cached) {
2159
+ cachedResults.push({
2160
+ filePath,
2161
+ diagnostics: cached.diagnostics,
2162
+ count: cached.count,
2163
+ });
2164
+ }
2165
+ else {
2166
+ filesToTouch.push(filePath);
2167
+ }
2168
+ }
2169
+ completed = cachedResults.length;
2170
+ if (cachedResults.length > 0) {
2171
+ options.onProgress?.(completed, files.length);
2172
+ }
2173
+ // Per-file scan mtime captured as each file completes below, so a
2174
+ // confirmed fresh result can be written back into the cache with the
2175
+ // mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
2176
+ // could race a concurrent edit).
2177
+ const scannedMtimeByFile = new Map();
1796
2178
  // Group files by their primary language server (#387, extracted as
1797
2179
  // `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
1798
2180
  // single-threaded per project: N concurrent touches to ONE server don't
@@ -1803,7 +2185,9 @@ export class LSPService {
1803
2185
  // in-flight touch each) and parallelize ACROSS servers — real parallelism
1804
2186
  // where it exists (a mixed TS+Python repo runs both), no flooding where it
1805
2187
  // doesn't. Capped so a many-language monorepo can't spawn unbounded groups.
1806
- const groups = groupFilesByPrimaryServer(files);
2188
+ // Only files that failed the cache-freshness check above go through this
2189
+ // (and the touch loop below) at all.
2190
+ const groups = groupFilesByPrimaryServer(filesToTouch);
1807
2191
  // #645: shared across every file/group in THIS sweep — lets a
1808
2192
  // `workspaceIndexing`-strategy server (marksman) pay its full
1809
2193
  // aggregateWaitMs budget only for the first file that touches it,
@@ -1828,6 +2212,8 @@ export class LSPService {
1828
2212
  durationMs: 0,
1829
2213
  metadata: {
1830
2214
  fileCount: files.length,
2215
+ cacheHits: cachedResults.length,
2216
+ filesToTouch: filesToTouch.length,
1831
2217
  maxFiles,
1832
2218
  perFileMs,
1833
2219
  serverGroups: groups.length,
@@ -1927,6 +2313,23 @@ export class LSPService {
1927
2313
  try {
1928
2314
  const content = contentCache.get(filePath) ??
1929
2315
  (await nodeFs.promises.readFile(filePath, "utf-8"));
2316
+ // #671: captured alongside the read, ahead of the (possibly slow)
2317
+ // touchFile wait below, so the cache entry records the mtime this
2318
+ // file actually had AT scan time — not a later re-stat that could
2319
+ // race a concurrent edit and silently mis-date the entry. Deliberately
2320
+ // synchronous (not `nodeFs.promises.stat`): this loop is timing-
2321
+ // sensitive (its opens must land inside `WatchedFilesQueue`'s 100ms
2322
+ // debounce window — see workspace-diagnostics-sweep-batch-open.test.ts
2323
+ // / -preopen-chunk.test.ts), and a blocking `statSync` costs a few
2324
+ // microseconds with no extra event-loop tick, where an awaited
2325
+ // promise would insert one.
2326
+ try {
2327
+ scannedMtimeByFile.set(filePath, nodeFs.statSync(filePath).mtimeMs);
2328
+ }
2329
+ catch {
2330
+ // Best-effort: a failed stat here just means this file won't be
2331
+ // eligible for caching below (no entry gets written for it).
2332
+ }
1930
2333
  // onTimeout:"undefined" so a hung file yields no diagnostics and the
1931
2334
  // worker moves on; a real touchFile rejection still propagates to the
1932
2335
  // catch below and is recorded as an error.
@@ -1961,8 +2364,17 @@ export class LSPService {
1961
2364
  // `.inconclusive` flag, already read above) so a `lens_diagnostics
1962
2365
  // mode=full` sweep suppresses the same findings the per-edit dispatch
1963
2366
  // runner does, instead of only the latter honoring it.
2367
+ // #692: also honor a profile's `skipTestFiles` gate (e.g. ast-grep,
2368
+ // #687/#688) — those PRs added the gate only to the per-edit merge
2369
+ // loop (`clients/dispatch/runners/lsp.ts`), so a `mode=full` sweep
2370
+ // re-surfaced every ast-grep finding on `*.test.ts` files wholesale,
2371
+ // duplicating what the per-edit path already suppresses. `content`
2372
+ // was already read above for this file, so `detectFileRole` gets the
2373
+ // higher-accuracy content-aware classification at no extra cost.
1964
2374
  const filteredDiagnostics = diagnostics
1965
- ? applyAuxiliarySuppressions(diagnostics, content)
2375
+ ? applyAuxiliarySuppressions(diagnostics, content, {
2376
+ fileRole: detectFileRole(filePath, content),
2377
+ })
1966
2378
  : diagnostics;
1967
2379
  results.push({
1968
2380
  filePath,
@@ -2017,8 +2429,19 @@ export class LSPService {
2017
2429
  if (workspacePullEnabled && !group.multiServer) {
2018
2430
  const pulled = await this.tryWorkspacePull(group.files, perFileMs);
2019
2431
  if (pulled) {
2020
- for (const result of pulled)
2432
+ for (const result of pulled) {
2021
2433
  results.push(result);
2434
+ // #671: a pull result is always confirmed (see
2435
+ // `tryWorkspacePull`'s doc comment), so it's cache-eligible
2436
+ // too — best-effort stat since the pull already resolved the
2437
+ // diagnostics for this file some time ago.
2438
+ try {
2439
+ scannedMtimeByFile.set(result.filePath, nodeFs.statSync(result.filePath).mtimeMs);
2440
+ }
2441
+ catch {
2442
+ // Not cache-eligible without a confirmed mtime.
2443
+ }
2444
+ }
2022
2445
  completed += group.files.length;
2023
2446
  options.onProgress?.(completed, files.length);
2024
2447
  return;
@@ -2067,6 +2490,7 @@ export class LSPService {
2067
2490
  durationMs: Date.now() - startedAt,
2068
2491
  metadata: {
2069
2492
  filesChecked: files.length,
2493
+ cacheHits: cachedResults.length,
2070
2494
  diagnosticCount: results.reduce((sum, result) => sum + (result?.count ?? 0), 0),
2071
2495
  serverGroups: groups.length,
2072
2496
  concurrency: groupWorkers,
@@ -2075,7 +2499,25 @@ export class LSPService {
2075
2499
  aborted: signal?.aborted ?? false,
2076
2500
  },
2077
2501
  });
2078
- return results.filter(Boolean);
2502
+ // #671: record every CONFIRMED fresh result (`!timedOut && !error`) back
2503
+ // into the cache, keyed by the mtime it was actually scanned at
2504
+ // (`scannedMtimeByFile`, captured per-file above), then persist once.
2505
+ // Deliberately survives an aborted/partial sweep — whatever completed
2506
+ // before the abort is still real, confirmed work and shouldn't be thrown
2507
+ // away; files that never got a confirmed result (including ones an abort
2508
+ // cut off before `processFile` ran) are simply absent from
2509
+ // `scannedMtimeByFile` and are skipped, leaving any pre-existing cache
2510
+ // entry for them exactly as `createWorkspaceDiagnosticsCacheContext`
2511
+ // loaded it (already-stale entries stay unreachable via `lookup`'s own
2512
+ // freshness check; nothing here needs to explicitly evict them).
2513
+ for (const result of results) {
2514
+ const scannedAt = scannedMtimeByFile.get(result.filePath);
2515
+ if (result.error || result.timedOut || scannedAt === undefined)
2516
+ continue;
2517
+ workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt);
2518
+ }
2519
+ workspaceDiagnosticsCacheCtx.persist();
2520
+ return [...cachedResults, ...results].filter(Boolean);
2079
2521
  }
2080
2522
  /**
2081
2523
  * #387 Item 2: one `workspace/diagnostic` pull covering a whole server group,
@@ -2184,8 +2626,38 @@ export class LSPService {
2184
2626
  if (this.checkDestroyed())
2185
2627
  return;
2186
2628
  this.isDestroyed = true;
2187
- // Cancel any in-flight spawns
2188
- this.state.inFlight.clear();
2629
+ // Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
2630
+ // spawnClient can observe isDestroyed and clean up. Skip on the
2631
+ // process-exiting path — the event loop is closing and we must not block.
2632
+ if (!options.processExiting && this.state.inFlight.size > 0) {
2633
+ const pending = Array.from(this.state.inFlight.values());
2634
+ this.state.inFlight.clear();
2635
+ const settled = await Promise.allSettled(pending);
2636
+ for (const result of settled) {
2637
+ if (result.status === "fulfilled" && result.value?.client) {
2638
+ result.value.client.shutdown({ fast: true }).catch(() => { });
2639
+ }
2640
+ }
2641
+ }
2642
+ else {
2643
+ this.state.inFlight.clear();
2644
+ }
2645
+ // Count alive clients BEFORE tearing them down — gives a meaningful
2646
+ // snapshot of what was released by this reset (post-teardown the count
2647
+ // would always be zero, which is useless for root-cause analysis).
2648
+ const aliveClients = this.getAliveClientCount();
2649
+ logLatency({
2650
+ type: "phase",
2651
+ phase: "lsp_service_reset",
2652
+ filePath: "",
2653
+ durationMs: 0,
2654
+ metadata: {
2655
+ reason: options.reason ?? null,
2656
+ aliveClients,
2657
+ fast: !!options.fast,
2658
+ processExiting: !!options.processExiting,
2659
+ },
2660
+ });
2189
2661
  for (const [_key, client] of this.state.clients) {
2190
2662
  try {
2191
2663
  await client.shutdown(options);