@herbertgao/pi-extensions 2026.8.5 → 2026.8.6

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 (155) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +10 -0
  8. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  9. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +5 -2
  10. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  11. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  16. package/node_modules/@narumitw/pi-btw/README.md +24 -17
  17. package/node_modules/@narumitw/pi-btw/package.json +5 -5
  18. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  19. package/node_modules/@narumitw/pi-btw/src/menu.ts +33 -13
  20. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  21. package/node_modules/pi-lens/CHANGELOG.md +95 -0
  22. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  23. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  24. package/node_modules/pi-lens/dist/clients/biome-client.js +121 -13
  25. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  26. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  27. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  28. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  29. package/node_modules/pi-lens/dist/clients/dead-code-client.js +135 -30
  30. package/node_modules/pi-lens/dist/clients/dependency-checker.js +19 -7
  31. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +6 -4
  32. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  33. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +114 -10
  34. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +101 -16
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -4
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  40. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  41. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  42. package/node_modules/pi-lens/dist/clients/extension-log.js +296 -3
  43. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  44. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  45. package/node_modules/pi-lens/dist/clients/git-guard.js +18 -21
  46. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  47. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +116 -7
  48. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  49. package/node_modules/pi-lens/dist/clients/installer/index.js +5 -1
  50. package/node_modules/pi-lens/dist/clients/jscpd-client.js +4 -9
  51. package/node_modules/pi-lens/dist/clients/knip-client.js +51 -14
  52. package/node_modules/pi-lens/dist/clients/latency-logger.js +50 -1
  53. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  54. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  55. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  56. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  57. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +58 -13
  58. package/node_modules/pi-lens/dist/clients/lsp/client.js +169 -8
  59. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +29 -0
  60. package/node_modules/pi-lens/dist/clients/lsp/index.js +264 -66
  61. package/node_modules/pi-lens/dist/clients/lsp/server.js +247 -60
  62. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  63. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  64. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  65. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  66. package/node_modules/pi-lens/dist/clients/mcp/session.js +30 -17
  67. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  68. package/node_modules/pi-lens/dist/clients/pipeline.js +29 -2
  69. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  70. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  71. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  72. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  73. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +153 -7
  74. package/node_modules/pi-lens/dist/clients/runtime-context.js +104 -11
  75. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +170 -22
  76. package/node_modules/pi-lens/dist/clients/runtime-session.js +28 -5
  77. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +124 -10
  78. package/node_modules/pi-lens/dist/clients/runtime-turn.js +388 -35
  79. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  80. package/node_modules/pi-lens/dist/clients/security-scan-client.js +88 -5
  81. package/node_modules/pi-lens/dist/clients/sg-runner.js +141 -23
  82. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  83. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  84. package/node_modules/pi-lens/dist/clients/test-runner-client.js +431 -24
  85. package/node_modules/pi-lens/dist/clients/tool-policy.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  87. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  89. package/node_modules/pi-lens/dist/index.js +4827 -1633
  90. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  91. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  92. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  93. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  94. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +3 -2
  95. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +62 -7
  96. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  97. package/node_modules/pi-lens/docs/agent-guide.md +38 -13
  98. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +11 -3
  99. package/node_modules/pi-lens/docs/features.md +14 -1
  100. package/node_modules/pi-lens/docs/globalconfig.md +11 -0
  101. package/node_modules/pi-lens/docs/servercapabilities.md +1 -1
  102. package/node_modules/pi-lens/docs/settings.md +6 -0
  103. package/node_modules/pi-lens/docs/usage.md +23 -5
  104. package/node_modules/pi-lens/docs/word-index.md +35 -0
  105. package/node_modules/pi-lens/package.json +1 -1
  106. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  107. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  108. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  109. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  110. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  111. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  112. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  113. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  114. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  115. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  116. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  117. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  118. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  119. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  120. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  121. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  122. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +79 -0
  123. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  124. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  125. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  126. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  127. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  128. package/node_modules/pi-mcp-adapter/CHANGELOG.md +13 -0
  129. package/node_modules/pi-mcp-adapter/README.md +4 -1
  130. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  131. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  132. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  133. package/node_modules/pi-mcp-adapter/direct-tools.ts +18 -15
  134. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  135. package/node_modules/pi-mcp-adapter/metadata-cache.ts +18 -8
  136. package/node_modules/pi-mcp-adapter/package.json +2 -1
  137. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  138. package/node_modules/pi-mcp-adapter/server-manager.ts +5 -0
  139. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  140. package/node_modules/pi-mcp-adapter/types.ts +93 -10
  141. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  142. package/node_modules/pi-web-access/README.md +18 -12
  143. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  144. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  145. package/node_modules/pi-web-access/curator-page.ts +5 -3
  146. package/node_modules/pi-web-access/curator-server.ts +2 -1
  147. package/node_modules/pi-web-access/extract.ts +106 -34
  148. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  149. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  150. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  151. package/node_modules/pi-web-access/index.ts +120 -48
  152. package/node_modules/pi-web-access/package.json +2 -2
  153. package/node_modules/pi-web-access/summary-review.ts +11 -5
  154. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  155. package/package.json +9 -9
