pi-lens 3.8.69 → 3.8.70

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 (133) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +15 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +51 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/code-quality-warnings.js +27 -0
  7. package/dist/clients/dead-code-client.js +5 -23
  8. package/dist/clients/diagnostics-publish.js +38 -2
  9. package/dist/clients/dispatch/auxiliary-lsp.js +62 -6
  10. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  11. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  12. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  13. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  14. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  15. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  16. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  17. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  18. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  19. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  20. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  21. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  22. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  23. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  24. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  25. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  26. package/dist/clients/dispatch/runners/lsp.js +22 -6
  27. package/dist/clients/gitleaks-client.js +40 -5
  28. package/dist/clients/installer/index.js +123 -6
  29. package/dist/clients/instance-reaper.js +229 -2
  30. package/dist/clients/instance-registry.js +71 -2
  31. package/dist/clients/knip-client.js +35 -38
  32. package/dist/clients/language-profile.js +7 -11
  33. package/dist/clients/lens-engine.js +12 -0
  34. package/dist/clients/log-cleanup.js +61 -19
  35. package/dist/clients/lsp/cascade-tier.js +31 -19
  36. package/dist/clients/lsp/client.js +52 -7
  37. package/dist/clients/lsp/config.js +27 -1
  38. package/dist/clients/lsp/index.js +547 -59
  39. package/dist/clients/lsp/server-strategies.js +17 -8
  40. package/dist/clients/lsp/server.js +63 -27
  41. package/dist/clients/lsp-budget.js +148 -0
  42. package/dist/clients/mcp/session.js +1 -0
  43. package/dist/clients/middle-man-analysis.js +260 -0
  44. package/dist/clients/module-report.js +173 -11
  45. package/dist/clients/ndjson-logger.js +24 -0
  46. package/dist/clients/opengrep-client.js +217 -0
  47. package/dist/clients/path-utils.js +40 -0
  48. package/dist/clients/pipeline.js +1 -1
  49. package/dist/clients/project-diagnostics/extractors.js +21 -1
  50. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  51. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  52. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  53. package/dist/clients/quiet-window.js +57 -2
  54. package/dist/clients/read-expansion.js +2 -3
  55. package/dist/clients/read-guard-tool-lines.js +10 -0
  56. package/dist/clients/resource-sampler.js +254 -0
  57. package/dist/clients/review-graph/builder.js +265 -11
  58. package/dist/clients/review-graph/symbol-id.js +57 -0
  59. package/dist/clients/runtime-session.js +40 -6
  60. package/dist/clients/runtime-turn.js +53 -11
  61. package/dist/clients/safe-spawn.js +48 -2
  62. package/dist/clients/source-filter.js +12 -25
  63. package/dist/clients/source-walker.js +66 -0
  64. package/dist/clients/startup-scan.js +37 -42
  65. package/dist/clients/symbol-containment.js +24 -0
  66. package/dist/clients/test-runner-client.js +544 -29
  67. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  68. package/dist/clients/widget-state.js +61 -1
  69. package/dist/clients/write-ordering-guard.js +58 -0
  70. package/dist/clients/zizmor-config.js +27 -0
  71. package/dist/index.js +6640 -2947
  72. package/dist/mcp/server.js +95 -19
  73. package/dist/tools/activate-tools.js +72 -0
  74. package/dist/tools/ast-dump.js +2 -8
  75. package/dist/tools/lens-diagnostics.js +290 -30
  76. package/dist/tools/lsp-diagnostics.js +544 -104
  77. package/dist/tools/module-report.js +18 -5
  78. package/docs/agent-tooling-improvement-plan.md +660 -0
  79. package/docs/agent-tools.md +129 -0
  80. package/docs/ast-grep_rules_catalog.md +564 -0
  81. package/docs/astplayground.md +178 -0
  82. package/docs/audit1.md +177 -0
  83. package/docs/custom-rules.md +231 -0
  84. package/docs/dependencies.md +54 -0
  85. package/docs/env_variables.md +10 -0
  86. package/docs/environment-variables.md +67 -0
  87. package/docs/fable.md +139 -0
  88. package/docs/features.md +338 -0
  89. package/docs/globalconfig.md +79 -0
  90. package/docs/inspiration_gograph.md +601 -0
  91. package/docs/inspiration_neovim.md +1795 -0
  92. package/docs/inspiration_neovim2.md +402 -0
  93. package/docs/inspiration_tree_sitter_rules.md +369 -0
  94. package/docs/inspiration_tree_sitter_rules_investigation.md +328 -0
  95. package/docs/inspiration_tree_sitter_rules_round2.md +470 -0
  96. package/docs/language-coverage.md +48 -0
  97. package/docs/lsp-bridge-analysis.md +444 -0
  98. package/docs/lsp-capability-matrix.md +157 -0
  99. package/docs/lsp-latency-benchmark.md +169 -0
  100. package/docs/mcp.md +438 -0
  101. package/docs/module-report-read-symbol.md +258 -0
  102. package/docs/newinspiration.md +338 -0
  103. package/docs/servercapabilities.md +118 -0
  104. package/docs/subagent-compat.md +281 -0
  105. package/docs/tools.md +13 -0
  106. package/docs/tools_improvement2.md +345 -0
  107. package/docs/tree-sitter_rules_catalog.md +296 -0
  108. package/docs/usage.md +129 -0
  109. package/docs/word-index.md +150 -0
  110. package/grammars/tree-sitter-c.wasm +0 -0
  111. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  112. package/grammars/tree-sitter-cpp.wasm +0 -0
  113. package/grammars/tree-sitter-dart.wasm +0 -0
  114. package/grammars/tree-sitter-elixir.wasm +0 -0
  115. package/grammars/tree-sitter-kotlin.wasm +0 -0
  116. package/grammars/tree-sitter-lua.wasm +0 -0
  117. package/grammars/tree-sitter-ocaml.wasm +0 -0
  118. package/grammars/tree-sitter-php.wasm +0 -0
  119. package/grammars/tree-sitter-ruby.wasm +0 -0
  120. package/grammars/tree-sitter-swift.wasm +0 -0
  121. package/grammars/tree-sitter-toml.wasm +0 -0
  122. package/grammars/tree-sitter-vue.wasm +0 -0
  123. package/grammars/tree-sitter-zig.wasm +0 -0
  124. package/package.json +5 -1
  125. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  126. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  127. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  128. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  129. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +1 -1
  130. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  131. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  132. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  133. package/dist/clients/production-readiness.js +0 -493
