pi-lens 4.0.0 → 4.0.1

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 (107) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/clients/advisory-provenance.js +314 -0
  3. package/dist/clients/agent-nudge.js +14 -7
  4. package/dist/clients/biome-client.js +121 -13
  5. package/dist/clients/bus-events-logger.js +62 -6
  6. package/dist/clients/bus-publish.js +11 -3
  7. package/dist/clients/cascade-format.js +57 -2
  8. package/dist/clients/console-guard-install.js +16 -4
  9. package/dist/clients/dead-code-client.js +135 -30
  10. package/dist/clients/dependency-checker.js +19 -7
  11. package/dist/clients/diagnostic-dispositions.js +6 -4
  12. package/dist/clients/diagnostics-publish.js +10 -3
  13. package/dist/clients/dispatch/dispatcher.js +114 -10
  14. package/dist/clients/dispatch/integration.js +101 -16
  15. package/dist/clients/dispatch/runners/lsp.js +37 -4
  16. package/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  17. package/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  18. package/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
  19. package/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  20. package/dist/clients/disposition-publish.js +10 -3
  21. package/dist/clients/eval-timestamp.js +17 -0
  22. package/dist/clients/extension-log.js +296 -3
  23. package/dist/clients/fix-worklog.js +5 -1
  24. package/dist/clients/format-events-publish.js +39 -8
  25. package/dist/clients/git-guard.js +18 -21
  26. package/dist/clients/go-client.js +21 -39
  27. package/dist/clients/govulncheck-client.js +116 -7
  28. package/dist/clients/host-ports.js +1 -1
  29. package/dist/clients/installer/index.js +5 -1
  30. package/dist/clients/jscpd-client.js +4 -9
  31. package/dist/clients/knip-client.js +51 -14
  32. package/dist/clients/latency-logger.js +50 -1
  33. package/dist/clients/lens-events.js +56 -25
  34. package/dist/clients/lens-flag-registry.js +8 -0
  35. package/dist/clients/live-bus-emitter.js +45 -2
  36. package/dist/clients/lsp/aggregation.js +30 -4
  37. package/dist/clients/lsp/cascade-tier.js +58 -13
  38. package/dist/clients/lsp/client.js +169 -8
  39. package/dist/clients/lsp/diagnostic-binding.js +29 -0
  40. package/dist/clients/lsp/index.js +264 -66
  41. package/dist/clients/lsp/server.js +247 -60
  42. package/dist/clients/lsp/tsserver-sync.js +96 -0
  43. package/dist/clients/lsp/wait-policy/classification.js +21 -5
  44. package/dist/clients/lsp/wait-policy/strategies.js +18 -3
  45. package/dist/clients/mcp/analyze.js +4 -0
  46. package/dist/clients/mcp/session.js +30 -17
  47. package/dist/clients/model-provider.js +53 -0
  48. package/dist/clients/pipeline.js +29 -2
  49. package/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  50. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  51. package/dist/clients/review-graph/query.js +24 -0
  52. package/dist/clients/run-duration.js +55 -0
  53. package/dist/clients/runtime-agent-end.js +153 -7
  54. package/dist/clients/runtime-context.js +104 -11
  55. package/dist/clients/runtime-coordinator.js +170 -22
  56. package/dist/clients/runtime-session.js +28 -5
  57. package/dist/clients/runtime-tool-result.js +124 -10
  58. package/dist/clients/runtime-turn.js +388 -35
  59. package/dist/clients/rust-client.js +21 -37
  60. package/dist/clients/security-scan-client.js +88 -5
  61. package/dist/clients/sg-runner.js +141 -23
  62. package/dist/clients/smells-rollup.js +18 -11
  63. package/dist/clients/startup-timing.js +7 -1
  64. package/dist/clients/test-runner-client.js +431 -24
  65. package/dist/clients/tool-policy.js +2 -0
  66. package/dist/clients/tool-set-policy.js +76 -0
  67. package/dist/clients/warm-attach.js +17 -0
  68. package/dist/clients/word-index.js +305 -33
  69. package/dist/index.js +4827 -1633
  70. package/dist/mcp/server.js +8 -2
  71. package/dist/tools/activate-tools.js +17 -5
  72. package/dist/tools/ast-grep-replace.js +9 -4
  73. package/dist/tools/lens-diagnostic-mark.js +5 -2
  74. package/dist/tools/lens-diagnostics.js +3 -2
  75. package/dist/tools/lsp-diagnostics.js +62 -7
  76. package/dist/tools/symbol-search.js +1 -1
  77. package/docs/agent-guide.md +38 -13
  78. package/docs/ast-grep_rules_catalog.md +11 -3
  79. package/docs/features.md +14 -1
  80. package/docs/globalconfig.md +11 -0
  81. package/docs/servercapabilities.md +1 -1
  82. package/docs/settings.md +6 -0
  83. package/docs/usage.md +23 -5
  84. package/docs/word-index.md +35 -0
  85. package/package.json +1 -1
  86. package/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  87. package/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  88. package/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  89. package/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  90. package/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  91. package/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  92. package/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  93. package/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  94. package/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  95. package/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  96. package/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  97. package/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  98. package/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  99. package/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  100. package/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  101. package/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  102. package/scripts/analyze-pi-lens-logs.mjs +79 -0
  103. package/skills/pi-lens-ast-grep/SKILL.md +10 -11
  104. package/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  105. package/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  106. package/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  107. package/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Persistent NDJSON trace of `pi.events` bus publish attempts
