pi-lens 3.8.69 → 3.8.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +31 -13
  3. package/dist/clients/bootstrap.js +3 -1
  4. package/dist/clients/bus-events-logger.js +53 -0
  5. package/dist/clients/bus-publish.js +45 -2
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/code-quality-warnings.js +27 -0
  9. package/dist/clients/dead-code-client.js +5 -23
  10. package/dist/clients/diagnostic-dispositions.js +298 -0
  11. package/dist/clients/diagnostics-publish.js +38 -2
  12. package/dist/clients/dispatch/auxiliary-lsp.js +144 -6
  13. package/dist/clients/dispatch/dispatcher.js +6 -1
  14. package/dist/clients/dispatch/facts/function-facts.js +83 -0
  15. package/dist/clients/dispatch/integration.js +19 -4
  16. package/dist/clients/dispatch/rules/async-noise.js +1 -1
  17. package/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +1 -1
  18. package/dist/clients/dispatch/rules/cors-wildcard.js +1 -1
  19. package/dist/clients/dispatch/rules/error-obscuring.js +2 -1
  20. package/dist/clients/dispatch/rules/error-swallowing.js +2 -1
  21. package/dist/clients/dispatch/rules/framework-call-noise.js +67 -0
  22. package/dist/clients/dispatch/rules/high-complexity.js +12 -1
  23. package/dist/clients/dispatch/rules/high-fan-out.js +14 -3
  24. package/dist/clients/dispatch/rules/high-import-coupling.js +1 -1
  25. package/dist/clients/dispatch/rules/missing-error-propagation.js +1 -1
  26. package/dist/clients/dispatch/rules/no-commented-credentials.js +1 -1
  27. package/dist/clients/dispatch/rules/pass-through-wrappers.js +1 -1
  28. package/dist/clients/dispatch/rules/placeholder-comments.js +1 -1
  29. package/dist/clients/dispatch/rules/unsafe-boundary.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +75 -18
  31. package/dist/clients/dispatch/runners/lsp.js +23 -34
  32. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  33. package/dist/clients/dispatch/suppress-writer.js +68 -0
  34. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  35. package/dist/clients/disposition-logger.js +39 -0
  36. package/dist/clients/disposition-publish.js +109 -0
  37. package/dist/clients/file-utils.js +94 -33
  38. package/dist/clients/format-events-publish.js +299 -0
  39. package/dist/clients/git-tracked-ignore.js +208 -0
  40. package/dist/clients/gitleaks-client.js +40 -5
  41. package/dist/clients/installer/index.js +123 -6
  42. package/dist/clients/instance-reaper.js +229 -2
  43. package/dist/clients/instance-registry.js +141 -2
  44. package/dist/clients/json-cache-read.js +65 -0
  45. package/dist/clients/knip-client.js +35 -38
  46. package/dist/clients/language-profile.js +11 -11
  47. package/dist/clients/lens-engine.js +12 -0
  48. package/dist/clients/lens-map.js +1079 -0
  49. package/dist/clients/log-cleanup.js +61 -19
  50. package/dist/clients/lsp/aggregation.js +69 -2
  51. package/dist/clients/lsp/cascade-tier.js +31 -19
  52. package/dist/clients/lsp/client.js +124 -14
  53. package/dist/clients/lsp/config.js +27 -1
  54. package/dist/clients/lsp/index.js +1028 -68
  55. package/dist/clients/lsp/server-strategies.js +17 -8
  56. package/dist/clients/lsp/server.js +80 -28
  57. package/dist/clients/lsp/tsserver-sync.js +145 -0
  58. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  59. package/dist/clients/lsp-budget.js +148 -0
  60. package/dist/clients/mcp/session.js +9 -0
  61. package/dist/clients/middle-man-analysis.js +260 -0
  62. package/dist/clients/module-report.js +173 -11
  63. package/dist/clients/ndjson-logger.js +24 -0
  64. package/dist/clients/opengrep-client.js +217 -0
  65. package/dist/clients/opengrep-config.js +2 -10
  66. package/dist/clients/path-utils.js +66 -0
  67. package/dist/clients/pipeline.js +5 -1
  68. package/dist/clients/project-diagnostics/cache.js +5 -12
  69. package/dist/clients/project-diagnostics/extractors.js +21 -1
  70. package/dist/clients/project-diagnostics/fresh-fetch.js +275 -0
  71. package/dist/clients/project-diagnostics/runner-adapters/opengrep.js +40 -0
  72. package/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +46 -0
  73. package/dist/clients/project-snapshot.js +3 -6
  74. package/dist/clients/quiet-window.js +57 -2
  75. package/dist/clients/read-expansion.js +2 -3
  76. package/dist/clients/read-guard-tool-lines.js +10 -0
  77. package/dist/clients/read-guard.js +1 -1
  78. package/dist/clients/resource-sampler.js +254 -0
  79. package/dist/clients/review-graph/builder.js +322 -42
  80. package/dist/clients/review-graph/import-resolvers.js +53 -19
  81. package/dist/clients/review-graph/symbol-id.js +57 -0
  82. package/dist/clients/runtime-agent-end.js +26 -0
  83. package/dist/clients/runtime-coordinator.js +9 -1
  84. package/dist/clients/runtime-session.js +93 -17
  85. package/dist/clients/runtime-tool-call.js +789 -0
  86. package/dist/clients/runtime-tool-result.js +15 -2
  87. package/dist/clients/runtime-turn.js +62 -13
  88. package/dist/clients/safe-spawn.js +48 -2
  89. package/dist/clients/session-state-store.js +6 -9
  90. package/dist/clients/sgconfig.js +2 -9
  91. package/dist/clients/source-filter.js +17 -25
  92. package/dist/clients/source-walker.js +66 -0
  93. package/dist/clients/startup-scan.js +105 -43
  94. package/dist/clients/symbol-containment.js +24 -0
  95. package/dist/clients/test-runner-client.js +544 -29
  96. package/dist/clients/tree-sitter-symbol-extractor.js +12 -5
  97. package/dist/clients/typos-config.js +2 -11
  98. package/dist/clients/widget-state.js +83 -1
  99. package/dist/clients/write-ordering-guard.js +58 -0
  100. package/dist/clients/zizmor-config.js +29 -10
  101. package/dist/index.js +25511 -19414
  102. package/dist/mcp/server.js +95 -19
  103. package/dist/tools/activate-tools.js +72 -0
  104. package/dist/tools/ast-dump.js +2 -8
  105. package/dist/tools/lens-diagnostic-mark.js +158 -0
  106. package/dist/tools/lens-diagnostics.js +327 -33
  107. package/dist/tools/lsp-diagnostics.js +525 -105
  108. package/dist/tools/module-report.js +18 -5
  109. package/docs/agent-tools.md +136 -0
  110. package/docs/ast-grep_rules_catalog.md +564 -0
  111. package/docs/astplayground.md +178 -0
  112. package/docs/audit1.md +177 -0
  113. package/docs/custom-rules.md +231 -0
  114. package/docs/dependencies.md +54 -0
  115. package/docs/dispositions.md +85 -0
  116. package/docs/env_variables.md +10 -0
  117. package/docs/environment-variables.md +83 -0
  118. package/docs/fable.md +140 -0
  119. package/docs/features.md +338 -0
  120. package/docs/globalconfig.md +79 -0
  121. package/docs/language-coverage.md +48 -0
  122. package/docs/lsp-capability-matrix.md +157 -0
  123. package/docs/lsp-latency-benchmark.md +169 -0
  124. package/docs/mcp.md +438 -0
  125. package/docs/module-report-read-symbol.md +258 -0
  126. package/docs/servercapabilities.md +118 -0
  127. package/docs/subagent-compat.md +281 -0
  128. package/docs/tools.md +16 -0
  129. package/docs/tools_improvement2.md +345 -0
  130. package/docs/tree-sitter_rules_catalog.md +296 -0
  131. package/docs/usage.md +129 -0
  132. package/docs/word-index.md +150 -0
  133. package/package.json +10 -1
  134. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  135. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  136. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  137. package/rules/ast-grep-rules/rules/array-callback-return-js.yml +1 -1
  138. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  139. package/rules/ast-grep-rules/rules/jwt-no-verify-js.yml +1 -1
  140. package/rules/ast-grep-rules/rules/no-extra-boolean-cast-js.yml +1 -1
  141. package/rules/ast-grep-rules/rules/no-implied-eval-js.yml +1 -1
  142. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +13 -1
  143. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  144. package/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +1 -1
  145. package/rules/ast-grep-rules/rules/weak-rsa-key-js.yml +1 -1
  146. package/skills/pi-lens-ast-grep/SKILL.md +3 -1
  147. package/dist/clients/production-readiness.js +0 -493
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Agent+user disposition layer over dispatch diagnostics (#690, unifying #181/
3
+ * #503/#504's discussion). Four dispositions:
4
+ *
5
+ * false-positive — the rule misfired. Project-persistent, routed nowhere
6
+ * special yet (telemetry hookup is a fast-follow).
7
+ * suppress — real finding, deliberate policy not to fix. Persistent,
8
+ * but the mechanism is an inline `pi-lens-ignore` comment
9
+ * written into the source (see suppress-writer.ts), not
10
+ * just a store entry — portable, git-visible, discoverable
11
+ * without pi-lens's own store. The store entry here is an
12
+ * audit-trail mirror, not the enforcement point.
13
+ * defer — fix later, not now. Session-ephemeral: held in memory
14
+ * only, so it naturally resurfaces on process restart —
15
+ * never persisted, never needs pruning.
16
+ * flagged — user wants the agent to fix this. Persistent until
17
+ * resolved; surfaced through the existing lens_diagnostics
18
+ * query (tagged), not a separate file/tool the agent has
19
+ * to separately poll.
20
+ *
21
+ * Anchoring: TWO flavors, chosen per-disposition because each one binds to a
22
+ * different thing conceptually:
23
+ *
24
+ * STRICT ("dd:" prefix) — relativeFile|tool|rule|normalizedMessage|
25
+ * lineContentHash(diagnostic's own line). Used ONLY for false-positive: a
26
+ * false-positive judgment is about THIS specific piece of code — if the
27
+ * line is rewritten, the rule earned a fresh chance to fire on the new
28
+ * content, so the mark should NOT follow it. Reuses read-guard's
29
+ * lineContentHash so a no-op formatter/whitespace pass doesn't rot the
30
+ * anchor, while a semantic edit to the flagged line correctly invalidates
31
+ * it.
32
+ * WEAK ("ddw:" prefix) — relativeFile|tool|rule|normalizedMessage, no line
33
+ * hash at all. Used for defer, flagged, and suppress: these are
34
+ * intent-level judgments ("I'll get to this", "fix this", "policy says
35
+ * don't") about a finding identity, not about one exact line's bytes —
36
+ * they must survive incidental edits elsewhere on the flagged line
37
+ * (reformatting, a nearby rename) without silently dropping the mark.
38
+ * suppress's real enforcement is the inline comment (see
39
+ * suppress-writer.ts) which travels with the code by construction; the
40
+ * weak-anchored store entry is just an audit mirror plus a second,
41
+ * belt-and-braces filter.
42
+ *
43
+ * Distinct prefixes ("dd:" vs "ddw:") keep the two id spaces from ever
44
+ * colliding in the same store.
45
+ *
46
+ * Content is hashed only from the diagnostic's own line (for the strict
47
+ * anchor), not a surrounding window as #181's original sketch considered.
48
+ * Two diagnostics on the same file/tool/rule/message whose flagged line
49
+ * happens to have identical content collide on the SAME strict anchor —
50
+ * deliberately: identical content at the same rule/message is a semantically
51
+ * equivalent finding, so marking one intentionally marks all of them (e.g. a
52
+ * copy-pasted line repeated a few times in the same file). If that
53
+ * assumption proves wrong in practice, a surrounding-window hash can be
54
+ * layered on later without changing the store shape.
55
+ */
56
+ import { createHash } from "node:crypto";
57
+ import * as fs from "node:fs";
58
+ import * as path from "node:path";
59
+ import { logDispositionEvent } from "./disposition-logger.js";
60
+ import { publishDisposition } from "./disposition-publish.js";
61
+ import { getProjectDataDir } from "./file-utils.js";
62
+ import { normalizeMapKey } from "./path-utils.js";
63
+ import { lineContentHash } from "./read-guard.js";
64
+ // "defer" is session-ephemeral by design (#690) — held only in memory so it
65
+ // resurfaces for free on the next process run, with no expiry/pruning logic
66
+ // needed. Stores WEAK anchors (see module doc) so a deferred finding stays
67
+ // hidden all session even if the flagged line itself is edited.
68
+ const deferredThisSession = new Set();
69
+ function normalizeMessage(message) {
70
+ return message.replace(/\s+/g, " ").trim().toLowerCase();
71
+ }
72
+ function relativeFile(filePath, cwd) {
73
+ const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
74
+ return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath);
75
+ }
76
+ /** Site-specific anchor — see module doc. Used only for false-positive. */
77
+ export function computeStrictAnchor(args) {
78
+ const lines = args.content?.split(/\r?\n/);
79
+ const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
80
+ const parts = [
81
+ relativeFile(args.filePath, args.cwd),
82
+ args.tool ?? "",
83
+ args.rule ?? "",
84
+ normalizeMessage(args.message),
85
+ lineContentHash(lineText),
86
+ ];
87
+ return `dd:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
88
+ }
89
+ /** Intent-level anchor — see module doc. Used for defer/flagged/suppress. */
90
+ export function computeWeakAnchor(args) {
91
+ const parts = [
92
+ relativeFile(args.filePath, args.cwd),
93
+ args.tool ?? "",
94
+ args.rule ?? "",
95
+ normalizeMessage(args.message),
96
+ ];
97
+ return `ddw:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
98
+ }
99
+ /** Both anchors a stored/filtered diagnostic would compute — the one shared
100
+ * derivation both the dispatch-pipeline filter and lens-diagnostics' flagged
101
+ * tag lookup must use so a mark and a fresh diagnostic converge on the same
102
+ * ids. */
103
+ export function anchorsForDiagnostic(cwd, filePath, diagnostic, content) {
104
+ const args = {
105
+ cwd,
106
+ filePath,
107
+ tool: diagnostic.tool,
108
+ rule: diagnostic.rule,
109
+ message: diagnostic.message,
110
+ line: diagnostic.line,
111
+ content,
112
+ };
113
+ return { strict: computeStrictAnchor(args), weak: computeWeakAnchor(args) };
114
+ }
115
+ function statePath(cwd) {
116
+ return path.join(getProjectDataDir(cwd), "cache", "diagnostic-dispositions.json");
117
+ }
118
+ let stateCache = null;
119
+ function readState(cwd) {
120
+ const p = statePath(cwd);
121
+ let stat;
122
+ try {
123
+ stat = fs.statSync(p);
124
+ }
125
+ catch {
126
+ if (stateCache && stateCache.path === p && stateCache.missing) {
127
+ return stateCache.state;
128
+ }
129
+ const empty = {};
130
+ stateCache = { path: p, missing: true, mtimeMs: -1, size: -1, state: empty };
131
+ return empty;
132
+ }
133
+ if (stateCache &&
134
+ stateCache.path === p &&
135
+ !stateCache.missing &&
136
+ stateCache.mtimeMs === stat.mtimeMs &&
137
+ stateCache.size === stat.size) {
138
+ return stateCache.state;
139
+ }
140
+ let state;
141
+ try {
142
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
143
+ state =
144
+ parsed && typeof parsed === "object" ? parsed : {};
145
+ }
146
+ catch {
147
+ state = {};
148
+ }
149
+ stateCache = {
150
+ path: p,
151
+ missing: false,
152
+ mtimeMs: stat.mtimeMs,
153
+ size: stat.size,
154
+ state,
155
+ };
156
+ return state;
157
+ }
158
+ function writeState(cwd, state) {
159
+ const p = statePath(cwd);
160
+ fs.mkdirSync(path.dirname(p), { recursive: true });
161
+ fs.writeFileSync(p, JSON.stringify(state, null, 2));
162
+ // Refresh the cache from the write we just did instead of invalidating it —
163
+ // avoids an immediate re-stat+re-parse of the file we already have in hand,
164
+ // and guards against coarse filesystem mtime granularity making a
165
+ // read-immediately-after-write look like a cache hit on stale data.
166
+ const stat = fs.statSync(p);
167
+ stateCache = {
168
+ path: p,
169
+ missing: false,
170
+ mtimeMs: stat.mtimeMs,
171
+ size: stat.size,
172
+ state,
173
+ };
174
+ }
175
+ /** Test-only escape hatch — the state cache is module-level, so tests that
176
+ * write the store file out-of-band (or across separate cwds sharing a stat
177
+ * coincidence) need to reset it between cases. */
178
+ export function _resetStateCacheForTests() {
179
+ stateCache = null;
180
+ }
181
+ /** Fire-and-forget mark telemetry (see markDisposition's doc): the NDJSON log
182
+ * entry (project-relative path — rule-tuning data, not machine layout) and
183
+ * the bus event (absolute normalized path — in-process consumers navigate).
184
+ * Neither can throw into the mark path; both are already internally
185
+ * fail-safe, but the try/catch keeps a future regression in either from
186
+ * breaking a mark. */
187
+ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
188
+ try {
189
+ logDispositionEvent({
190
+ event: "mark",
191
+ disposition,
192
+ tool: target.tool,
193
+ rule: target.rule,
194
+ filePath: relativeFile(target.filePath, cwd),
195
+ line: target.line,
196
+ reason,
197
+ anchor,
198
+ previousDisposition: existing?.disposition,
199
+ });
200
+ publishDisposition({
201
+ cwd,
202
+ filePath: target.filePath,
203
+ disposition,
204
+ tool: target.tool,
205
+ rule: target.rule,
206
+ line: target.line,
207
+ anchor,
208
+ reason,
209
+ });
210
+ }
211
+ catch {
212
+ // never let telemetry break a mark
213
+ }
214
+ }
215
+ /**
216
+ * Record a disposition. Picks the anchor flavor per-disposition (see module
217
+ * doc): strict for false-positive, weak for everything else. Returns the
218
+ * anchor actually used, so callers (the mark tool) can report/verify it.
219
+ *
220
+ * This is THE single choke point for mark telemetry — the NDJSON log
221
+ * (disposition-logger.ts, #181's FP-rule-tuning signal) and the
222
+ * `pilens:diagnostic:disposition` bus event (disposition-publish.ts) both
223
+ * hang off it, so the agent tool and any future UI caller are covered without
224
+ * per-caller wiring.
225
+ */
226
+ export function markDisposition(cwd, target, disposition, reason) {
227
+ const anchor = disposition === "false-positive"
228
+ ? computeStrictAnchor(target)
229
+ : computeWeakAnchor(target);
230
+ // Captured for BOTH branches: a defer never writes the store, but a store
231
+ // entry can already exist at the same weak anchor (a prior flagged/suppress
232
+ // mark) — the log should record what this mark shadowed either way.
233
+ const existing = readState(cwd).dispositions?.[anchor];
234
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
235
+ if (disposition === "defer") {
236
+ deferredThisSession.add(anchor);
237
+ return anchor;
238
+ }
239
+ const state = readState(cwd);
240
+ state.dispositions ??= {};
241
+ const now = new Date().toISOString();
242
+ const capturesFixContext = disposition === "flagged";
243
+ const lineText = capturesFixContext
244
+ ? (target.content?.split(/\r?\n/)[target.line !== undefined ? target.line - 1 : -1] ?? existing?.lineText)?.trim()
245
+ : existing?.lineText;
246
+ state.dispositions[anchor] = {
247
+ disposition,
248
+ reason: reason ?? existing?.reason,
249
+ createdAt: existing?.createdAt ?? now,
250
+ lastSeenAt: now,
251
+ line: capturesFixContext ? (target.line ?? existing?.line) : existing?.line,
252
+ lineText,
253
+ };
254
+ writeState(cwd, state);
255
+ return anchor;
256
+ }
257
+ export function getDisposition(cwd, anchor) {
258
+ return readState(cwd).dispositions?.[anchor];
259
+ }
260
+ export function isDeferredThisSession(anchor) {
261
+ return deferredThisSession.has(anchor);
262
+ }
263
+ /** Test-only escape hatch — defer state is module-level (one process = one
264
+ * session), so tests need to reset it between cases. */
265
+ export function _resetDeferredForTests() {
266
+ deferredThisSession.clear();
267
+ }
268
+ /**
269
+ * Drop diagnostics disposed false-positive/suppress, or deferred this session,
270
+ * from `diagnostics`. `flagged` diagnostics are kept as-is — callers that want
271
+ * to surface the flag (e.g. lens_diagnostics' rendering) look it up separately
272
+ * via getDisposition on the WEAK anchor (anchorsForDiagnostic(...).weak).
273
+ *
274
+ * Computes both anchors per diagnostic (cheap — same hash primitive, twice)
275
+ * since false-positive is keyed strict while defer/suppress are keyed weak;
276
+ * see module doc for why each disposition binds the way it does.
277
+ */
278
+ export function applyDispositions(diagnostics, cwd, filePath, content) {
279
+ if (!diagnostics.length)
280
+ return diagnostics;
281
+ const dispositions = readState(cwd).dispositions;
282
+ if (!dispositions && deferredThisSession.size === 0)
283
+ return diagnostics;
284
+ return diagnostics.filter((d) => {
285
+ const { strict, weak } = anchorsForDiagnostic(cwd, filePath, d, content);
286
+ if (deferredThisSession.has(weak))
287
+ return false;
288
+ if (dispositions?.[strict]?.disposition === "false-positive")
289
+ return false;
290
+ // Belt-and-braces: the inline `pi-lens-ignore` comment is the real
291
+ // suppress enforcement (see suppress-writer.ts) and normally already
292
+ // dropped this finding upstream via applyInlineSuppressions. This is a
293
+ // harmless second cover for the store-only audit trail case.
294
+ if (dispositions?.[weak]?.disposition === "suppress")
295
+ return false;
296
+ return true;
297
+ });
298
+ }
@@ -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,8 @@
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";
20
+ import { isSubagentSession } from "../subagent-mode.js";
19
21
  import { findLocalOpengrepConfig } from "../opengrep-config.js";