@@ -63,7 +63,7 @@ export function loadBootstrapClients() {
63
63
  return [];
64
64
  }
65
65
  }
66
- const [ruffClient, biomeClient, knipClient, todoScanner, jscpdClient, depChecker, testRunnerClient, metricsClient, complexityClient, goClient, govulncheckClient, gitleaksClient, trivyClient, rustClient, agentBehaviorClient, deadCodeClients,] = await Promise.all([
66
+ const [ruffClient, biomeClient, knipClient, todoScanner, jscpdClient, depChecker, testRunnerClient, metricsClient, complexityClient, goClient, govulncheckClient, gitleaksClient, trivyClient, opengrepClient, rustClient, agentBehaviorClient, deadCodeClients,] = await Promise.all([
67
67
  load("ruff", async () => new (await import("./ruff-client.js")).RuffClient()),
68
68
  load("biome", async () => new (await import("./biome-client.js")).BiomeClient()),
69
69
  load("knip", async () => new (await import("./knip-client.js")).KnipClient()),
@@ -77,6 +77,7 @@ export function loadBootstrapClients() {
77
77
  load("govulncheck", async () => new (await import("./govulncheck-client.js")).GovulncheckClient()),
78
78
  load("gitleaks", async () => new (await import("./gitleaks-client.js")).GitleaksClient()),
79
79
  load("trivy", async () => new (await import("./trivy-client.js")).TrivyClient()),
80
+ load("opengrep", async () => new (await import("./opengrep-client.js")).OpengrepClient()),
80
81
  load("rust", async () => new (await import("./rust-client.js")).RustClient()),
81
82
  load("agent-behavior", async () => new (await import("./agent-behavior-client.js")).AgentBehaviorClient()),
82
83
  loadList("dead-code", async () => (await import("./dead-code-client.js")).getDeadCodeClients()),
@@ -97,6 +98,7 @@ export function loadBootstrapClients() {
97
98
  govulncheckClient,
98
99
  gitleaksClient,
99
100
  trivyClient,
101
+ opengrepClient,
100
102
  rustClient,
101
103
  agentBehaviorClient,
102
104
  deadCodeClients,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Persistent NDJSON trace of `pi.events` bus publish attempts
3
+ * (`pilens:files:touched` #482 / `pilens:diagnostics` #502).
4
+ *
5
+ * Both producers (clients/bus-publish.ts, clients/diagnostics-publish.ts) are
6
+ * fire-and-forget: on failure or on a structural no-op (never wired, kill
7
+ * switch off) they only invoke an optional `dbg` callback, which varies by
8
+ * host and is a documented no-op in the MCP host (clients/mcp/session.ts's
9
+ * `dbg: noop`). That leaves bus-publish outcomes invisible in exactly the
10
+ * context where they matter most — same failure shape as the #544 MCP
11
+ * session_start incident this repo already fixed once.
12
+ *
13
+ * This module gives bus events the same durable trace every other pi-lens
14
+ * subsystem already has (latency.log, cascade.log, read-guard.log, ...) —
15
+ * see clients/latency-logger.ts for the house pattern this mirrors exactly:
16
+ * one shared `createNdjsonLogger` writer, `isTestMode()` no-op guard,
17
+ * `getBusEventsLogPath()` for testability.
18
+ *
19
+ * Logging volume: `emitted` and `emit_failed` are logged on every call —
20
+ * they're the two outcomes an operator actually needs a per-event trace for.
21
+ * `skipped_unwired` and `skipped_disabled` are process-lifetime-static facts
22
+ * (wiring happens once at extension factory time; the kill switch is an env
23
+ * var read once at startup) — logging them on every publish attempt would
24
+ * spam one identical line per write batch for an entire session with zero
25
+ * new information after the first. Both are gated log-once-per-process, the
26
+ * same `hasLoggedFailure` shape the producers already use for emit_failed.
27
+ * The empty-batch branch (`paths.length === 0` / `files.length === 0`) is
28
+ * NOT logged at all: every call site already guards against invoking these
29
+ * functions with nothing to report (see clients/pipeline.ts,
30
+ * clients/runtime-agent-end.ts), so it's a normal, frequent no-op rather
31
+ * than a real event worth a log line.
32
+ */
33
+ import * as path from "node:path";
34
+ import { isTestMode } from "./env-utils.js";
35
+ import { getGlobalPiLensDir } from "./file-utils.js";
36
+ import { createNdjsonLogger } from "./ndjson-logger.js";
37
+ const BUS_EVENTS_LOG_FILE = path.join(getGlobalPiLensDir(), "bus-events.log");
38
+ const writer = createNdjsonLogger({ filePath: BUS_EVENTS_LOG_FILE });
39
+ export function logBusEvent(entry) {
40
+ if (isTestMode()) {
41
+ return;
42
+ }
43
+ writer.log({ ts: new Date().toISOString(), ...entry });
44
+ }
45
+ export function getBusEventsLogPath() {
46
+ return BUS_EVENTS_LOG_FILE;
47
+ }
48
+ /** Resolve once all enqueued bus-event writes are on disk (tests/shutdown). */
49
+ export function flushBusEventsLog() {
50
+ return writer.flush();
51
+ }
@@ -17,12 +17,19 @@
17
17
  * callback is invoked at most once on first failure so a wired caller can log
18
18
  * it without spamming.
19
19
  */
20
+ import { logBusEvent } from "./bus-events-logger.js";
20
21
  import { normalizeFilePath } from "./path-utils.js";
21
22
  import { appendRecentTouches } from "./recent-touches.js";
22
23
  export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
23
24
  export const BUS_FILES_TOUCHED_VERSION = 1;
24
25
  let busEmit;
25
26
  let hasLoggedFailure = false;
27
+ // Log-once-per-process guards for the two structural-no-op outcomes below
28
+ // (see bus-events-logger.ts's module doc for why these aren't logged on
29
+ // every call — both are static, session-lifetime facts, not transient
30
+ // per-event failures).
31
+ let hasLoggedUnwired = false;
32
+ let hasLoggedDisabled = false;
26
33
  /**
27
34
  * Wire the emit function from pi's `pi.events` bus. Called once at extension
28
35
  * factory time from index.ts (module-level singleton, same pattern as
@@ -37,6 +44,8 @@ export function wireBusEmitter(emitFn) {
37
44
  export function _resetForTests() {
38
45
  busEmit = undefined;
39
46
  hasLoggedFailure = false;
47
+ hasLoggedUnwired = false;
48
+ hasLoggedDisabled = false;
40
49
  _envCache = undefined;
41
50
  }
42
51
  let _envCache;
@@ -73,8 +82,18 @@ export function publishFilesTouched(args) {
73
82
  return;
74
83
  if (args.paths.length === 0)
75
84
  return;
76
- if (!isBusPublishEnabled())
85
+ if (!isBusPublishEnabled()) {
86
+ if (!hasLoggedDisabled) {
87
+ hasLoggedDisabled = true;
88
+ logBusEvent({
89
+ event: BUS_FILES_TOUCHED_EVENT,
90
+ outcome: "skipped_disabled",
91
+ cwd: normalizeFilePath(args.cwd),
92
+ reason: args.reason,
93
+ });
94
+ }
77
95
  return;
96
+ }
78
97
  void appendRecentTouches({
79
98
  cwd: args.cwd,
80
99
  reason: args.reason,
@@ -83,8 +102,18 @@ export function publishFilesTouched(args) {
83
102
  }).catch((err) => {
84
103
  args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
85
104
  });
86
- if (!busEmit)
105
+ if (!busEmit) {
106
+ if (!hasLoggedUnwired) {
107
+ hasLoggedUnwired = true;
108
+ logBusEvent({
109
+ event: BUS_FILES_TOUCHED_EVENT,
110
+ outcome: "skipped_unwired",
111
+ cwd: normalizeFilePath(args.cwd),
112
+ reason: args.reason,
113
+ });
114
+ }
87
115
  return;
116
+ }
88
117
  try {
89
118
  const payload = {
90
119
  v: BUS_FILES_TOUCHED_VERSION,
@@ -100,8 +129,22 @@ export function publishFilesTouched(args) {
100
129
  }));
101
130
  }
102
131
  busEmit(BUS_FILES_TOUCHED_EVENT, payload);
132
+ logBusEvent({
133
+ event: BUS_FILES_TOUCHED_EVENT,
134
+ outcome: "emitted",
135
+ cwd: payload.cwd,
136
+ reason: args.reason,
137
+ fileCount: payload.paths.length,
138
+ });
103
139
  }
104
140
  catch (err) {
141
+ logBusEvent({
142
+ event: BUS_FILES_TOUCHED_EVENT,
143
+ outcome: "emit_failed",
144
+ cwd: normalizeFilePath(args.cwd),
145
+ reason: args.reason,
146
+ error: String(err),
147
+ });
105
148
  if (!hasLoggedFailure) {
106
149
  hasLoggedFailure = true;
107
150
  args.dbg?.(`bus-publish: pilens:files:touched emit failed (further failures suppressed): ${err}`);
@@ -135,6 +135,33 @@ export function buildCodeQualityWarningsReport(args) {
135
135
  },
136
136
  };
137
137
  }
138
+ /**
139
+ * #557 audit (same race class as #555's LSP client fix and #560's
140
+ * `widget-state.ts` `recordDiagnostics` fix): pi-lens deliberately allows
141
+ * concurrent pipeline runs for the SAME file across DIFFERENT same-turn
142
+ * edits (dedupe key is `filePath + contentHash`, not just `filePath` — see
143
+ * `clients/runtime-tool-result.ts`), so a per-key cache fed directly from
144
+ * those concurrent pipeline runs needs an ordering guard (`WriteOrderingGuard`
145
+ * from `clients/write-ordering-guard.ts`) or an older edit's slower pipeline
146
+ * can silently overwrite a newer edit's fresher write.
147
+ *
148
+ * This call site is NOT exposed to that race. Unlike `widget-state.ts`'s
149
+ * `recordDiagnostics` (called directly from `clients/pipeline.ts`, once per
150
+ * pipeline run — i.e. potentially several times per turn, out of completion
151
+ * order), `writeCodeQualityWarningsReport` has exactly one caller:
152
+ * `handleTurnEnd` in `clients/runtime-turn.ts`, which per-edit pipeline runs
153
+ * never call directly — they only feed `runtime.recordCodeQualityWarnings`
154
+ * (an accumulating, order-independent Map). `handleTurnEnd` itself reads that
155
+ * accumulator once via `runtime.peekCodeQualityWarnings()` and writes the
156
+ * aggregate report exactly once per turn-end invocation — the same single
157
+ * sequential turn-end-only shape already confirmed safe for
158
+ * `writeActionableWarningsReport` in `clients/actionable-warnings.ts`, right
159
+ * next to this call site in `handleTurnEnd`. No second writer, so no
160
+ * ordering token to guard against — see
161
+ * `tests/clients/code-quality-warnings.test.ts`'s
162
+ * "single sequential caller" test, which pins this invariant so a future
163
+ * change that adds a second call site has a better chance of being caught.
164
+ */
138
165
  export function writeCodeQualityWarningsReport(cacheManager, cwd, report) {
139
166
  cacheManager.writeCache("code-quality-warnings", report, cwd);
140
167
  }
@@ -11,10 +11,8 @@
11
11
  * Phase 1 ships Python via `vulture`. Future phases add Go/Rust/etc. by
12
12
  * implementing DeadCodeClient and adding to getDeadCodeClients().
13
13
  */
14
- import * as fs from "node:fs";
15
- import * as os from "node:os";
16
14
  import * as path from "node:path";
17
- import { isAtOrAboveHomeDir } from "./path-utils.js";
15
+ import { findNearestMarkerRoot } from "./path-utils.js";
18
16
  import { safeSpawnAsync } from "./safe-spawn.js";
19
17
  function emptyResult(language) {
20
18
  return {
@@ -108,34 +106,18 @@ export class PythonDeadCodeClient {
108
106
  * Nearest dir with a Python project marker, never at/above $HOME and never
109
107
  * escaping a VCS boundary — same containment rules as KnipClient so a scan
110
108
  * launched from a bare cwd can't recurse the whole home tree (#250/#296).
109
+ * Delegates to the shared path-utils helper (refs #625) rather than
110
+ * hand-rolling the climb; only the marker list differs from KnipClient's.
111
111
  */
112
112
  resolveProjectRoot(startDir, homeDirOverride) {
113
- const markers = [
113
+ return findNearestMarkerRoot(startDir, [
114
114
  "pyproject.toml",
115
115
  "setup.py",
116
116
  "setup.cfg",
117
117
  "requirements.txt",
118
118
  "Pipfile",
119
119
  "tox.ini",
120
- ];
121
- const boundaries = [".git", ".hg", ".svn"];
122
- const homeDir = path.resolve(homeDirOverride ?? os.homedir());
123
- let current = path.resolve(startDir);
124
- for (let depth = 0; depth < 64; depth++) {
125
- if (isAtOrAboveHomeDir(current, homeDir))
126
- return null;
127
- if (markers.some((m) => fs.existsSync(path.join(current, m)))) {
128
- return current;
129
- }
130
- if (boundaries.some((m) => fs.existsSync(path.join(current, m)))) {
131
- return null;
132
- }
133
- const parent = path.dirname(current);
134
- if (parent === current)
135
- return null;
136
- current = parent;
137
- }
138
- return null;
120
+ ], { boundaries: [".git", ".hg", ".svn"], homeDir: homeDirOverride });
139
121
  }
140
122
  async ensureAvailable() {
141
123
  if (this.available !== null)
@@ -59,6 +59,7 @@
59
59
  * fields may be added under `v: 1`; a breaking change to an existing field's
60
60
  * meaning must bump `v`.
61
61
  */
62
+ import { logBusEvent } from "./bus-events-logger.js";
62
63
  import { normalizeFilePath } from "./path-utils.js";
63
64
  import { isBusPublishEnabled } from "./bus-publish.js";
64
65
  export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
@@ -67,6 +68,8 @@ export const BUS_DIAGNOSTICS_VERSION = 1;
67
68
  export const MAX_DIAGNOSTICS_PER_FILE_EVENT = 12;
68
69
  let busEmit;
69
70
  let hasLoggedFailure = false;
71
+ let hasLoggedUnwired = false;
72
+ let hasLoggedDisabled = false;
70
73
  let seqCounter = 0;
71
74
  /** Paths this producer has reported with at least one non-empty diagnostics array, so we know when to fire the one-time clean-transition event. */
72
75
  const reportedDirtyPaths = new Set();
@@ -82,6 +85,8 @@ export function wireDiagnosticsBusEmitter(emitFn) {
82
85
  export function _resetDiagnosticsPublishForTests() {
83
86
  busEmit = undefined;
84
87
  hasLoggedFailure = false;
88
+ hasLoggedUnwired = false;
89
+ hasLoggedDisabled = false;
85
90
  seqCounter = 0;
86
91
  reportedDirtyPaths.clear();
87
92
  }
@@ -129,10 +134,28 @@ export function publishDiagnostics(args) {
129
134
  return;
130
135
  if (args.files.length === 0)
131
136
  return;
132
- if (!isBusPublishEnabled())
137
+ if (!isBusPublishEnabled()) {
138
+ if (!hasLoggedDisabled) {
139
+ hasLoggedDisabled = true;
140
+ logBusEvent({
141
+ event: BUS_DIAGNOSTICS_EVENT,
142
+ outcome: "skipped_disabled",
143
+ cwd: normalizeFilePath(args.cwd),
144
+ });
145
+ }
133
146
  return;
134
- if (!busEmit)
147
+ }
148
+ if (!busEmit) {
149
+ if (!hasLoggedUnwired) {
150
+ hasLoggedUnwired = true;
151
+ logBusEvent({
152
+ event: BUS_DIAGNOSTICS_EVENT,
153
+ outcome: "skipped_unwired",
154
+ cwd: normalizeFilePath(args.cwd),
155
+ });
156
+ }
135
157
  return;
158
+ }
136
159
  try {
137
160
  const fileEntries = args.files.map((f) => {
138
161
  const normPath = normalizeFilePath(f.path);
@@ -161,8 +184,21 @@ export function publishDiagnostics(args) {
161
184
  files: fileEntries,
162
185
  };
163
186
  busEmit(BUS_DIAGNOSTICS_EVENT, payload);
187
+ logBusEvent({
188
+ event: BUS_DIAGNOSTICS_EVENT,
189
+ outcome: "emitted",
190
+ cwd: payload.cwd,
191
+ fileCount: payload.files.length,
192
+ seq: payload.seq,
193
+ });
164
194
  }
165
195
  catch (err) {
196
+ logBusEvent({
197
+ event: BUS_DIAGNOSTICS_EVENT,
198
+ outcome: "emit_failed",
199
+ cwd: normalizeFilePath(args.cwd),
200
+ error: String(err),
201
+ });
166
202
  if (!hasLoggedFailure) {
167
203
  hasLoggedFailure = true;
168
204
  args.dbg?.(`diagnostics-publish: pilens:diagnostics emit failed (further failures suppressed): ${err}`);
@@ -16,6 +16,7 @@
16
16
  * Adding a new cross-cutting tool = register an `LSPServerInfo` with
17
17
  * `role:"auxiliary"` (clients/lsp/server.ts) + one profile entry here.
18
18
  */
19
+ import { shouldDegradeAuxiliaryLsp } from "../lsp-budget.js";
19
20
  import { findLocalOpengrepConfig } from "../opengrep-config.js";
20
21
  import { findLocalTyposConfig } from "../typos-config.js";
21
22
  import { findLocalZizmorConfig } from "../zizmor-config.js";
@@ -56,6 +57,11 @@ export function isNosemgrepSuppressed(d, content) {
56
57
  return (checkLine(lines[startLine], false) ||
57
58
  checkLine(lines[startLine - 1], true));
58
59
  }
60
+ // #277 R7: every profile below used this exact same rule (ERROR-severity
61
+ // findings block only when the workspace opted into curated/authored rules;
62
+ // everything else stays advisory) — shared here instead of copy-pasted per
63
+ // profile so a future policy change (e.g. a WARNING-blocking tier) is one edit.
64
+ const blockOnErrorWhenAllowed = (d, { blockingAllowed }) => (blockingAllowed && d.severity === 1 ? "blocking" : "warning");
59
65
  export const AUXILIARY_LSP_PROFILES = [
60
66
  {
61
67
  serverId: "opengrep",
@@ -71,7 +77,7 @@ export const AUXILIARY_LSP_PROFILES = [
71
77
  // supplies its own curated rules (the author's deliberate severity); the
72
78
  // auto set is advisory. Either way, findings surface via lens_diagnostics.
73
79
  allowBlocking: (cwd) => Boolean(findLocalOpengrepConfig(cwd)),
74
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
80
+ semantic: blockOnErrorWhenAllowed,
75
81
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "opengrep", d.message ?? ""),
76
82
  // Honor the canonical Semgrep suppression the user already knows (#441).
77
83
  isSuppressed: isNosemgrepSuppressed,
@@ -88,7 +94,7 @@ export const AUXILIARY_LSP_PROFILES = [
88
94
  // deliberate, so preserve ast-grep's severity semantics: ERROR can block,
89
95
  // WARNING/INFO stay advisory.
90
96
  allowBlocking: () => true,
91
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
97
+ semantic: blockOnErrorWhenAllowed,
92
98
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "ast-grep", d.message ?? ""),
93
99
  },
94
100
  {
@@ -104,7 +110,7 @@ export const AUXILIARY_LSP_PROFILES = [
104
110
  // ignores). Advisory otherwise — findings still surface via lens_diagnostics.
105
111
  // zizmor maps High→ERROR(1), Medium/Low→WARNING(2), Informational→INFO(3).
106
112
  allowBlocking: (cwd) => Boolean(findLocalZizmorConfig(cwd)),
107
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
113
+ semantic: blockOnErrorWhenAllowed,
108
114
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "zizmor", d.message ?? ""),
109
115
  },
110
116
  {
@@ -122,23 +128,73 @@ export const AUXILIARY_LSP_PROFILES = [
122
128
  // severity is WARNING, so even with a config it stays advisory unless the
123
129
  // repo raises `diagnostic-severity` to Error.
124
130
  allowBlocking: (cwd) => Boolean(findLocalTyposConfig(cwd)),
125
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
131
+ semantic: blockOnErrorWhenAllowed,
126
132
  // A misspelling is a documentation/quality defect — not security or
127
133
  // correctness. "style" is the closest taxonomy class.
128
134
  defectClass: () => "style",
129
135
  },
130
136
  ];
131
137
  /** The auxiliary server ids enabled for this turn (the lsp runner passes these
132
- * to `touchFile` since it — not the LSP service — owns flag access). */
138
+ * to `touchFile` since it — not the LSP service — owns flag access).
139
+ *
140
+ * #449 slice 2 (prototype): when this process decided at `session_start` that
141
+ * the machine-wide LSP budget is exceeded (`clients/lsp-budget.ts`), auxiliary
142
+ * servers are skipped entirely for the rest of the session — the primary
143
+ * language server per file is unaffected. This is a per-SESSION degrade, not
144
+ * per-file: once over budget, this session never spawns its auxiliary fleet,
145
+ * rather than flip-flopping file to file. */
133
146
  export function enabledAuxiliaryLspServerIds(getFlag) {
147
+ if (shouldDegradeAuxiliaryLsp())
148
+ return [];
134
149
  return AUXILIARY_LSP_PROFILES.flatMap((p) => p.enabledByDefault &&
135
150
  !(p.killSwitchFlag && getFlag(p.killSwitchFlag) === true)
136
151
  ? [p.serverId]
137
152
  : []);
138
153
  }
154
+ // #277 R7: `findAuxiliaryProfileForSource` is called once per diagnostic, and a
155
+ // file's diagnostics are typically dominated by a handful of distinct `source`
156
+ // strings (one per tool that fired). Memoizing by exact `source` turns an
157
+ // O(profiles) regex scan per diagnostic into one scan per distinct source seen
158
+ // — safe because `AUXILIARY_LSP_PROFILES` is a fixed module-level const, never
159
+ // mutated at runtime, so a source's matching profile never changes.
160
+ const profileForSourceCache = new Map();
139
161
  /** Find the profile whose server emitted a diagnostic with this `source`. */
140
162
  export function findAuxiliaryProfileForSource(source) {
141
163
  if (!source)
142
164
  return undefined;
143
- return AUXILIARY_LSP_PROFILES.find((p) => p.sourceMatch.test(source));
165
+ const cached = profileForSourceCache.get(source);
166
+ if (cached !== undefined || profileForSourceCache.has(source))
167
+ return cached;
168
+ const found = AUXILIARY_LSP_PROFILES.find((p) => p.sourceMatch.test(source));
169
+ profileForSourceCache.set(source, found);
170
+ return found;
171
+ }
172
+ /**
173
+ * Single-diagnostic suppression check (#586): look up the diagnostic's
174
+ * auxiliary profile by `source` and, if that profile declares an
175
+ * `isSuppressed` callback (currently only opengrep's `# nosemgrep`, #441),
176
+ * apply it. Returns false for diagnostics with no matching profile or whose
177
+ * profile has no native suppression syntax — the common case for plain
178
+ * language-server diagnostics.
179
+ *
180
+ * This is the ONE lookup+apply implementation; every call site that decides
181
+ * whether to drop a diagnostic for its tool's own inline suppression comment
182
+ * should go through this (or `applyAuxiliarySuppressions` below) rather than
183
+ * re-deriving the profile lookup.
184
+ */
185
+ export function isAuxiliaryDiagnosticSuppressed(d, content) {
186
+ const profile = findAuxiliaryProfileForSource(d.source);
187
+ return Boolean(profile?.isSuppressed?.(d, content));
188
+ }
189
+ /**
190
+ * Filter a diagnostic list down to the ones NOT suppressed by their
191
+ * auxiliary profile's native inline-comment syntax (#586). This is the
192
+ * shared helper `tools/lsp-diagnostics.ts` and `clients/lsp/index.ts`'s
193
+ * `runWorkspaceDiagnostics` use so a `// nosemgrep` (or any future profile's
194
+ * equivalent) suppresses a finding identically whether it's seen via the
195
+ * per-edit dispatch runner or a standalone diagnostics query — previously
196
+ * only the former honored it (#586).
197
+ */
198
+ export function applyAuxiliarySuppressions(diagnostics, content) {
199
+ return diagnostics.filter((d) => !isAuxiliaryDiagnosticSuppressed(d, content));
144
200
  }
@@ -1,3 +1,4 @@
1
+ import { findOwnerName } from "../../symbol-containment.js";
1
2
  import { extractFactsFromTree, firstChildOfType, walk, } from "./tree-sitter-facts.js";
2
3
  const BOUNDARY_PREFIXES = [
3
4
  "fetch",
@@ -160,6 +161,62 @@ function collectOutgoingCalls(body) {
160
161
  });
161
162
  return [...calls];
162
163
  }
164
+ /**
165
+ * `obj.method()` call sites with a simple-identifier receiver (refs #655
166
+ * phase 2). Only the plain shape `identifier.identifier(...)` is captured —
167
+ * chained (`a.b.c()`), computed (`a[x]()`), and `this.`-receiver calls are
168
+ * left out of this list (they still show up in `outgoingCalls`'s flattened
169
+ * text form as before); those richer shapes need real type inference to
170
+ * resolve safely, which is out of scope for this bounded slice.
171
+ */
172
+ function collectMemberCallSites(body) {
173
+ const sites = [];
174
+ walk(body, (node) => {
175
+ if (node.type !== "call_expression")
176
+ return;
177
+ const callee = (node.children ?? [])[0];
178
+ if (!callee || callee.type !== "member_expression")
179
+ return;
180
+ const object = (callee.children ?? [])[0];
181
+ const property = (callee.children ?? []).find((c) => c?.type === "property_identifier");
182
+ if (!object || object.type !== "identifier" || !property)
183
+ return;
184
+ sites.push({ receiver: object.text, method: property.text });
185
+ });
186
+ return sites;
187
+ }
188
+ /**
189
+ * Best-effort receiver -> class-name map for one function (refs #655 phase
190
+ * 2). Two clear, common shapes only — see {@link FunctionSummary.receiverTypes}.
191
+ */
192
+ function collectReceiverTypes(body, params) {
193
+ const types = {};
194
+ for (const param of params) {
195
+ const id = firstNamedChild(param);
196
+ if (!id || id.type !== "identifier")
197
+ continue;
198
+ const typeAnnotation = (param.children ?? []).find((c) => c?.type === "type_annotation");
199
+ const typeId = typeAnnotation
200
+ ? firstChildOfType(typeAnnotation, "type_identifier")
201
+ : undefined;
202
+ if (typeId)
203
+ types[id.text] = typeId.text;
204
+ }
205
+ walk(body, (node) => {
206
+ if (node.type !== "variable_declarator")
207
+ return;
208
+ const id = firstChildOfType(node, "identifier");
209
+ if (!id)
210
+ return;
211
+ const value = (node.children ?? []).find((c) => c?.type === "new_expression");
212
+ if (!value)
213
+ return;
214
+ const ctor = firstNamedChild(value);
215
+ if (ctor?.type === "identifier")
216
+ types[id.text] = ctor.text;
217
+ });
218
+ return types;
219
+ }
163
220
  function hasAwaitInNode(node) {
164
221
  let found = false;
165
222
  walk(node, (n) => {
@@ -192,6 +249,13 @@ export const functionFactProvider = {
192
249
  async run(ctx, store) {
193
250
  await extractFactsFromTree(ctx, store, { "file.functionSummaries": [] }, (root) => {
194
251
  const summaries = [];
252
+ // Class/interface declarations, collected in the SAME walk over the
253
+ // already-parsed tree (refs #655 phase 2) so owner/qualified-name
254
+ // computation for jsts needs no second parse — see
255
+ // `symbol-containment.ts`'s doc comment for why this can't literally
256
+ // share code with module-report.ts's tree-sitter-symbol-extractor path
257
+ // (different tree-sitter integration), only the same algorithm.
258
+ const containers = [];
195
259
  const addSummary = (node) => {
196
260
  const body = getBody(node);
197
261
  if (!body)
@@ -210,6 +274,7 @@ export const functionFactProvider = {
210
274
  name: getFunctionName(node),
211
275
  line: node.startPosition.row + 1,
212
276
  column: node.startPosition.column + 1,
277
+ endLine: body.endPosition.row + 1,
213
278
  isAsync: Boolean(firstChildOfType(node, "async")),
214
279
  hasAwait: hasAwaitInNode(body),
215
280
  hasReturnAwaitCall: hasReturnAwaitCall(body),
@@ -221,12 +286,30 @@ export const functionFactProvider = {
221
286
  cyclomaticComplexity: calcCyclomaticComplexity(body),
222
287
  maxNestingDepth: calcMaxNestingDepth(body),
223
288
  outgoingCalls: collectOutgoingCalls(body),
289
+ memberCallSites: collectMemberCallSites(body),
290
+ receiverTypes: collectReceiverTypes(body, params),
224
291
  });
225
292
  };
226
293
  walk(root, (node) => {
227
294
  if (FUNCTION_TYPES.has(node.type))
228
295
  addSummary(node);
296
+ if (node.type === "class_declaration" ||
297
+ node.type === "interface_declaration") {
298
+ const nameNode = firstChildOfType(node, "type_identifier");
299
+ if (nameNode) {
300
+ containers.push({
301
+ name: nameNode.text,
302
+ startLine: node.startPosition.row + 1,
303
+ endLine: node.endPosition.row + 1,
304
+ });
305
+ }
306
+ }
229
307
  });
308
+ for (const summary of summaries) {
309
+ const owner = findOwnerName(containers, summary.line, summary.endLine ?? summary.line);
310
+ if (owner)
311
+ summary.owner = owner;
312
+ }
230
313
  return { "file.functionSummaries": summaries };
231
314
  });
232
315
  },
@@ -23,7 +23,7 @@ export const asyncNoiseRule = {
23
23
  fn.statementCount > 1) {
24
24
  diagnostics.push({
25
25
  id: `async-noise:${ctx.filePath}:${fn.line}:${fn.column}`,
26
- tool: "async-noise",
26
+ tool: "fact-rules",
27
27
  filePath: ctx.filePath,
28
28
  line: fn.line,
29
29
  column: fn.column,
@@ -12,7 +12,7 @@ export const asyncUnnecessaryWrapperRule = {
12
12
  continue;
13
13
  diagnostics.push({
14
14
  id: `async-unnecessary-wrapper:${ctx.filePath}:${f.line}`,
15
- tool: "async-unnecessary-wrapper",
15
+ tool: "fact-rules",
16
16
  rule: "async-unnecessary-wrapper",
17
17
  filePath: ctx.filePath,
18
18
  line: f.line,
@@ -38,7 +38,7 @@ export const corsWildcardRule = {
38
38
  if (isWildcard) {
39
39
  diagnostics.push({
40
40
  id: `cors-wildcard:${ctx.filePath}:${i + 1}`,
41
- tool: "cors-wildcard",
41
+ tool: "fact-rules",
42
42
  rule: "cors-wildcard",
43
43
  filePath: ctx.filePath,
44
44
  line: i + 1,
@@ -16,7 +16,8 @@ export const errorObscuringRule = {
16
16
  !s.bodyText.includes(s.catchParam)) {
17
17
  diagnostics.push({
18
18
  id: `error-obscuring:${ctx.filePath}:${s.line}:${s.column}`,
19
- tool: "error-obscuring",
19
+ tool: "fact-rules",
20
+ rule: "error-obscuring",
20
21
  filePath: ctx.filePath,
21
22
  line: s.line,
22
23
  column: s.column,
@@ -13,7 +13,8 @@ export const errorSwallowingRule = {
13
13
  if (s.isEmpty && !s.isDocumentedLocalFallback && s.boundaryCategory !== "fs") {
14
14
  diagnostics.push({
15
15
  id: `error-swallowing:${ctx.filePath}:${s.line}:${s.column}`,
16
- tool: "error-swallowing",
16
+ tool: "fact-rules",
17
+ rule: "error-swallowing",
17
18
  filePath: ctx.filePath,
18
19
  line: s.line,
19
20
  column: s.column,