3
3
  * (`pilens:files:touched` #482 / `pilens:diagnostics` #502 /
4
- * `pilens:format:queued` + `pilens:format:start` #673).
4
+ * `pilens:diagnostic:disposition` / `pilens:format:queued` +
5
+ * `pilens:format:start` + `pilens:autofix:start` #673/#684 /
6
+ * `pi-lens/analysis-complete` + `pi-lens/findings` + `pi-lens/turn-findings`
7
+ * #1415) — nine event names across five producers.
5
8
  *
6
- * All four producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
7
- * clients/format-events-publish.ts) are fire-and-forget: on failure or on a
8
- * structural no-op (never wired, kill switch off) they only invoke an
9
- * optional `dbg` callback, which varies by host and is a documented no-op in
10
- * the MCP host (clients/mcp/session.ts's `dbg: noop`). That leaves
9
+ * All five producers (clients/bus-publish.ts, clients/diagnostics-publish.ts,
10
+ * clients/disposition-publish.ts, clients/format-events-publish.ts,
11
+ * clients/lens-events.ts) are fire-and-forget: on failure or on a structural
12
+ * no-op (never wired, kill switch off) the four #482/#502/#673/#684
13
+ * producers only invoke an optional `dbg` callback, which varies by host and
14
+ * is a documented no-op in the MCP host (clients/mcp/session.ts's
15
+ * `dbg: noop`); `lens-events.ts` (#1415) has no `dbg` param at all — its
16
+ * events are purely observational inter-extension telemetry, so this NDJSON
17
+ * trace is its ONLY failure-visible surface. Either way, that leaves
11
18
  * bus-publish outcomes invisible in exactly the context where they matter
12
19
  * most — same failure shape as the #544 MCP session_start incident this repo
13
20
  * already fixed once.
@@ -26,6 +33,9 @@
26
33
  * spam one identical line per write batch for an entire session with zero
27
34
  * new information after the first. Both are gated log-once-per-process, the
28
35
  * same `hasLoggedFailure` shape the producers already use for emit_failed.
36
+ * `skipped_stale_session` is an info-level per-occurrence outcome: the emit
37
+ * seam intentionally declined a target whose associated ctx was confirmed
38
+ * stale, so it is neither a bus failure nor input for the smells rollup.
29
39
  * The empty-batch branch (`paths.length === 0` / `files.length === 0`) is
30
40
  * NOT logged at all: every call site already guards against invoking these
31
41
  * functions with nothing to report (see clients/pipeline.ts,
@@ -37,17 +47,63 @@ import { isTestMode } from "./env-utils.js";
37
47
  import { getGlobalPiLensDir } from "./file-utils.js";
38
48
  import { createNdjsonLogger } from "./ndjson-logger.js";
39
49
  import { getMaxLogSizeMB } from "./log-cleanup.js";
50
+ import { logLatency } from "./latency-logger.js";
40
51
  const BUS_EVENTS_LOG_FILE = path.join(getGlobalPiLensDir(), "bus-events.log");
41
52
  const writer = createNdjsonLogger({
42
53
  filePath: BUS_EVENTS_LOG_FILE,
43
54
  maxBytes: getMaxLogSizeMB() * 1024 * 1024,
44
55
  });
56
+ const eventRollupCounts = new Map();
57
+ function bumpRollupCount(event, outcome) {
58
+ const existing = eventRollupCounts.get(event) ?? {
59
+ emitted: 0,
60
+ skipped_stale_session: 0,
61
+ emit_failed: 0,
62
+ };
63
+ existing[outcome] += 1;
64
+ eventRollupCounts.set(event, existing);
65
+ }
45
66
  export function logBusEvent(entry) {
67
+ if (entry.outcome === "emitted" ||
68
+ entry.outcome === "skipped_stale_session" ||
69
+ entry.outcome === "emit_failed") {
70
+ bumpRollupCount(entry.event, entry.outcome);
71
+ }
46
72
  if (isTestMode()) {
47
73
  return;
48
74
  }
49
75
  writer.log({ ts: new Date().toISOString(), ...entry });
50
76
  }
77
+ /** Snapshot the current session's rollup, keyed by event name. Non-mutating —
78
+ * pair with {@link resetBusEventRollupCounts} at session end. */
79
+ export function getBusEventRollupCounts() {
80
+ return Object.fromEntries(eventRollupCounts);
81
+ }
82
+ /** Clear the rollup — call once the session-end snapshot has been logged
83
+ * (or from tests) so a new session starts from zero. */
84
+ export function resetBusEventRollupCounts() {
85
+ eventRollupCounts.clear();
86
+ }
87
+ /**
88
+ * Log one `session_end_bus_rollup` latency row per event NAME that had any
89
+ * activity this session, then clear the rollup. Called from index.ts's
90
+ * `session_shutdown` handler (the session-end hook this repo's other
91
+ * teardown work — LSP fleet shutdown, cache-prefix eviction — already runs
92
+ * from). A no-op (logs nothing) when nothing was ever published, matching
93
+ * `formatSmellsSessionStartLine`'s "no noise on an ordinary session" shape.
94
+ */
95
+ export function emitBusEventRollupAtSessionEnd(cwd) {
96
+ for (const [event, counts] of eventRollupCounts) {
97
+ logLatency({
98
+ type: "phase",
99
+ phase: "session_end_bus_rollup",
100
+ filePath: cwd,
101
+ durationMs: 0,
102
+ metadata: { event, ...counts },
103
+ });
104
+ }
105
+ resetBusEventRollupCounts();
106
+ }
51
107
  export function getBusEventsLogPath() {
52
108
  return BUS_EVENTS_LOG_FILE;
53
109
  }
@@ -20,7 +20,7 @@
20
20
  import { logBusEvent } from "./bus-events-logger.js";
21
21
  import { normalizeFilePath } from "./path-utils.js";
22
22
  import { appendRecentTouches } from "./recent-touches.js";
23
- import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
23
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
24
24
  export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
25
25
  export const BUS_FILES_TOUCHED_VERSION = 1;
26
26
  const liveEmitter = createLiveBusEmitter();
@@ -106,8 +106,14 @@ export function publishFilesTouched(args) {
106
106
  }).catch((err) => {
107
107
  args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
108
108
  });
109
- const busEmit = liveEmitter.get();
110
- if (!busEmit) {
109
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
110
+ event: BUS_FILES_TOUCHED_EVENT,
111
+ cwd: normalizeFilePath(args.cwd),
112
+ reason: args.reason,
113
+ }));
114
+ if (resolution.outcome === "stale-session")
115
+ return;
116
+ if (resolution.outcome === "unwired") {
111
117
  if (!hasLoggedUnwired) {
112
118
  hasLoggedUnwired = true;
113
119
  logBusEvent({
@@ -119,6 +125,7 @@ export function publishFilesTouched(args) {
119
125
  }
120
126
  return;
121
127
  }
128
+ const busEmit = resolution.emit;
122
129
  try {
123
130
  const payload = {
124
131
  v: BUS_FILES_TOUCHED_VERSION,
@@ -150,6 +157,7 @@ export function publishFilesTouched(args) {
150
157
  cwd: normalizeFilePath(args.cwd),
151
158
  reason: args.reason,
152
159
  error: String(err),
160
+ ctxSource: resolution.ctxSource,
153
161
  });
154
162
  if (!hasLoggedFailure) {
155
163
  hasLoggedFailure = true;
@@ -1,10 +1,14 @@
1
+ import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
1
2
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
2
3
  export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
3
4
  const withErrors = neighbors.filter((n) => n.diagnostics.length > 0);
4
- if (withErrors.length === 0)
5
+ const inconclusive = neighbors.filter((n) => n.inconclusive === true && n.diagnostics.length === 0);
6
+ if (withErrors.length === 0 && inconclusive.length === 0)
5
7
  return "";
6
8
  const noun = options.noun ?? "neighbor";
7
- let out = `📐 Cascade errors in ${withErrors.length} ${noun} file(s) — fix before finishing turn:`;
9
+ let out = withErrors.length > 0
10
+ ? `📐 Cascade errors in ${withErrors.length} ${noun} file(s) — fix before finishing turn:`
11
+ : "";
8
12
  for (const neighbor of withErrors) {
9
13
  const display = toRunnerDisplayPath(cwd, neighbor.filePath);
10
14
  const reason = options.includeReason ? ` reason="${neighbor.reason}"` : "";
@@ -17,5 +21,56 @@ export function formatCascadeNeighborDiagnostics(cwd, neighbors, options = {}) {
17
21
  }
18
22
  out += "\n</diagnostics>";
19
23
  }
24
+ if (inconclusive.length > 0) {
25
+ if (out)
26
+ out += "\n";
27
+ out += `⚠️ Cascade diagnostics inconclusive for ${inconclusive.length} ${noun} file(s) — no clean result was confirmed:`;
28
+ for (const neighbor of inconclusive) {
29
+ out += `\n ${toRunnerDisplayPath(cwd, neighbor.filePath)}`;
30
+ }
31
+ }
20
32
  return out;
21
33
  }
34
+ /**
35
+ * Build the turn-end `CascadeRun` for a neighbour whose diagnostics landed only
36
+ * AFTER its cascade touch skipped the in-lane wait (#1023's `resolved-found`
37
+ * quiet-window outcome; #1444 made native TS7 take that same path). Returns
38
+ * `undefined` when there is nothing agent-facing to say — no ERROR-severity
39
+ * diagnostics, or nothing the formatter renders.
40
+ *
41
+ * Lives here rather than inline in the quiet-window callback so the delivery
42
+ * path (reconcile → run → turn_end) is testable end to end; index.ts only wires
43
+ * it to `runtime.appendCascadeRun`.
44
+ */
45
+ export function buildResolvedFoundCascadeRun(cwd, neighbor) {
46
+ const { filePath } = neighbor;
47
+ const diagnostics = convertLspDiagnostics(neighbor.diagnostics.filter((d) => d.severity === 1), filePath, { tool: "lsp" });
48
+ if (diagnostics.length === 0)
49
+ return undefined;
50
+ const neighbors = [
51
+ { filePath, reason: "references", diagnostics, lspTouched: true },
52
+ ];
53
+ const formatted = formatCascadeNeighborDiagnostics(cwd, neighbors, {
54
+ noun: "cold neighbor",
55
+ });
56
+ if (!formatted)
57
+ return undefined;
58
+ return {
59
+ filePath,
60
+ result: {
61
+ filePath,
62
+ impact: {
63
+ filePath,
64
+ changedSymbols: [],
65
+ directImporters: [],
66
+ directCallers: [],
67
+ neighborFiles: [filePath],
68
+ riskFlags: [],
69
+ },
70
+ neighbors,
71
+ formatted,
72
+ },
73
+ neighborCount: 1,
74
+ diagnosticCount: diagnostics.length,
75
+ };
76
+ }
@@ -1,7 +1,9 @@
1
- import { performance } from "node:perf_hooks";
2
1
  // Capture this before the guard's other module initialization so host boot is
3
- // not charged to extension evaluation (#1374).
4
- export const PI_LENS_EVAL_STARTED_MS = performance.now();
2
+ // not charged to extension evaluation (#1374). Lives in its own
3
+ // side-effect-free module (#1434 S3d) — see eval-timestamp.ts's docstring for
4
+ // why: `startup-timing.ts` needs this constant but must never trigger a
5
+ // console-guard install merely by importing it.
6
+ import { PI_LENS_EVAL_STARTED_MS } from "./eval-timestamp.js";
5
7
  // #1333: install the console guard as an import side-effect so it runs before
6
8
  // any other module's initialization code. Installing inside the extension
7
9
  // factory is too late — by the time the factory runs, every static/transitive
@@ -10,5 +12,15 @@ export const PI_LENS_EVAL_STARTED_MS = performance.now();
10
12
  // MUST remain the first import of index.ts; the enforcement test pins that.
11
13
  // installConsoleGuard() itself is idempotent and no-ops under test mode and
12
14
  // PI_LENS_CONSOLE_GUARD=0.
13
- import { installConsoleGuard } from "./extension-log.js";
15
+ // #1434: the guard captures only while pi-lens owns execution, so the module
16
+ // window must be open for the rest of the import graph. index.ts closes it on
17
+ // its last line; an unref'd backstop closes it if that never runs.
18
+ import { installConsoleGuard, openModuleLoadConsoleWindow, } from "./extension-log.js";
14
19
  installConsoleGuard();
20
+ openModuleLoadConsoleWindow();
21
+ // Not re-exported: nothing should import the timestamp from this module.
22
+ // `clients/startup-timing.ts` imports it straight from `eval-timestamp.ts`,
23
+ // which is what keeps that import side-effect-free (S3d). The `void` keeps
24
+ // the import from looking unused to lint while documenting that this module
25
+ // only needs the ordering (evaluate eval-timestamp.ts first), not the value.
26
+ void PI_LENS_EVAL_STARTED_MS;
@@ -15,6 +15,7 @@ import { createSubsystemLogger } from "./extension-log.js";
15
15
  import * as path from "node:path";
16
16
  import { findNearestMarkerRoot } from "./path-utils.js";
17
17
  import { safeSpawnAsync } from "./safe-spawn.js";
18
+ import { classifyProbeFailure, createAvailabilityLatch, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
18
19
  function emptyResult(language) {
19
20
  return {
20
21
  success: false,
@@ -39,6 +40,21 @@ const VULTURE_EXCLUDES = [
39
40
  "*/__pycache__/*",
40
41
  "*/.eggs/*",
41
42
  ];
43
+ // Decorators whose target is called by a framework, never by name in the tree.
44
+ // vulture cannot see those call sites, so every such symbol is a permanent
45
+ // false positive that reappears in every scan — the fastest way to teach a
46
+ // reader to stop reading the advisory. Glob-matched by vulture itself.
47
+ const VULTURE_IGNORE_DECORATORS = [
48
+ "@pytest.fixture",
49
+ "@pytest.fixture(*",
50
+ "@fixture",
51
+ "@fixture(*",
52
+ "@app.*",
53
+ "@router.*",
54
+ "@celery.task*",
55
+ "@task",
56
+ "@shared_task*",
57
+ ];
42
58
  // vulture line: `path/to/file.py:12: unused function 'foo' (60% confidence)`
43
59
  const VULTURE_LINE = /^(.*?):(\d+): unused (\w[\w ]*?) '([^']+)' \((\d+)% confidence\)\s*$/;
44
60
  /**
@@ -86,7 +102,12 @@ export function parseVultureOutput(output, root) {
86
102
  export class PythonDeadCodeClient {
87
103
  id = "python";
88
104
  language = "Python";
89
- available = null;
105
+ /**
106
+ * Transient-aware memo (#1467): only a durable "vulture is not installed"
107
+ * verdict is remembered for the session. A probe that timed out expires and
108
+ * is re-probed, so vulture cannot be disabled for the process by one stall.
109
+ */
110
+ availabilityLatch = createAvailabilityLatch();
90
111
  resolved = null;
91
112
  ensureInFlight = null;
92
113
  inFlight = new Map();
@@ -103,6 +124,10 @@ export class PythonDeadCodeClient {
103
124
  detect(cwd) {
104
125
  return this.resolveProjectRoot(cwd) !== null;
105
126
  }
127
+ owns(filePath) {
128
+ const ext = path.extname(filePath).toLowerCase();
129
+ return ext === ".py" || ext === ".pyi";
130
+ }
106
131
  /**
107
132
  * Nearest dir with a Python project marker, never at/above $HOME and never
108
133
  * escaping a VCS boundary — same containment rules as KnipClient so a scan
@@ -121,8 +146,9 @@ export class PythonDeadCodeClient {
121
146
  ], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
122
147
  }
123
148
  async ensureAvailable() {
124
- if (this.available !== null)
125
- return this.available;
149
+ const memo = this.availabilityLatch.read();
150
+ if (memo !== null)
151
+ return memo;
126
152
  if (this.ensureInFlight)
127
153
  return this.ensureInFlight;
128
154
  this.ensureInFlight = this.doEnsureAvailable();
@@ -146,19 +172,81 @@ export class PythonDeadCodeClient {
146
172
  { cmd: "python", prefix: ["-m", "vulture"] },
147
173
  { cmd: "python3", prefix: ["-m", "vulture"] },
148
174
  ];
175
+ // A timeout on ANY candidate means the machine, not the tool, answered —
176
+ // the run gets a bounded retry instead of a permanent skip (#1467).
177
+ let sawTransient = false;
178
+ let transientCause = "probe-timeout";
179
+ // Accumulated across ALL candidates, because the failure verdicts below
180
+ // are about the whole sweep rather than any one probe. Reporting zero
181
+ // here would erase the evidence that cracked #1467: four 5s probes and
182
+ // a stalled host look identical to an absent tool without these two
183
+ // numbers.
184
+ const sweepStartedAt = Date.now();
185
+ let sweepHostStallMs = 0;
149
186
  for (const c of candidates) {
150
- const probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
151
- timeout: 5000,
152
- });
187
+ const sampler = startHostStallSampler();
188
+ const startedAt = Date.now();
189
+ let probe;
190
+ let hostStallMs;
191
+ try {
192
+ probe = await safeSpawnAsync(c.cmd, [...c.prefix, "--version"], {
193
+ timeout: 5000,
194
+ });
195
+ }
196
+ finally {
197
+ hostStallMs = sampler.stop();
198
+ sweepHostStallMs += hostStallMs;
199
+ }
153
200
  if (!probe.error && probe.status === 0) {
154
201
  this.resolved = c;
155
- this.available = true;
202
+ this.availabilityLatch.noteAvailable();
156
203
  this.log(`vulture found: ${[c.cmd, ...c.prefix].join(" ")}`);
204
+ logAvailabilityDecision({
205
+ tool: "vulture",
206
+ verdict: "available",
207
+ outcome: "success",
208
+ cause: "ok",
209
+ elapsedMs: Date.now() - startedAt,
210
+ latched: true,
211
+ hostStallMs,
212
+ budgetMs: 5000,
213
+ });
157
214
  return true;
158
215
  }
216
+ const classified = classifyProbeFailure(probe, { hostStallMs });
217
+ if (classified.outcome === "transient") {
218
+ sawTransient = true;
219
+ transientCause = classified.cause;
220
+ }
221
+ }
222
+ if (sawTransient) {
223
+ const retryAfterMs = this.availabilityLatch.noteUnavailable("transient", transientCause);
224
+ this.log("vulture probe timed out; will retry (not treated as missing)");
225
+ logAvailabilityDecision({
226
+ tool: "vulture",
227
+ verdict: "unavailable",
228
+ outcome: "transient",
229
+ cause: transientCause,
230
+ elapsedMs: Date.now() - sweepStartedAt,
231
+ hostStallMs: sweepHostStallMs,
232
+ latched: false,
233
+ retryAfterMs,
234
+ budgetMs: 5000,
235
+ });
236
+ return false;
159
237
  }
160
- this.available = false;
238
+ this.availabilityLatch.noteUnavailable("missing", "not-found");
161
239
  this.log("vulture not installed; skipping (no auto-install)");
240
+ logAvailabilityDecision({
241
+ tool: "vulture",
242
+ verdict: "unavailable",
243
+ outcome: "missing",
244
+ cause: "not-found",
245
+ elapsedMs: Date.now() - sweepStartedAt,
246
+ hostStallMs: sweepHostStallMs,
247
+ latched: true,
248
+ budgetMs: 5000,
249
+ });
162
250
  return false;
163
251
  }
164
252
  async analyze(cwd) {
@@ -193,6 +281,7 @@ export class PythonDeadCodeClient {
193
281
  ".",
194
282
  `--min-confidence=${this.minConfidence}`,
195
283
  `--exclude=${VULTURE_EXCLUDES.join(",")}`,
284
+ `--ignore-decorators=${VULTURE_IGNORE_DECORATORS.join(",")}`,
196
285
  ];
197
286
  const result = await safeSpawnAsync(invocation.cmd, args, {
198
287
  timeout: ANALYSIS_TIMEOUT_MS,
@@ -258,30 +347,46 @@ export function deadCodeIssueCount(result) {
258
347
  result.unusedDeps.length +
259
348
  result.unlistedDeps.length);
260
349
  }
350
+ /** Every bucket flattened — delta diffing and diagnostic mapping want one list. */
351
+ export function deadCodeIssues(result) {
352
+ return [
353
+ ...result.unusedExports,
354
+ ...result.unusedFiles,
355
+ ...result.unusedDeps,
356
+ ...result.unlistedDeps,
357
+ ];
358
+ }
261
359
  /**
262
- * Format cached dead-code results into one turn_end advisory, or "" when there
263
- * is nothing to report. Merges multiple languages (polyglot repos) under one
264
- * `[Dead code]` heading so it reads consistently next to the Knip advisory.
265
- * Advisory-only: these are project-wide signals, never blockers.
360
+ * Stable identity for diffing one scan against the previous one.
361
+ *
362
+ * Deliberately excludes the line number. An edit shifts every line below it,
363
+ * and the delta is then filtered to exactly the files the edit touched — so a
364
+ * line in the key turns each shifted pre-existing finding into a "newly unused"
365
+ * report under a heading that blames the agent's own edit for orphaning it.
366
+ * Inserting four lines above one real finding produced four false ones.
266
367
  */
267
- export function formatDeadCodeAdvisory(results, maxPerLang = 10) {
268
- const withFindings = results.filter((r) => r.success && deadCodeIssueCount(r) > 0);
269
- if (withFindings.length === 0)
368
+ export function deadCodeIssueKey(issue) {
369
+ return `${issue.category}:${issue.file ?? ""}:${issue.name}`;
370
+ }
371
+ /**
372
+ * Format this turn's ATTRIBUTABLE delta — symbols that became unused in files
373
+ * the agent just edited — or "" when there is nothing to report. Mirrors the
374
+ * Knip advisory's contract deliberately: the project-wide list is not injected
375
+ * per turn (hundreds of pre-existing findings would drown the blockers and burn
376
+ * context every turn), it stays available on demand via lens_diagnostics.
377
+ */
378
+ export function formatDeadCodeDelta(issues, language, max = 5) {
379
+ if (issues.length === 0)
270
380
  return "";
271
- const lines = [];
272
- for (const r of withFindings) {
273
- const count = deadCodeIssueCount(r);
274
- lines.push(`${r.language}: ${count} unused symbol(s)`);
275
- for (const issue of r.unusedExports.slice(0, maxPerLang)) {
276
- const loc = issue.file
277
- ? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})`
278
- : "";
279
- lines.push(` - unused ${issue.kind} '${issue.name}'${loc}`);
280
- }
281
- if (r.unusedExports.length > maxPerLang) {
282
- lines.push(` … and ${r.unusedExports.length - maxPerLang} more`);
283
- }
381
+ let report = `💀 Newly unused ${language} symbols in files you edited — check if callers need updating (dead-code):\n`;
382
+ for (const issue of issues.slice(0, max)) {
383
+ const loc = issue.file
384
+ ? `${issue.file}${issue.line ? `:${issue.line}` : ""}`
385
+ : "(unknown)";
386
+ report += ` ${loc} — unused ${issue.kind} ${issue.name}\n`;
387
+ }
388
+ if (issues.length > max) {
389
+ report += ` … and ${issues.length - max} more\n`;
284
390
  }
285
- return ("💀 [Dead code] project-wide unused symbols — verify before removing:\n " +
286
- lines.join("\n "));
391
+ return report;
287
392
  }
@@ -15,6 +15,7 @@ import { findNodeToolBinary } from "./package-manager.js";
15
15
  import { isFullyQualified } from "./path-utils.js";
16
16
  import { safeSpawnAsync } from "./safe-spawn.js";
17
17
  import { createAvailabilityChecker, discoverManagedTool, getManagedToolEnvironment, resolveAvailableOrInstall, } from "./dispatch/runners/utils/runner-helpers.js";
18
+ import { createAvailabilityLatch } from "./dispatch/runners/utils/availability-policy.js";
18
19
  /**
19
20
  * Build madge's argv for a circular-dependency scan.
20
21
  *
@@ -158,7 +159,8 @@ export class DependencyChecker {
158
159
  environment: (cwd) => getManagedToolEnvironment("madge", cwd),
159
160
  unclassifiedFailureOutcome: "missing",
160
161
  });
161
- available = null;
162
+ /** Transient-aware availability memo — see `ensureAvailable` (#1467). */
163
+ availabilityLatch = createAvailabilityLatch();
162
164
  ensureInFlight = null;
163
165
  checkInFlight = new Map();
164
166
  scanInFlight = new Map();
@@ -320,12 +322,17 @@ export class DependencyChecker {
320
322
  this.circularFiles = circularFiles;
321
323
  }
322
324
  /**
323
- * Check if madge is available, auto-install if not
325
+ * Check if madge is available, auto-install if not.
326
+ *
327
+ * Shares knip's latch policy (#1467): only a durable verdict is memoized, so
328
+ * a probe that timed out — madge's `--version` is ~0.7 s today, but a host
329
+ * event-loop stall can expire any budget — does not disable madge for the
330
+ * life of the process.
324
331
  */
325
332
  async ensureAvailable(cwd = process.cwd()) {
326
- // Fast path: already checked
327
- if (this.available !== null)
328
- return this.available;
333
+ const memo = this.availabilityLatch.read();
334
+ if (memo !== null)
335
+ return memo;
329
336
  if (this.ensureInFlight)
330
337
  return this.ensureInFlight;
331
338
  this.ensureInFlight = this.doEnsureAvailable(cwd);
@@ -338,8 +345,13 @@ export class DependencyChecker {
338
345
  }
339
346
  async doEnsureAvailable(cwd) {
340
347
  const resolved = await resolveAvailableOrInstall(this.madgeAvailability, "madge", cwd);
341
- this.available = resolved !== null;
342
- return this.available;
348
+ if (resolved !== null) {
349
+ this.availabilityLatch.noteAvailable();
350
+ return true;
351
+ }
352
+ const verdict = this.madgeAvailability.getVerdict(cwd);
353
+ this.availabilityLatch.noteUnavailable(verdict.outcome ?? "missing", verdict.cause ?? "not-found");
354
+ return false;
343
355
  }
344
356
  /**
345
357
  * Check if a file is part of a circular dependency (from cache)
@@ -278,7 +278,7 @@ export function _resetStateCacheForTests() {
278
278
  * Neither can throw into the mark path; both are already internally
279
279
  * fail-safe, but the try/catch keeps a future regression in either from
280
280
  * breaking a mark. */
281
- function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
281
+ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity) {
282
282
  try {
283
283
  logDispositionEvent({
284
284
  event: "mark",
@@ -290,6 +290,8 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
290
290
  reason,
291
291
  anchor,
292
292
  previousDisposition: existing?.disposition,
293
+ model: identity?.model || undefined,
294
+ provider: identity?.provider || undefined,
293
295
  });
294
296
  publishDisposition({
295
297
  cwd,
@@ -317,7 +319,7 @@ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
317
319
  * hang off it, so the agent tool and any future UI caller are covered without
318
320
  * per-caller wiring.
319
321
  */
320
- export function markDisposition(cwd, target, disposition, reason) {
322
+ export function markDisposition(cwd, target, disposition, reason, identity) {
321
323
  const anchor = disposition === "false-positive"
322
324
  ? computeStrictAnchor(target)
323
325
  : computeWeakAnchor(target);
@@ -327,7 +329,7 @@ export function markDisposition(cwd, target, disposition, reason) {
327
329
  const existing = readState(cwd).dispositions?.[anchor];
328
330
  if (disposition === "defer") {
329
331
  deferredThisSession.add(anchor);
330
- emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
332
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
331
333
  return anchor;
332
334
  }
333
335
  const now = new Date().toISOString();
@@ -344,7 +346,7 @@ export function markDisposition(cwd, target, disposition, reason) {
344
346
  lineText,
345
347
  };
346
348
  commitDisposition(cwd, anchor, entry);
347
- emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
349
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing, identity);
348
350
  return anchor;
349
351
  }
350
352
  export function getDisposition(cwd, anchor) {
@@ -61,7 +61,7 @@
61
61
  */
62
62
  import { logBusEvent } from "./bus-events-logger.js";
63
63
  import { normalizeFilePath } from "./path-utils.js";
64
- import { createLiveBusEmitter, recordStaleBusFailure, } from "./live-bus-emitter.js";
64
+ import { createLiveBusEmitter, recordStaleBusFailure, resolveLiveBusEmitter, } from "./live-bus-emitter.js";
65
65
  import { isBusPublishEnabled } from "./bus-publish.js";
66
66
  export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
67
67
  export const BUS_DIAGNOSTICS_VERSION = 1;
@@ -149,8 +149,13 @@ export function publishDiagnostics(args) {
149
149
  }
150
150
  return;
151
151
  }
152
- const busEmit = liveEmitter.get();
153
- if (!busEmit) {
152
+ const resolution = resolveLiveBusEmitter(liveEmitter, () => ({
153
+ event: BUS_DIAGNOSTICS_EVENT,
154
+ cwd: normalizeFilePath(args.cwd),
155
+ }));
156
+ if (resolution.outcome === "stale-session")
157
+ return;
158
+ if (resolution.outcome === "unwired") {
154
159
  if (!hasLoggedUnwired) {
155
160
  hasLoggedUnwired = true;
156
161
  logBusEvent({
@@ -161,6 +166,7 @@ export function publishDiagnostics(args) {
161
166
  }
162
167
  return;
163
168
  }
169
+ const busEmit = resolution.emit;
164
170
  try {
165
171
  const fileEntries = args.files.map((f) => {
166
172
  const normPath = normalizeFilePath(f.path);
@@ -204,6 +210,7 @@ export function publishDiagnostics(args) {
204
210
  outcome: "emit_failed",
205
211
  cwd: normalizeFilePath(args.cwd),
206
212
  error: String(err),
213
+ ctxSource: resolution.ctxSource,
207
214
  });
208
215
  if (!hasLoggedFailure) {
209
216
  hasLoggedFailure = true;