20
22
  import { findLocalTyposConfig } from "../typos-config.js";
21
23
  import { findLocalZizmorConfig } from "../zizmor-config.js";
@@ -56,6 +58,11 @@ export function isNosemgrepSuppressed(d, content) {
56
58
  return (checkLine(lines[startLine], false) ||
57
59
  checkLine(lines[startLine - 1], true));
58
60
  }
61
+ // #277 R7: every profile below used this exact same rule (ERROR-severity
62
+ // findings block only when the workspace opted into curated/authored rules;
63
+ // everything else stays advisory) — shared here instead of copy-pasted per
64
+ // profile so a future policy change (e.g. a WARNING-blocking tier) is one edit.
65
+ const blockOnErrorWhenAllowed = (d, { blockingAllowed }) => (blockingAllowed && d.severity === 1 ? "blocking" : "warning");
59
66
  export const AUXILIARY_LSP_PROFILES = [
60
67
  {
61
68
  serverId: "opengrep",
@@ -71,7 +78,7 @@ export const AUXILIARY_LSP_PROFILES = [
71
78
  // supplies its own curated rules (the author's deliberate severity); the
72
79
  // auto set is advisory. Either way, findings surface via lens_diagnostics.
73
80
  allowBlocking: (cwd) => Boolean(findLocalOpengrepConfig(cwd)),
74
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
81
+ semantic: blockOnErrorWhenAllowed,
75
82
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "opengrep", d.message ?? ""),
76
83
  // Honor the canonical Semgrep suppression the user already knows (#441).
77
84
  isSuppressed: isNosemgrepSuppressed,
@@ -83,12 +90,17 @@ export const AUXILIARY_LSP_PROFILES = [
83
90
  sourceMatch: /ast[-_]?grep/i,
84
91
  killSwitchFlag: "no-ast-grep",
85
92
  enabledByDefault: true,
93
+ // Matches the in-process ast-grep-napi runner's own `skipTestFiles: true`
94
+ // (#687) — that flag stops applying the moment the ast-grep binary is
95
+ // present, since the napi runner then skips entirely in favor of this
96
+ // LSP surface, which had no test-file gating of its own.
97
+ skipTestFiles: true,
86
98
  // The ast-grep LSP runs either the repo's own sgconfig (when present) or
87
99
  // pi-lens's shipped baseline sgconfig. In both cases the rule severity is
88
100
  // deliberate, so preserve ast-grep's severity semantics: ERROR can block,
89
101
  // WARNING/INFO stay advisory.
90
102
  allowBlocking: () => true,
91
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
103
+ semantic: blockOnErrorWhenAllowed,
92
104
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "ast-grep", d.message ?? ""),
93
105
  },
94
106
  {
@@ -104,7 +116,7 @@ export const AUXILIARY_LSP_PROFILES = [
104
116
  // ignores). Advisory otherwise — findings still surface via lens_diagnostics.
105
117
  // zizmor maps High→ERROR(1), Medium/Low→WARNING(2), Informational→INFO(3).
106
118
  allowBlocking: (cwd) => Boolean(findLocalZizmorConfig(cwd)),
107
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
119
+ semantic: blockOnErrorWhenAllowed,
108
120
  defectClass: (d) => classifyDefect(String(d.code ?? ""), "zizmor", d.message ?? ""),
109
121
  },
110
122
  {
@@ -122,23 +134,149 @@ export const AUXILIARY_LSP_PROFILES = [
122
134
  // severity is WARNING, so even with a config it stays advisory unless the
123
135
  // repo raises `diagnostic-severity` to Error.
124
136
  allowBlocking: (cwd) => Boolean(findLocalTyposConfig(cwd)),
125
- semantic: (d, { blockingAllowed }) => blockingAllowed && d.severity === 1 ? "blocking" : "warning",
137
+ semantic: blockOnErrorWhenAllowed,
126
138
  // A misspelling is a documentation/quality defect — not security or
127
139
  // correctness. "style" is the closest taxonomy class.
128
140
  defectClass: () => "style",
129
141
  },
130
142
  ];
131
143
  /** 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). */
144
+ * to `touchFile` since it — not the LSP service — owns flag access).
145
+ *
146
+ * #449 slice 2 (prototype): when this process decided at `session_start` that
147
+ * the machine-wide LSP budget is exceeded (`clients/lsp-budget.ts`), auxiliary
148
+ * servers are skipped entirely for the rest of the session — the primary
149
+ * language server per file is unaffected. This is a per-SESSION degrade, not
150
+ * per-file: once over budget, this session never spawns its auxiliary fleet,
151
+ * rather than flip-flopping file to file. */
133
152
  export function enabledAuxiliaryLspServerIds(getFlag) {
153
+ // #449 slice 2 (budget): skip auxiliaries when machine-wide LSP budget is
154
+ // exceeded. #713 (subagent light mode): reuse the same seam — a subagent
155
+ // session also skips auxiliaries; the parent session already runs them on
156
+ // the same cwd. PI_LENS_SUBAGENT_FULL=1 restores full behavior via
157
+ // isSubagentSession() returning false.
158
+ if (shouldDegradeAuxiliaryLsp() || isSubagentSession())
159
+ return [];
134
160
  return AUXILIARY_LSP_PROFILES.flatMap((p) => p.enabledByDefault &&
135
161
  !(p.killSwitchFlag && getFlag(p.killSwitchFlag) === true)
136
162
  ? [p.serverId]
137
163
  : []);
138
164
  }
165
+ // #277 R7: `findAuxiliaryProfileForSource` is called once per diagnostic, and a
166
+ // file's diagnostics are typically dominated by a handful of distinct `source`
167
+ // strings (one per tool that fired). Memoizing by exact `source` turns an
168
+ // O(profiles) regex scan per diagnostic into one scan per distinct source seen
169
+ // — safe because `AUXILIARY_LSP_PROFILES` is a fixed module-level const, never
170
+ // mutated at runtime, so a source's matching profile never changes.
171
+ const profileForSourceCache = new Map();
139
172
  /** Find the profile whose server emitted a diagnostic with this `source`. */
140
173
  export function findAuxiliaryProfileForSource(source) {
141
174
  if (!source)
142
175
  return undefined;
143
- return AUXILIARY_LSP_PROFILES.find((p) => p.sourceMatch.test(source));
176
+ const cached = profileForSourceCache.get(source);
177
+ if (cached !== undefined || profileForSourceCache.has(source))
178
+ return cached;
179
+ const found = AUXILIARY_LSP_PROFILES.find((p) => p.sourceMatch.test(source));
180
+ profileForSourceCache.set(source, found);
181
+ return found;
182
+ }
183
+ /**
184
+ * Single-diagnostic suppression check (#586): look up the diagnostic's
185
+ * auxiliary profile by `source` and, if that profile declares an
186
+ * `isSuppressed` callback (currently only opengrep's `# nosemgrep`, #441),
187
+ * apply it. Returns false for diagnostics with no matching profile or whose
188
+ * profile has no native suppression syntax — the common case for plain
189
+ * language-server diagnostics.
190
+ *
191
+ * This is the ONE lookup+apply implementation; every call site that decides
192
+ * whether to drop a diagnostic for its tool's own inline suppression comment
193
+ * should go through this (or `applyAuxiliarySuppressions` below) rather than
194
+ * re-deriving the profile lookup.
195
+ */
196
+ export function isAuxiliaryDiagnosticSuppressed(d, content) {
197
+ const profile = findAuxiliaryProfileForSource(d.source);
198
+ return Boolean(profile?.isSuppressed?.(d, content));
199
+ }
200
+ /**
201
+ * Filter a diagnostic list down to the ones NOT suppressed by their
202
+ * auxiliary profile's native inline-comment syntax (#586). This is the
203
+ * shared helper `tools/lsp-diagnostics.ts` and `clients/lsp/index.ts`'s
204
+ * `runWorkspaceDiagnostics` use so a `// nosemgrep` (or any future profile's
205
+ * equivalent) suppresses a finding identically whether it's seen via the
206
+ * per-edit dispatch runner or a standalone diagnostics query — previously
207
+ * only the former honored it (#586).
208
+ *
209
+ * #692: `opts.fileRole` additionally drops a diagnostic whose auxiliary
210
+ * profile declares `skipTestFiles` (e.g. ast-grep, #687) when the file is a
211
+ * test file — the per-edit dispatch runner (`clients/dispatch/runners/lsp.ts`)
212
+ * has applied this gate since #687/#688 via its own inline check; the
213
+ * `runWorkspaceDiagnostics` workspace sweep (`clients/lsp/index.ts`) called
214
+ * this function WITHOUT ever passing a fileRole, so ast-grep findings on test
215
+ * files that are suppressed per-edit reappeared wholesale in every
216
+ * `mode=full` sweep. Omitting `opts` (or `opts.fileRole`) keeps every existing
217
+ * 2-arg call site's behavior byte-for-byte unchanged.
218
+ */
219
+ export function applyAuxiliarySuppressions(diagnostics, content, opts) {
220
+ return diagnostics.filter((d) => {
221
+ if (isAuxiliaryDiagnosticSuppressed(d, content))
222
+ return false;
223
+ if (opts?.fileRole === "test") {
224
+ const profile = findAuxiliaryProfileForSource(d.source);
225
+ if (profile?.skipTestFiles)
226
+ return false;
227
+ }
228
+ return true;
229
+ });
230
+ }
231
+ /**
232
+ * #692: shared aux re-tag implementation — extracted from the per-edit
233
+ * dispatch runner (`clients/dispatch/runners/lsp.ts`) so a scan/sweep path
234
+ * that reconciles LSP diagnostics into widget state (`lens_diagnostics
235
+ * mode=full`, `lsp_diagnostics`) gives its auxiliary-sourced findings
236
+ * (ast-grep, opengrep, zizmor, typos) the SAME tool re-tag, semantic policy,
237
+ * and defect-class classification the per-edit path always has — previously
238
+ * only the per-edit runner ran this loop, so a scan-reconciled aux finding
239
+ * kept `tool: "lsp"` and lost its curated-repo-rules `blockingAllowed`
240
+ * context entirely (#692).
241
+ *
242
+ * `diagnostics` and `rawLspDiags` must be the SAME length and index-aligned —
243
+ * `convertLspDiagnostics` maps its input 1:1, so this holds for every caller
244
+ * that passes it the same array it just converted. Mutates and returns the
245
+ * SURVIVING subset of `diagnostics` (native-inline-suppressed and
246
+ * `skipTestFiles`-dropped entries removed), mirroring the per-edit runner's
247
+ * prior inline behavior exactly.
248
+ */
249
+ export function retagAuxiliaryDiagnostics(diagnostics, rawLspDiags, content, ctx) {
250
+ const blockingAllowedByProfile = new Map();
251
+ const suppressedIndices = new Set();
252
+ for (let i = 0; i < diagnostics.length; i++) {
253
+ const profile = findAuxiliaryProfileForSource(rawLspDiags[i]?.source);
254
+ if (!profile)
255
+ continue;
256
+ if (profile.skipTestFiles && ctx.fileRole === "test") {
257
+ suppressedIndices.add(i);
258
+ continue;
259
+ }
260
+ if (isAuxiliaryDiagnosticSuppressed(rawLspDiags[i], content)) {
261
+ suppressedIndices.add(i);
262
+ continue;
263
+ }
264
+ let blockingAllowed = blockingAllowedByProfile.get(profile);
265
+ if (blockingAllowed === undefined) {
266
+ blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
267
+ blockingAllowedByProfile.set(profile, blockingAllowed);
268
+ }
269
+ const d = diagnostics[i];
270
+ d.tool = profile.tool;
271
+ d.semantic = profile.semantic(rawLspDiags[i], { blockingAllowed });
272
+ if (d.semantic !== "blocking" && d.severity === "error") {
273
+ d.severity = "warning";
274
+ }
275
+ const defectClass = profile.defectClass?.(rawLspDiags[i]);
276
+ if (defectClass)
277
+ d.defectClass = defectClass;
278
+ }
279
+ return suppressedIndices.size
280
+ ? diagnostics.filter((_, i) => !suppressedIndices.has(i))
281
+ : diagnostics;
144
282
  }
@@ -27,6 +27,7 @@ import { loadPiLensProjectConfig } from "../project-lens-config.js";
27
27
  import { RUNTIME_CONFIG, getRunnerTimeoutFloorMs } from "../runtime-config.js";
28
28
  import { safeSpawnAsync } from "../safe-spawn.js";
29
29
  import { classifyDiagnostic } from "./diagnostic-taxonomy.js";
30
+ import { applyDispositions } from "../diagnostic-dispositions.js";
30
31
  import { applyInlineSuppressions } from "./inline-suppressions.js";
31
32
  import { getToolPlan } from "./plan.js";
32
33
  import { resolveRunnerPath } from "./runner-context.js";
@@ -559,7 +560,11 @@ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
559
560
  const overlapSuppressed = suppressLintOverlapsWithLsp(dockerOverlapSuppressed);
560
561
  const fileContent = ctx.facts.getFileFact(ctx.filePath, "file.content") ?? "";
561
562
  const inlineSuppressed = applyInlineSuppressions(overlapSuppressed, fileContent);
562
- let visibleDiagnostics = inlineSuppressed;
563
+ // #690: agent/user disposition layer — drop false-positive/suppress marks
564
+ // and anything deferred this session. flagged marks are left in place;
565
+ // lens_diagnostics tags them at render time via the same anchor.
566
+ const dispositionFiltered = applyDispositions(inlineSuppressed, ctx.cwd, ctx.filePath, fileContent);
567
+ let visibleDiagnostics = dispositionFiltered;
563
568
  let resolvedCount = 0;
564
569
  if (ctx.deltaMode && previousBaseline) {
565
570
  const filtered = filterDelta(visibleDiagnostics, previousBaseline, (d) => d.id);