@@ -9,8 +9,10 @@ import { resolveRunnerPath, toRunnerDisplayPath, } from "./dispatch/runner-conte
9
9
  import { getKnipIgnorePatterns } from "./file-utils.js";
10
10
  import { isTestRoleCollateral } from "./collateral-test-role.js";
11
11
  import { dedupeSecretFindings, fromAstGrepWarnings, fromGitleaks, fromTrivySecrets, isSecretWarning, secretLocationKey, } from "./secret-findings.js";
12
- import { formatDeadCodeAdvisory } from "./dead-code-client.js";
12
+ import { deadCodeIssueKey, deadCodeIssues, formatDeadCodeDelta, } from "./dead-code-client.js";
13
+ import { logDeadCodeScan } from "./dead-code-logger.js";
13
14
  import { PROJECT_DIAGNOSTICS_CACHE_VERSION, writeProjectDiagnosticsDeltaReport, } from "./project-diagnostics/cache.js";
15
+ import { deadCodeIssueToProjectDiagnostic } from "./project-diagnostics/runner-adapters/dead-code.js";
14
16
  import { knipIssuesToProjectDiagnostics } from "./project-diagnostics/runner-adapters/knip.js";
15
17
  import { logLatency } from "./latency-logger.js";
16
18
  import { getLspBudgetIdleTimeoutMs, shouldShortenLspIdleTimeout, } from "./lsp-budget.js";
@@ -18,6 +20,19 @@ import { updateHeartbeat } from "./instance-registry.js";
18
20
  import { emitLensTurnFindings } from "./lens-events.js";
19
21
  import { RUNTIME_CONFIG } from "./runtime-config.js";
20
22
  import { isSubagentSession } from "./subagent-mode.js";
23
+ import { formatRunDurationMs } from "./run-duration.js";
24
+ import { MAX_ADVISORY_AFFECTED_FILES, dropFindingsForMissingPaths, snapshotAdvisoryProvenance, } from "./advisory-provenance.js";
25
+ /**
26
+ * Would writing `next` over `prev` throw away a good scan for a failed one?
27
+ *
28
+ * A failed run carries no findings. Writing it evicts the last good result, and
29
+ * every later reader then serves the failure as the answer — a 194-byte "not
30
+ * available" record replaced 149 KB of real findings in every dogfood project
31
+ * (#925, #1467). Callers keep the previous cache when this returns true.
32
+ */
33
+ function wouldPoisonCache(prev, next) {
34
+ return !next.success && prev?.data.success === true;
35
+ }
21
36
  // LSP idle reset scheduling — prevents thrashing by delaying shutdown
22
37
  let lspIdleResetTimeout = null;
23
38
  function emitIdleResetReporterWarning(reportErr) {
@@ -132,7 +147,15 @@ export async function handleTurnEnd(deps) {
132
147
  turnState = cacheManager.readTurnState(cwd);
133
148
  }
134
149
  const files = Object.keys(turnState.files);
135
- if (files.length === 0) {
150
+ // R1 (#1443 follow-up): a read-only turn (no files touched) must not take
151
+ // the fast idle-reset path while a carried cascade run — or one still
152
+ // settling — is waiting for its delivery opportunity. Falling through to
153
+ // the normal pipeline lets the settle/drain/merge logic below run exactly
154
+ // as it does for an edit turn, so a carried finding reaches the agent
155
+ // instead of dying unrendered. `hasCascadeRuns()` is a cheap peek (no
156
+ // pending work almost every turn), so the common read-only turn still
157
+ // takes the early return below.
158
+ if (files.length === 0 && !runtime.hasCascadeRuns()) {
136
159
  // A genuinely clean session must invalidate the persisted guard record.
137
160
  // Blocker records are retained only while the runtime still reports one.
138
161
  if (getFlag("lens-guard") && !runtime.gitGuardHasBlockers) {
@@ -209,10 +232,58 @@ export async function handleTurnEnd(deps) {
209
232
  const cascadeRuns = runtime.consumeCascadeRuns().filter((run) => {
210
233
  const originSeq = run.origin?.projectSeq;
211
234
  const originTurn = run.origin?.turnSeq;
212
- // A deferred result from before a later write/turn is not current state.
213
- // Old test fixtures without provenance remain accepted for compatibility.
214
- return ((originSeq === undefined || originSeq === runtime.projectSeq) &&
215
- (originTurn === undefined || originTurn === runtime.turnIndex));
235
+ // A deferred result from AFTER a later write is not current state. Old test
236
+ // fixtures without provenance remain accepted for compatibility.
237
+ //
238
+ // #1443: `turnSeq` alone is NOT a supersede signal, and it used to be an
239
+ // unconditional reject. Every LATE run — one whose compute missed the
240
+ // settle cap and was re-parked by `settleCascadeRuns`, and one the
241
+ // quiet-window reconcile appended after this turn's predecessor already
242
+ // consumed (carried across turn_start by `beginTurn`) — is BY DEFINITION
243
+ // from an earlier turn, so `originTurn === runtime.turnIndex` was always
244
+ // false for exactly the runs the carry-over was built to preserve. Both
245
+ // producers' contracts were dead code: the measured cases were the two
246
+ // highest-fan-out cascades of the day (38 and 40 neighbours).
247
+ //
248
+ // R2 (#1443 follow-up): `projectSeq` alone is NOT a per-file supersede
249
+ // signal — it is GLOBAL, advancing on every pi-observed write anywhere in
250
+ // the project. Rejecting on any mismatch meant an edit to an unrelated
251
+ // file superseded a run that had nothing to do with it, reintroducing the
252
+ // exact 38/40-neighbour loss #1443 was written to fix, one filter down.
253
+ // `getFilesChangedSince` (#451) is the honest per-file signal: a run is
254
+ // superseded only if its own primary file or one of its neighbours was
255
+ // actually rewritten since it launched. A late-but-not-superseded run is
256
+ // surfaced; a superseded one is dropped with a RECORD (never silently),
257
+ // so the loss stays countable.
258
+ if (originSeq !== undefined) {
259
+ const changedSince = runtime.getFilesChangedSince(originSeq);
260
+ if (changedSince.length > 0) {
261
+ const changedSet = new Set(changedSince);
262
+ const primaryKey = normalizeMapKey(path.resolve(run.filePath));
263
+ const neighborKeys = (run.result?.neighbors ?? []).map((n) => normalizeMapKey(path.resolve(n.filePath)));
264
+ const supersededByOwnFile = changedSet.has(primaryKey) ||
265
+ neighborKeys.some((k) => changedSet.has(k));
266
+ if (supersededByOwnFile) {
267
+ logCascade({
268
+ phase: "cascade_carry_over_drop",
269
+ filePath: run.filePath,
270
+ neighborCount: run.neighborCount,
271
+ diagnosticCount: run.diagnosticCount,
272
+ reason: "superseded_by_later_write",
273
+ metadata: {
274
+ originProjectSeq: originSeq,
275
+ projectSeq: runtime.projectSeq,
276
+ originTurnSeq: originTurn,
277
+ turnIndex: runtime.turnIndex,
278
+ carriedTurns: run.carriedTurns,
279
+ changedFiles: changedSince,
280
+ },
281
+ });
282
+ return false;
283
+ }
284
+ }
285
+ }
286
+ return true;
216
287
  });
217
288
  const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
218
289
  if (cascadeResults.length > 0) {
@@ -231,11 +302,25 @@ export async function handleTurnEnd(deps) {
231
302
  }
232
303
  }
233
304
  const parts = [];
305
+ // #1446 item 1: track what actually gets injected — a suppressed result
306
+ // (real formatted cascade text, but every one of its neighbors was claimed
307
+ // by a LATER result — see the reverse-iteration ownership pass above) was
308
+ // previously indistinguishable from "no output"; this counts it explicitly
309
+ // instead of letting it vanish.
310
+ let injectedNeighborCount = 0;
311
+ let injectedDiagnosticCount = 0;
312
+ let suppressedByOwnership = 0;
234
313
  for (const result of seen.values()) {
235
314
  const pk = normalizeMapKey(result.filePath);
236
315
  const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
237
- if (ownsAny && result.formatted)
316
+ if (ownsAny && result.formatted) {
238
317
  parts.push(result.formatted);
318
+ injectedNeighborCount += result.neighbors.length;
319
+ injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
320
+ }
321
+ else if (!ownsAny && result.formatted) {
322
+ suppressedByOwnership++;
323
+ }
239
324
  }
240
325
  // Suggest tests for cascade neighbors (files with diagnostics)
241
326
  const neighborFilesWithErrors = cascadeResults
@@ -243,9 +328,27 @@ export async function handleTurnEnd(deps) {
243
328
  .filter((n) => n.diagnostics.length > 0)
244
329
  .map((n) => n.filePath);
245
330
  const uniqueNeighborFiles = [...new Set(neighborFilesWithErrors)];
331
+ let testSuggestionCount = 0;
246
332
  if (uniqueNeighborFiles.length > 0 &&
247
333
  typeof testRunnerClient.suggestTestFiles === "function") {
248
334
  const testSuggestions = testRunnerClient.suggestTestFiles(uniqueNeighborFiles, cwd);
335
+ testSuggestionCount = testSuggestions.length;
336
+ // #1446 item 2: this path previously emitted nothing to any log — a
337
+ // zero-suggestion outcome (neighbors had errors but no test file
338
+ // resolved for any of them) is the more interesting case, so it is
339
+ // recorded on the same phase rather than only logging on a hit.
340
+ logCascade({
341
+ phase: "cascade_test_targets",
342
+ filePath: files[0] ?? cwd,
343
+ neighborCount: uniqueNeighborFiles.length,
344
+ metadata: {
345
+ neighborFiles: uniqueNeighborFiles.slice(0, 10),
346
+ suggestedTestFiles: testSuggestions.slice(0, 10).map((s) => s.testFile),
347
+ runner: testSuggestions[0]?.runner,
348
+ truncated: testSuggestions.length > 10,
349
+ zeroSuggestions: testSuggestions.length === 0,
350
+ },
351
+ });
249
352
  if (testSuggestions.length > 0) {
250
353
  const testLines = testSuggestions
251
354
  .slice(0, 5)
@@ -257,8 +360,30 @@ export async function handleTurnEnd(deps) {
257
360
  parts.push(testSection);
258
361
  }
259
362
  }
260
- if (parts.length > 0)
261
- blockerParts.push(parts.join("\n\n"));
363
+ if (parts.length > 0) {
364
+ const section = parts.join("\n\n");
365
+ blockerParts.push(section);
366
+ // #1446 item 1: proves the cascade section reached `blockerParts` —
367
+ // i.e. it was QUEUED for persistence into the turn-end advisory — not
368
+ // that it reached the agent. The counters alone (cascade_result,
369
+ // cascade_turn_end) never confirmed even that much, only computation.
370
+ // Actual delivery happens later, via consumeTurnEndFindings/
371
+ // peekTurnEndFindings, and can still be suppressed after this point
372
+ // (e.g. allFilesDeleted, cross-turn dedup, or the session ending
373
+ // before the next turn_end drains it) — this record does not prove
374
+ // the agent ever saw the text.
375
+ logCascade({
376
+ phase: "cascade_injected",
377
+ filePath: files[0] ?? cwd,
378
+ neighborCount: injectedNeighborCount,
379
+ diagnosticCount: injectedDiagnosticCount,
380
+ metadata: {
381
+ sectionChars: section.length,
382
+ testSuggestionCount,
383
+ suppressedByOwnership,
384
+ },
385
+ });
386
+ }
262
387
  logCascade({
263
388
  phase: "cascade_turn_end",
264
389
  filePath: files[0] ?? cwd,
@@ -313,7 +438,15 @@ export async function handleTurnEnd(deps) {
313
438
  const reasons = [...byDetail.keys()].join("; ");
314
439
  return `${frame.lead(fileCount, reasons)}\n${lines.join("\n")}`;
315
440
  };
316
- const graphRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason !== "lsp_binding_rejected");
441
+ // #1445: `excluded_by_role` (test files excluded from the graph BY DESIGN,
442
+ // #260) is never agent-facing — it is not a graph failure, and #1080
443
+ // already excludes test-role files from every neighbor surface, so "a
444
+ // clean result does not cover them" would itself be a false claim. It
445
+ // stays visible in the `cascade_indeterminate` log below (metadata-only,
446
+ // info-level) so the log can tell an intentional exclusion from a real
447
+ // graph gap, but it never reaches `buildAdvisory`/the agent.
448
+ const graphRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason !== "lsp_binding_rejected" &&
449
+ r.indeterminate?.reason !== "excluded_by_role");
317
450
  const bindingRuns = indeterminateRuns.filter((r) => r.indeterminate?.reason === "lsp_binding_rejected");
318
451
  // Factual/informational phrasing — the advisory tier wraps this with an
319
452
  // "ℹ️ Advisory — no action required this turn:" label, so an imperative
@@ -385,8 +518,12 @@ export async function handleTurnEnd(deps) {
385
518
  // run tool checks every turn. Also back off after a timeout/kill so every
386
519
  // agent turn does not spend 30s launching another heavyweight knip process.
387
520
  const prevKnip = cacheManager.readCache("knip", cwd);
521
+ // An availability failure is NOT a hard knip failure: knip never ran, so
522
+ // there is nothing to back off from, and backing off would make an
523
+ // expiring probe verdict permanent again (#1467).
388
524
  const previousFailedHard = prevKnip &&
389
525
  !prevKnip.data.success &&
526
+ !prevKnip.data.failureKind &&
390
527
  /(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prevKnip.data.summary);
391
528
  if (previousFailedHard) {
392
529
  dbg(`turn_end: skipping knip after recent failure: ${prevKnip.data.summary}`);
@@ -394,13 +531,23 @@ export async function handleTurnEnd(deps) {
394
531
  }
395
532
  else {
396
533
  const knipResult = await knipClient.analyze(cwd, getKnipIgnorePatterns());
397
- cacheManager.writeCache("knip", knipResult, cwd);
534
+ // Never overwrite a good scan with a failure (#925, #1467): the last
535
+ // good result stays until a new successful scan replaces it.
536
+ const knipWouldPoison = wouldPoisonCache(prevKnip, knipResult);
537
+ if (knipWouldPoison) {
538
+ dbg(`turn_end: keeping last good knip cache; this run failed: ${knipResult.summary}`);
539
+ }
540
+ else {
541
+ cacheManager.writeCache("knip", knipResult, cwd);
542
+ }
398
543
  knipMeta = {
399
544
  success: knipResult.success,
400
545
  totalIssues: knipResult.issues.length,
401
546
  newIssues: 0,
402
547
  blockerIssues: 0,
403
548
  ...(!knipResult.success && { reason: knipResult.summary }),
549
+ ...(knipResult.failureKind && { failureKind: knipResult.failureKind }),
550
+ ...(knipWouldPoison && { cacheKept: true }),
404
551
  };
405
552
  if (knipResult.success && knipResult.issues.length > 0) {
406
553
  const issueKey = (i) => `${i.type}:${i.file ?? ""}:${i.name}:${i.line ?? 0}:${i.package ?? ""}`;
@@ -467,6 +614,140 @@ export async function handleTurnEnd(deps) {
467
614
  durationMs: Date.now() - t2,
468
615
  metadata: knipMeta,
469
616
  });
617
+ // Cross-file dead-code (#127) for non-JS/TS languages, on knip's contract:
618
+ // re-scan only when this turn touched a file the client owns, then inject the
619
+ // ATTRIBUTABLE delta — symbols in those files that became unused because of
620
+ // the edit. The project-wide list is deliberately NOT injected per turn (the
621
+ // same reasoning as knip above) and stays available via lens_diagnostics.
622
+ // MUST run before the projectDiagnosticsDelta write below, or a dead-code-only
623
+ // turn would persist nothing.
624
+ const tDeadCode = Date.now();
625
+ const deadCodeMeta = {};
626
+ if (runtime.isStartupScanInFlight("dead-code")) {
627
+ dbg("turn_end: skipping dead-code (startup scan still in flight)");
628
+ deadCodeMeta.skipped = true;
629
+ deadCodeMeta.reason = "startup_scan_in_flight";
630
+ }
631
+ else if (deadCodeClients.length === 0) {
632
+ deadCodeMeta.reason = "no_clients";
633
+ }
634
+ else {
635
+ // The modified-file set costs a resolveRunnerPath per file, and that walks
636
+ // every ancestor to the filesystem root on a miss. Build it lazily, only
637
+ // once a client has actually claimed this project, so an all-JS repo with
638
+ // no dead-code client pays nothing per turn. Knip does the same.
639
+ let modifiedSet = null;
640
+ const modifiedFiles = () => (modifiedSet ??= new Set(files.map((f) => resolveRunnerPath(cwd, f))));
641
+ let newIssueTotal = 0;
642
+ const reasons = [];
643
+ // A malformed client or deps object must never abort turn_end. Before the
644
+ // per-turn delta this block only read a cache; now it iterates and awaits,
645
+ // so the whole thing needs the guard, not just `client.analyze`.
646
+ try {
647
+ for (const client of deadCodeClients) {
648
+ if (!client.detect(cwd)) {
649
+ reasons.push(`${client.id}:not_detected`);
650
+ continue;
651
+ }
652
+ if (![...modifiedFiles()].some((f) => client.owns(f))) {
653
+ reasons.push(`${client.id}:no_owned_files`);
654
+ continue;
655
+ }
656
+ const cacheKey = `dead-code-${client.id}`;
657
+ const prev = cacheManager.readCache(cacheKey, cwd);
658
+ // Back off after a timeout/kill so an unresponsive scanner cannot cost
659
+ // every later turn its full analysis budget (mirrors knip).
660
+ if (prev &&
661
+ !prev.data.success &&
662
+ /(timed out|killed|SIGTERM|SIGKILL|SIGABRT)/i.test(prev.data.summary)) {
663
+ dbg(`turn_end: skipping dead-code after failure: ${prev.data.summary}`);
664
+ deadCodeMeta.skipped = true;
665
+ reasons.push(`${client.id}:backoff:${prev.data.summary}`);
666
+ continue;
667
+ }
668
+ const startMs = Date.now();
669
+ try {
670
+ const result = await client.analyze(cwd);
671
+ const durationMs = Date.now() - startMs;
672
+ // Never overwrite a good scan with a failure (#925, #1467): a
673
+ // vulture timeout on one .py turn would otherwise evict the
674
+ // session_start scan, and the backoff above would then latch
675
+ // off the poisoned record.
676
+ if (wouldPoisonCache(prev, result)) {
677
+ dbg(`turn_end: keeping last good dead-code(${client.id}) cache; this run failed: ${result.summary}`);
678
+ deadCodeMeta.cacheKept = true;
679
+ }
680
+ else {
681
+ cacheManager.writeCache(cacheKey, result, cwd, {
682
+ scanDurationMs: durationMs,
683
+ });
684
+ }
685
+ // One event per cross-file scan (AGENTS.md) — the per-turn scan is
686
+ // now the primary path, so dead-code.log must see it too.
687
+ logDeadCodeScan({
688
+ language: client.language,
689
+ success: result.success,
690
+ cached: false,
691
+ unusedExports: result.unusedExports.length,
692
+ unusedFiles: result.unusedFiles.length,
693
+ unusedDeps: result.unusedDeps.length,
694
+ unlistedDeps: result.unlistedDeps.length,
695
+ durationMs: result.durationMs ?? durationMs,
696
+ ...(!result.success && { reason: result.summary }),
697
+ });
698
+ deadCodeMeta.success = result.success;
699
+ if (!result.success) {
700
+ reasons.push(`${client.id}:scan_failed:${result.summary}`);
701
+ continue;
702
+ }
703
+ deadCodeMeta.totalIssues =
704
+ (deadCodeMeta.totalIssues ?? 0) + deadCodeIssues(result).length;
705
+ // No baseline means every finding looks new. Report nothing rather
706
+ // than blame the edit for the whole project's pre-existing debt.
707
+ if (!prev?.data.success) {
708
+ reasons.push(`${client.id}:no_previous_scan`);
709
+ continue;
710
+ }
711
+ const prevKeys = new Set(deadCodeIssues(prev.data).map(deadCodeIssueKey));
712
+ const modified = modifiedFiles();
713
+ const newIssues = deadCodeIssues(result).filter((issue) => {
714
+ if (prevKeys.has(deadCodeIssueKey(issue)))
715
+ return false;
716
+ if (!issue.file)
717
+ return false;
718
+ return modified.has(resolveRunnerPath(cwd, issue.file));
719
+ });
720
+ if (newIssues.length === 0) {
721
+ reasons.push(`${client.id}:clean`);
722
+ continue;
723
+ }
724
+ newIssueTotal += newIssues.length;
725
+ projectDiagnosticsDelta.push(...newIssues.map((issue) => deadCodeIssueToProjectDiagnostic(cwd, issue, result.language)));
726
+ projectDiagnosticsSources.add("dead-code");
727
+ advisoryParts.push(formatDeadCodeDelta(newIssues, result.language));
728
+ }
729
+ catch (err) {
730
+ dbg(`turn_end: dead-code(${client.id}) failed: ${err}`);
731
+ reasons.push(`${client.id}:threw`);
732
+ }
733
+ }
734
+ }
735
+ catch (err) {
736
+ dbg(`turn_end: dead-code block failed: ${err}`);
737
+ reasons.push("block_threw");
738
+ }
739
+ deadCodeMeta.newIssues = newIssueTotal;
740
+ if (reasons.length > 0)
741
+ deadCodeMeta.reason = reasons.join(",");
742
+ }
743
+ logLatency({
744
+ type: "phase",
745
+ toolName: "turn_end",
746
+ filePath: cwd,
747
+ phase: "dead-code",
748
+ durationMs: Date.now() - tDeadCode,
749
+ metadata: deadCodeMeta,
750
+ });
470
751
  // govulncheck — surface session_start-cached Go CVE findings as advisory.
471
752
  // No per-turn re-run in this slice; the cache refreshes at next session_start.
472
753
  const govCacheEntry = cacheManager.readCache("govulncheck", cwd);
@@ -495,11 +776,25 @@ export async function handleTurnEnd(deps) {
495
776
  // location so a committed/hardcoded secret is reported ONCE (with combined
496
777
  // provenance) — a blocker, since credentials need rotation before merge.
497
778
  const gitleaksData = cacheManager.readCache("gitleaks", cwd)?.data;
779
+ // #1461 slice 1 (#1460): the gitleaks cache is TTL-only, so a finding for a
780
+ // file deleted after the scan is still served as a 🔴 blocker for the rest
781
+ // of the 30-minute window — the live case, and 119 of 126 findings in
782
+ // pi-lens's own cache. This read is the single agent-facing consumer of that
783
+ // store (session_start's read only decides whether to re-scan; the
784
+ // project-diagnostics path re-scans fresh and reconciles at load), so the
785
+ // drop belongs here, before the findings enter the shared secret pipeline.
786
+ // gitleaks only in this slice — trivy secrets are slice 1's sibling store.
787
+ const gitleaksFindings = dropFindingsForMissingPaths({
788
+ store: "gitleaks",
789
+ findings: gitleaksData?.findings ?? [],
790
+ cwd,
791
+ citedPath: (finding) => finding.file,
792
+ });
498
793
  const astSecretWarnings = runtime
499
794
  .peekActionableWarnings()
500
795
  .filter(isSecretWarning);
501
796
  const sessionSecrets = dedupeSecretFindings([
502
- ...fromGitleaks(gitleaksData?.findings ?? []),
797
+ ...fromGitleaks(gitleaksFindings),
503
798
  ...fromTrivySecrets(trivyCacheEntry?.data?.secrets ?? []),
504
799
  ]);
505
800
  // Locations already surfaced as session-scan secret blockers — used to enrich
@@ -668,8 +963,32 @@ export async function handleTurnEnd(deps) {
668
963
  dbg(`turn_end: firing ${targets.length} test target(s) async (non-blocking)`);
669
964
  const firedAtTurn = runtime.turnIndex;
670
965
  const firedSessionId = runtime.telemetrySessionId;
966
+ const priorTestCache = cacheManager.readCache("test-runner-findings", cwd)?.data;
967
+ const testRunGeneration = (priorTestCache?.testRunGeneration ?? 0) + 1;
968
+ const provenanceFiles = [
969
+ ...candidates.map((candidate) => ({ path: candidate.abs, role: "source" })),
970
+ ...targets.map((target) => ({ path: target.testFile, role: "test" })),
971
+ ];
972
+ const launchedFrom = snapshotAdvisoryProvenance({
973
+ cwd,
974
+ runtime,
975
+ generation: testRunGeneration,
976
+ files: provenanceFiles,
977
+ });
978
+ cacheManager.writeCache("test-runner-findings", { ...(priorTestCache ?? { content: "" }), testRunGeneration }, cwd);
671
979
  Promise.allSettled(targets.map((t) => testRunnerClient.runTestFileAsync(t.testFile, cwd, t.runner, t.config)))
672
980
  .then((results) => {
981
+ const publishedAgainst = snapshotAdvisoryProvenance({
982
+ cwd,
983
+ runtime,
984
+ generation: testRunGeneration,
985
+ files: provenanceFiles,
986
+ });
987
+ const superseded = launchedFrom.revision.sessionId !== publishedAgainst.revision.sessionId ||
988
+ launchedFrom.revision.projectSeq !== publishedAgainst.revision.projectSeq ||
989
+ launchedFrom.revision.turnIndex !== publishedAgainst.revision.turnIndex ||
990
+ launchedFrom.files.some((file, index) => publishedAgainst.files[index]?.sha256 !== file.sha256 ||
991
+ publishedAgainst.files[index]?.path !== file.path);
673
992
  // #628: the turn advancing while tests ran no longer means the
674
993
  // results are thrown away — a late result is still real
675
994
  // information about what's currently broken. It's tagged `stale`
@@ -686,9 +1005,29 @@ export async function handleTurnEnd(deps) {
686
1005
  resultValues.push(r.value);
687
1006
  const { file, runner, passed, failed, duration, error } = r.value;
688
1007
  const shortFile = path.basename(file);
1008
+ // #1479: `(0ms)` used to be printed for a run nobody
1009
+ // timed — a payload with no suite timestamps, an
1010
+ // unrecognised summary line, or an empty result — and
1011
+ // that is the same string a genuinely sub-millisecond
1012
+ // run produces. A reader could not tell "measured 0"
1013
+ // from "not measured", which is the confusion #1452 was
1014
+ // reported for. `duration` is now absent when it was
1015
+ // never measured, and this line says which one it has.
1016
+ //
1017
+ // #1480: the test is `formatRunDurationMs`, not an
1018
+ // inline comparison. The "absent = unmeasured" contract
1019
+ // was being re-derived at every site that read a
1020
+ // duration, and a site that gets it slightly wrong —
1021
+ // treating a measured `0` as absent — puts the bug back
1022
+ // without touching this comment.
1023
+ const elapsed = formatRunDurationMs(duration);
1024
+ // Lifted out of the template below for the same reason
1025
+ // `elapsed` is: the pair read as a nested ternary, which
1026
+ // this line only got flagged for because #1479 touched it.
1027
+ const verdict = failed > 0 ? "FAIL" : "PASS";
689
1028
  const summary = error && passed === 0 && failed === 0
690
1029
  ? `error: ${error}`
691
- : `${failed > 0 ? "FAIL" : "PASS"} ${passed}p/${failed}f (${duration}ms)`;
1030
+ : `${verdict} ${passed}p/${failed}f (${elapsed})`;
692
1031
  dbg(`turn_end: ${stale ? "[stale] " : ""}test ${runner} ${shortFile} → ${summary}`);
693
1032
  if (failed > 0) {
694
1033
  const formatted = testRunnerClient.formatResult(r.value);
@@ -697,10 +1036,24 @@ export async function handleTurnEnd(deps) {
697
1036
  }
698
1037
  }
699
1038
  if (failures.length > 0) {
1039
+ const currentGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
1040
+ if (currentGeneration !== undefined && currentGeneration > testRunGeneration) {
1041
+ dbg(`turn_end: test generation ${testRunGeneration} superseded by ${currentGeneration}`);
1042
+ return;
1043
+ }
700
1044
  const content = stale
701
1045
  ? `[from a prior turn — the edit that triggered this run had already been superseded by the time results came back]\n\n${failures.join("\n\n")}`
702
1046
  : failures.join("\n\n");
703
- cacheManager.writeCache("test-runner-findings", { content, stale, results: resultValues }, cwd);
1047
+ cacheManager.writeCache("test-runner-findings", {
1048
+ content,
1049
+ stale,
1050
+ results: resultValues,
1051
+ testRunGeneration,
1052
+ launchedFrom,
1053
+ publishedAgainst,
1054
+ provenance: publishedAgainst,
1055
+ superseded,
1056
+ }, cwd);
704
1057
  if (getFlag("lens-guard") && firedSessionId === runtime.telemetrySessionId) {
705
1058
  clearGitGuardTestFailure(cacheManager, cwd, runtime, resultValues
706
1059
  .filter((value) => value.failed === 0)
@@ -928,26 +1281,6 @@ export async function handleTurnEnd(deps) {
928
1281
  },
929
1282
  });
930
1283
  }
931
- // Cross-file dead-code (#127): surface the cached session_start scan as an
932
- // advisory (project-wide unused symbols are slow to compute, so we read the
933
- // cache rather than re-scanning every turn — the analogue of knip's cache
934
- // for non-JS/TS languages). Merged across languages for polyglot repos.
935
- try {
936
- const deadCodeResults = [];
937
- for (const client of deadCodeClients) {
938
- if (!client.detect(cwd))
939
- continue;
940
- const cached = cacheManager.readCache(`dead-code-${client.id}`, cwd);
941
- if (cached?.data)
942
- deadCodeResults.push(cached.data);
943
- }
944
- const advisory = formatDeadCodeAdvisory(deadCodeResults);
945
- if (advisory)
946
- advisoryParts.push(advisory);
947
- }
948
- catch (err) {
949
- dbg(`turn_end: dead-code advisory failed: ${err}`);
950
- }
951
1284
  cacheManager.incrementTurnCycle(cwd, currentOwner);
952
1285
  const labeledAdvisoryParts = advisoryParts.map((p) => `ℹ️ Advisory — no action required this turn:\n${p}`);
953
1286
  const findingParts = [...blockerParts, ...labeledAdvisoryParts];
@@ -1022,7 +1355,27 @@ export async function handleTurnEnd(deps) {
1022
1355
  });
1023
1356
  }
1024
1357
  else {
1025
- cacheManager.writeCache("turn-end-findings", { content }, cwd);
1358
+ const allAffectedFiles = [
1359
+ ...files.map((file) => resolveRunnerPath(cwd, file)),
1360
+ ...cascadeResults.flatMap((result) => result.neighbors
1361
+ .filter((neighbor) => neighbor.diagnostics.length > 0)
1362
+ .map((neighbor) => resolveRunnerPath(cwd, neighbor.filePath))),
1363
+ ];
1364
+ const affectedFiles = [...new Set(allAffectedFiles)]
1365
+ .slice(0, MAX_ADVISORY_AFFECTED_FILES);
1366
+ const affectedFilesTruncated = new Set(allAffectedFiles).size > affectedFiles.length;
1367
+ cacheManager.writeCache("turn-end-findings", {
1368
+ content,
1369
+ affectedFiles,
1370
+ affectedFilesTruncated,
1371
+ provenance: snapshotAdvisoryProvenance({
1372
+ cwd,
1373
+ runtime,
1374
+ generation: 0,
1375
+ files: affectedFiles.map((file) => ({ path: file, role: "affected" })),
1376
+ truncated: affectedFilesTruncated,
1377
+ }),
1378
+ }, cwd);
1026
1379
  }
1027
1380
  cacheManager.writeCache("turn-end-findings-last", {
1028
1381
  signature,
@@ -7,15 +7,16 @@
7
7
  * Docs: https://doc.rust-lang.org/cargo/
8
8
  */
9
9
  import { createSubsystemLogger } from "./extension-log.js";
10
- import * as fs from "node:fs";
11
10
  import * as path from "node:path";
12
- import { safeSpawnAsync } from "./safe-spawn.js";
11
+ import { createToolchainAvailability, } from "./dispatch/runners/utils/toolchain-availability.js";
13
12
  // --- Common install paths ---
14
13
  const CARGO_WINDOWS_PATHS = [
15
14
  path.join(process.env.USERPROFILE || "", ".cargo", "bin", "cargo.exe"),
16
15
  path.join(process.env.SYSTEMDRIVE || "C:", "\\cargo", "bin", "cargo.exe"),
17
16
  "cargo.exe", // PATH
18
17
  ];
18
+ /** Budget for the PATH candidate's `cargo --version` probe, ms. */
19
+ const PROBE_TIMEOUT_MS = 3_000;
19
20
  const CARGO_UNIX_PATHS = [
20
21
  path.join(process.env.HOME || "", ".cargo", "bin", "cargo"),
21
22
  "/usr/local/cargo/bin/cargo",
@@ -24,56 +25,39 @@ const CARGO_UNIX_PATHS = [
24
25
  ];
25
26
  // --- Client ---
26
27
  export class RustClient {
27
- cargoAvailable = null;
28
- cargoPath = null;
28
+ /**
29
+ * Availability lifecycle, behind the shared transient-aware latch (#1476).
30
+ * The PATH candidate is resolved by spawning `cargo --version` with a 3 s
31
+ * budget, so a host stall could latch "no cargo" for the session and silently
32
+ * disable every Rust diagnostic until restart.
33
+ */
34
+ availability;
29
35
  log;
30
36
  constructor(verbose = false) {
31
37
  this.log = verbose
32
38
  ? createSubsystemLogger("rust")
33
39
  : () => { };
40
+ this.availability = createToolchainAvailability({
41
+ tool: "cargo",
42
+ label: "Cargo",
43
+ windowsPaths: CARGO_WINDOWS_PATHS,
44
+ unixPaths: CARGO_UNIX_PATHS,
45
+ probeArgs: ["--version"],
46
+ budgetMs: PROBE_TIMEOUT_MS,
47
+ log: (msg) => this.log(msg),
48
+ });
34
49
  }
35
50
  /**
36
51
  * Find cargo executable path (async — probes PATH candidates off the event loop).
37
52
  */
38
53
  async findCargoPathAsync() {
39
- if (this.cargoPath)
40
- return this.cargoPath;
41
- const paths = process.platform === "win32" ? CARGO_WINDOWS_PATHS : CARGO_UNIX_PATHS;
42
- for (const p of paths) {
43
- try {
44
- if (p.includes("\\") || p.includes("/")) {
45
- if (fs.existsSync(p)) {
46
- this.cargoPath = p;
47
- return p;
48
- }
49
- }
50
- else {
51
- const result = await safeSpawnAsync(p, ["--version"], {
52
- timeout: 3000,
53
- });
54
- if (!result.error && result.status === 0) {
55
- this.cargoPath = p;
56
- return p;
57
- }
58
- }
59
- }
60
- catch (err) {
61
- void err;
62
- }
63
- }
64
- return null;
54
+ return this.availability.findPath();
65
55
  }
66
56
  /**
67
57
  * Check if cargo is installed (cached)
68
58
  */
69
59
  async isAvailableAsync() {
70
- if (this.cargoAvailable !== null)
71
- return this.cargoAvailable;
72
- this.cargoAvailable = (await this.findCargoPathAsync()) !== null;
73
- if (this.cargoAvailable) {
74
- this.log(`Cargo found: ${this.cargoPath}`);
75
- }
76
- return this.cargoAvailable;
60
+ return this.availability.isAvailable();
77
61
  }
78
62
  /**
79
63
  * Check if a file is a Rust file