pi-lens 3.8.70 → 3.8.72

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 (139) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +24 -0
  3. package/config/markdownlint/core.json +6 -0
  4. package/dist/clients/atomic-write.js +83 -0
  5. package/dist/clients/bus-events-logger.js +10 -8
  6. package/dist/clients/cache/rule-cache.js +9 -5
  7. package/dist/clients/cache-manager.js +19 -13
  8. package/dist/clients/config-enabled-shape.js +39 -0
  9. package/dist/clients/diagnostic-dispositions.js +360 -0
  10. package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
  11. package/dist/clients/dispatch/dispatcher.js +6 -1
  12. package/dist/clients/dispatch/integration.js +21 -5
  13. package/dist/clients/dispatch/runners/lsp.js +59 -57
  14. package/dist/clients/dispatch/runners/markdownlint.js +7 -4
  15. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  16. package/dist/clients/dispatch/runners/pyright.js +1 -0
  17. package/dist/clients/dispatch/runners/tree-sitter.js +1 -1
  18. package/dist/clients/dispatch/suppress-writer.js +68 -0
  19. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  20. package/dist/clients/disposition-logger.js +39 -0
  21. package/dist/clients/disposition-publish.js +109 -0
  22. package/dist/clients/env-utils.js +38 -0
  23. package/dist/clients/file-utils.js +115 -36
  24. package/dist/clients/format-events-publish.js +299 -0
  25. package/dist/clients/git-tracked-ignore.js +208 -0
  26. package/dist/clients/instance-registry.js +94 -24
  27. package/dist/clients/jscpd-client.js +52 -39
  28. package/dist/clients/json-cache-read.js +65 -0
  29. package/dist/clients/language-profile.js +16 -19
  30. package/dist/clients/lens-config.js +106 -27
  31. package/dist/clients/lens-engine.js +129 -3
  32. package/dist/clients/lens-map.js +1079 -0
  33. package/dist/clients/lsp/aggregation.js +69 -2
  34. package/dist/clients/lsp/cascade-tier.js +1 -38
  35. package/dist/clients/lsp/client.js +75 -8
  36. package/dist/clients/lsp/index.js +1093 -95
  37. package/dist/clients/lsp/server-strategies.js +3 -224
  38. package/dist/clients/lsp/server.js +18 -2
  39. package/dist/clients/lsp/tsserver-sync.js +145 -0
  40. package/dist/clients/lsp/wait-policy/capability-snapshot.js +1 -0
  41. package/dist/clients/lsp/wait-policy/classification.js +63 -0
  42. package/dist/clients/lsp/wait-policy/index.js +10 -0
  43. package/dist/clients/lsp/wait-policy/strategies.js +244 -0
  44. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  45. package/dist/clients/lsp-budget.js +54 -16
  46. package/dist/clients/mcp/analyze.js +1 -1
  47. package/dist/clients/mcp/host-shim.js +12 -8
  48. package/dist/clients/mcp/ipc.js +118 -0
  49. package/dist/clients/mcp/session.js +10 -2
  50. package/dist/clients/opengrep-config.js +2 -10
  51. package/dist/clients/path-utils.js +26 -0
  52. package/dist/clients/pipeline.js +15 -6
  53. package/dist/clients/project-diagnostics/cache.js +5 -12
  54. package/dist/clients/project-diagnostics/fresh-fetch.js +20 -1
  55. package/dist/clients/project-diagnostics/scanner.js +37 -6
  56. package/dist/clients/project-lens-config.js +167 -3
  57. package/dist/clients/project-report.js +627 -0
  58. package/dist/clients/project-scale.js +239 -0
  59. package/dist/clients/project-scan-policy.js +11 -1
  60. package/dist/clients/project-snapshot.js +3 -6
  61. package/dist/clients/read-guard.js +1 -1
  62. package/dist/clients/recent-touches.js +6 -11
  63. package/dist/clients/review-graph/builder.js +247 -64
  64. package/dist/clients/review-graph/import-resolvers.js +191 -21
  65. package/dist/clients/review-graph/tsconfig-paths.js +242 -0
  66. package/dist/clients/review-graph/workspace-modules.js +21 -9
  67. package/dist/clients/rules-scanner.js +15 -2
  68. package/dist/clients/runtime-agent-end.js +127 -14
  69. package/dist/clients/runtime-config.js +4 -0
  70. package/dist/clients/runtime-coordinator.js +59 -2
  71. package/dist/clients/runtime-session.js +93 -13
  72. package/dist/clients/runtime-tool-call.js +789 -0
  73. package/dist/clients/runtime-tool-result.js +18 -4
  74. package/dist/clients/runtime-turn.js +12 -2
  75. package/dist/clients/safe-spawn.js +264 -40
  76. package/dist/clients/session-lifecycle.js +36 -0
  77. package/dist/clients/session-state-store.js +14 -12
  78. package/dist/clients/sgconfig.js +2 -9
  79. package/dist/clients/source-filter.js +136 -56
  80. package/dist/clients/source-walker.js +89 -0
  81. package/dist/clients/startup-scan.js +229 -84
  82. package/dist/clients/subagent-mode.js +14 -3
  83. package/dist/clients/todo-scanner.js +5 -1
  84. package/dist/clients/tool-policy.js +26 -4
  85. package/dist/clients/typos-config.js +2 -11
  86. package/dist/clients/warm-attach.js +204 -0
  87. package/dist/clients/widget-state.js +22 -0
  88. package/dist/clients/word-index.js +36 -5
  89. package/dist/clients/workspace-topology.js +266 -0
  90. package/dist/clients/zizmor-config.js +2 -10
  91. package/dist/index.js +35072 -29093
  92. package/dist/mcp/server.js +125 -10
  93. package/dist/tools/ast-grep-search.js +21 -0
  94. package/dist/tools/lens-diagnostic-mark.js +349 -0
  95. package/dist/tools/lens-diagnostics.js +159 -27
  96. package/dist/tools/lsp-diagnostics.js +116 -120
  97. package/dist/tools/lsp-navigation.js +11 -2
  98. package/dist/tools/project-report.js +94 -0
  99. package/dist/tools/symbol-search.js +32 -8
  100. package/docs/agent-tools.md +11 -4
  101. package/docs/dispositions.md +85 -0
  102. package/docs/environment-variables.md +26 -0
  103. package/docs/fable.md +10 -9
  104. package/docs/features.md +7 -1
  105. package/docs/globalconfig.md +70 -0
  106. package/docs/servercapabilities.md +1 -1
  107. package/docs/tools.md +3 -0
  108. package/docs/usage.md +13 -2
  109. package/package.json +6 -1
  110. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  111. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  112. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  113. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  114. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
  115. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  116. package/scripts/download-grammars.js +1 -0
  117. package/docs/agent-tooling-improvement-plan.md +0 -660
  118. package/docs/inspiration_gograph.md +0 -601
  119. package/docs/inspiration_neovim.md +0 -1795
  120. package/docs/inspiration_neovim2.md +0 -402
  121. package/docs/inspiration_tree_sitter_rules.md +0 -369
  122. package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
  123. package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
  124. package/docs/lsp-bridge-analysis.md +0 -444
  125. package/docs/newinspiration.md +0 -338
  126. package/grammars/tree-sitter-c.wasm +0 -0
  127. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  128. package/grammars/tree-sitter-cpp.wasm +0 -0
  129. package/grammars/tree-sitter-dart.wasm +0 -0
  130. package/grammars/tree-sitter-elixir.wasm +0 -0
  131. package/grammars/tree-sitter-kotlin.wasm +0 -0
  132. package/grammars/tree-sitter-lua.wasm +0 -0
  133. package/grammars/tree-sitter-ocaml.wasm +0 -0
  134. package/grammars/tree-sitter-php.wasm +0 -0
  135. package/grammars/tree-sitter-ruby.wasm +0 -0
  136. package/grammars/tree-sitter-swift.wasm +0 -0
  137. package/grammars/tree-sitter-toml.wasm +0 -0
  138. package/grammars/tree-sitter-vue.wasm +0 -0
  139. package/grammars/tree-sitter-zig.wasm +0 -0
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { getProjectDataDir } from "./file-utils.js";
14
+ import { readJsonCache } from "./json-cache-read.js";
14
15
  import { normalizeMapKey } from "./path-utils.js";
15
16
  // --- Defaults ---
16
17
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
@@ -72,13 +73,20 @@ export class CacheManager {
72
73
  return null;
73
74
  }
74
75
  try {
75
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
76
+ const onReadError = (err) => {
77
+ this.log(`Cache read error: ${scanner} — ${err}`);
78
+ };
79
+ const meta = readJsonCache(metaPath, (parsed) => parsed, onReadError);
80
+ if (meta === undefined)
81
+ return null;
76
82
  const age = Date.now() - new Date(meta.timestamp).getTime();
77
83
  if (age > maxAgeMs) {
78
84
  this.log(`Cache stale: ${scanner} (age: ${Math.round(age / 1000)}s, max: ${maxAgeMs / 1000}s)`);
79
85
  return null;
80
86
  }
81
- const data = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
87
+ const data = readJsonCache(cachePath, (parsed) => parsed, onReadError);
88
+ if (data === undefined)
89
+ return null;
82
90
  this.log(`Cache hit: ${scanner} (age: ${Math.round(age / 1000)}s)`);
83
91
  return { data, meta };
84
92
  }
@@ -111,7 +119,9 @@ export class CacheManager {
111
119
  if (!fs.existsSync(metaPath))
112
120
  return false;
113
121
  try {
114
- const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
122
+ const meta = readJsonCache(metaPath, (parsed) => parsed);
123
+ if (meta === undefined)
124
+ return false;
115
125
  const age = Date.now() - new Date(meta.timestamp).getTime();
116
126
  return age <= maxAgeMs;
117
127
  }
@@ -150,16 +160,12 @@ export class CacheManager {
150
160
  lastUpdated: new Date().toISOString(),
151
161
  };
152
162
  }
153
- try {
154
- return JSON.parse(fs.readFileSync(statePath, "utf-8"));
155
- }
156
- catch {
157
- return {
158
- ...DEFAULT_TURN_STATE,
159
- files: {},
160
- lastUpdated: new Date().toISOString(),
161
- };
162
- }
163
+ const state = readJsonCache(statePath, (parsed) => parsed);
164
+ return (state ?? {
165
+ ...DEFAULT_TURN_STATE,
166
+ files: {},
167
+ lastUpdated: new Date().toISOString(),
168
+ });
163
169
  }
164
170
  /**
165
171
  * Write turn state.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared parser for the `{ enabled?: boolean }` shape used by BOTH the global
3
+ * config (`clients/lens-config.ts`, `~/.pi-lens/config.json`) and the project
4
+ * config (`clients/project-lens-config.ts`, `.pi-lens.json`) for their
5
+ * mutation-control keys (`format`, `autofix`, `actionableWarnings.autoFix`).
6
+ *
7
+ * Extracted (#792) so both loaders parse and warn on the identical shape
8
+ * identically instead of two independent hand-rolled implementations that had
9
+ * diverged in strictness — project warned on invalid `enabled` values via its
10
+ * own `warnInvalidConfigOnce`, global silently dropped them. This module has
11
+ * no dependency on either config module (avoids an import cycle) — callers
12
+ * supply their own `warnInvalid` callback so each config keeps its own
13
+ * warn-once plumbing (keyed by its own config path).
14
+ */
15
+ /**
16
+ * Parse a raw `{ enabled?: boolean }` value.
17
+ *
18
+ * - `undefined` input → `undefined` (key absent from the config).
19
+ * - Non-object (or array) input → `warnInvalid` is called, returns `undefined`.
20
+ * - Object with no `enabled` key → `{}` (present but nothing to say).
21
+ * - `enabled` present but not a boolean → `warnInvalid` is called, returns `{}`.
22
+ * - `enabled` a boolean → `{ enabled }`.
23
+ */
24
+ export function parseEnabledShape(value, fieldPath, warnInvalid) {
25
+ if (value === undefined)
26
+ return undefined;
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
28
+ warnInvalid(`${fieldPath} must be an object`);
29
+ return undefined;
30
+ }
31
+ const raw = value;
32
+ if (!("enabled" in raw))
33
+ return {};
34
+ if (typeof raw.enabled !== "boolean") {
35
+ warnInvalid(`${fieldPath}.enabled must be a boolean`);
36
+ return {};
37
+ }
38
+ return { enabled: raw.enabled };
39
+ }
@@ -0,0 +1,360 @@
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 { writeFileAtomic } from "./atomic-write.js";
60
+ import { logDispositionEvent } from "./disposition-logger.js";
61
+ import { publishDisposition } from "./disposition-publish.js";
62
+ import { getProjectDataDir } from "./file-utils.js";
63
+ import { normalizeMapKey } from "./path-utils.js";
64
+ import { lineContentHash } from "./read-guard.js";
65
+ // "defer" is session-ephemeral by design (#690) — held only in memory so it
66
+ // resurfaces for free on the next process run, with no expiry/pruning logic
67
+ // needed. Stores WEAK anchors (see module doc) so a deferred finding stays
68
+ // hidden all session even if the flagged line itself is edited.
69
+ const deferredThisSession = new Set();
70
+ /** Exported (#802) so lens-diagnostic-mark's cross-check against live widget
71
+ * diagnostics matches a message the same way anchor derivation does — a
72
+ * second, slightly different normalizer would make a real match invisible. */
73
+ export function normalizeMessage(message) {
74
+ return message.replace(/\s+/g, " ").trim().toLowerCase();
75
+ }
76
+ function relativeFile(filePath, cwd) {
77
+ const rel = path.relative(cwd, filePath).replace(/\\/g, "/");
78
+ return rel && !rel.startsWith("..") ? rel : normalizeMapKey(filePath);
79
+ }
80
+ /** Site-specific anchor — see module doc. Used only for false-positive. */
81
+ export function computeStrictAnchor(args) {
82
+ const lines = args.content?.split(/\r?\n/);
83
+ const lineText = args.line !== undefined && lines ? (lines[args.line - 1] ?? "") : "";
84
+ const parts = [
85
+ relativeFile(args.filePath, args.cwd),
86
+ args.tool ?? "",
87
+ args.rule ?? "",
88
+ normalizeMessage(args.message),
89
+ lineContentHash(lineText),
90
+ ];
91
+ return `dd:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
92
+ }
93
+ /** Intent-level anchor — see module doc. Used for defer/flagged/suppress. */
94
+ export function computeWeakAnchor(args) {
95
+ const parts = [
96
+ relativeFile(args.filePath, args.cwd),
97
+ args.tool ?? "",
98
+ args.rule ?? "",
99
+ normalizeMessage(args.message),
100
+ ];
101
+ return `ddw:${createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 12)}`;
102
+ }
103
+ /** Both anchors a stored/filtered diagnostic would compute — the one shared
104
+ * derivation both the dispatch-pipeline filter and lens-diagnostics' flagged
105
+ * tag lookup must use so a mark and a fresh diagnostic converge on the same
106
+ * ids. */
107
+ export function anchorsForDiagnostic(cwd, filePath, diagnostic, content) {
108
+ const args = {
109
+ cwd,
110
+ filePath,
111
+ tool: diagnostic.tool,
112
+ rule: diagnostic.rule,
113
+ message: diagnostic.message,
114
+ line: diagnostic.line,
115
+ content,
116
+ };
117
+ return { strict: computeStrictAnchor(args), weak: computeWeakAnchor(args) };
118
+ }
119
+ function statePath(cwd) {
120
+ return path.join(getProjectDataDir(cwd), "cache", "diagnostic-dispositions.json");
121
+ }
122
+ let stateCache = null;
123
+ function readState(cwd) {
124
+ const p = statePath(cwd);
125
+ let stat;
126
+ try {
127
+ stat = fs.statSync(p);
128
+ }
129
+ catch {
130
+ if (stateCache && stateCache.path === p && stateCache.missing) {
131
+ return stateCache.state;
132
+ }
133
+ const empty = {};
134
+ stateCache = { path: p, missing: true, mtimeMs: -1, size: -1, state: empty };
135
+ return empty;
136
+ }
137
+ if (stateCache &&
138
+ stateCache.path === p &&
139
+ !stateCache.missing &&
140
+ stateCache.mtimeMs === stat.mtimeMs &&
141
+ stateCache.size === stat.size) {
142
+ return stateCache.state;
143
+ }
144
+ let state;
145
+ try {
146
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
147
+ state =
148
+ parsed && typeof parsed === "object" ? parsed : {};
149
+ }
150
+ catch {
151
+ // Now that writeState is tmp+rename atomic, a torn read (another process
152
+ // mid-write) can no longer land here — this only fires on genuine
153
+ // corruption/wrong-shape content. Caching `{}` against this stat is still
154
+ // correct, not a permanent trap: any future rewrite of the file (a fix,
155
+ // or this process's own next writeState) changes mtime/size, which
156
+ // invalidates the cache below on the next readState call. Only a file
157
+ // that never changes again would serve empty state forever — and
158
+ // reparsing the same invalid bytes every hot-path call would yield the
159
+ // same `{}` anyway, so the cache costs nothing in that case.
160
+ state = {};
161
+ }
162
+ stateCache = {
163
+ path: p,
164
+ missing: false,
165
+ mtimeMs: stat.mtimeMs,
166
+ size: stat.size,
167
+ state,
168
+ };
169
+ return state;
170
+ }
171
+ // Atomic tmp+rename via clients/atomic-write.ts (#762; shared with
172
+ // instance-registry.ts / recent-touches.ts / review-graph/builder.ts): a
173
+ // cross-process reader must never observe a partially-written file —
174
+ // rename() replaces the destination atomically on both POSIX and Windows
175
+ // (libuv uses MOVEFILE_REPLACE_EXISTING), so a concurrent readState sees
176
+ // either the old JSON or the new JSON, never a torn write that fails to
177
+ // parse. Unlike those best-effort writers, `bestEffort: false` here means a
178
+ // failure still propagates (matches the pre-atomic writeFileSync's behavior,
179
+ // which never swallowed errors either) — a disposition mark silently vanishing
180
+ // is a correctness bug for this store, not just a lost observability sample.
181
+ function writeState(cwd, state) {
182
+ const p = statePath(cwd);
183
+ fs.mkdirSync(path.dirname(p), { recursive: true });
184
+ writeFileAtomic(p, JSON.stringify(state, null, 2), { bestEffort: false });
185
+ // Refresh the cache from the write we just did instead of invalidating it —
186
+ // avoids an immediate re-stat+re-parse of the file we already have in hand,
187
+ // and guards against coarse filesystem mtime granularity making a
188
+ // read-immediately-after-write look like a cache hit on stale data.
189
+ const stat = fs.statSync(p);
190
+ stateCache = {
191
+ path: p,
192
+ missing: false,
193
+ mtimeMs: stat.mtimeMs,
194
+ size: stat.size,
195
+ state,
196
+ };
197
+ }
198
+ /** Test-only escape hatch — the state cache is module-level, so tests that
199
+ * write the store file out-of-band (or across separate cwds sharing a stat
200
+ * coincidence) need to reset it between cases. */
201
+ export function _resetStateCacheForTests() {
202
+ stateCache = null;
203
+ }
204
+ /** Fire-and-forget mark telemetry (see markDisposition's doc): the NDJSON log
205
+ * entry (project-relative path — rule-tuning data, not machine layout) and
206
+ * the bus event (absolute normalized path — in-process consumers navigate).
207
+ * Neither can throw into the mark path; both are already internally
208
+ * fail-safe, but the try/catch keeps a future regression in either from
209
+ * breaking a mark. */
210
+ function emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing) {
211
+ try {
212
+ logDispositionEvent({
213
+ event: "mark",
214
+ disposition,
215
+ tool: target.tool,
216
+ rule: target.rule,
217
+ filePath: relativeFile(target.filePath, cwd),
218
+ line: target.line,
219
+ reason,
220
+ anchor,
221
+ previousDisposition: existing?.disposition,
222
+ });
223
+ publishDisposition({
224
+ cwd,
225
+ filePath: target.filePath,
226
+ disposition,
227
+ tool: target.tool,
228
+ rule: target.rule,
229
+ line: target.line,
230
+ anchor,
231
+ reason,
232
+ });
233
+ }
234
+ catch {
235
+ // never let telemetry break a mark
236
+ }
237
+ }
238
+ /**
239
+ * Record a disposition. Picks the anchor flavor per-disposition (see module
240
+ * doc): strict for false-positive, weak for everything else. Returns the
241
+ * anchor actually used, so callers (the mark tool) can report/verify it.
242
+ *
243
+ * This is THE single choke point for mark telemetry — the NDJSON log
244
+ * (disposition-logger.ts, #181's FP-rule-tuning signal) and the
245
+ * `pilens:diagnostic:disposition` bus event (disposition-publish.ts) both
246
+ * hang off it, so the agent tool and any future UI caller are covered without
247
+ * per-caller wiring.
248
+ */
249
+ export function markDisposition(cwd, target, disposition, reason) {
250
+ const anchor = disposition === "false-positive"
251
+ ? computeStrictAnchor(target)
252
+ : computeWeakAnchor(target);
253
+ // Captured for BOTH branches: a defer never writes the store, but a store
254
+ // entry can already exist at the same weak anchor (a prior flagged/suppress
255
+ // mark) — the log should record what this mark shadowed either way.
256
+ const existing = readState(cwd).dispositions?.[anchor];
257
+ emitMarkTelemetry(cwd, target, disposition, anchor, reason, existing);
258
+ if (disposition === "defer") {
259
+ deferredThisSession.add(anchor);
260
+ return anchor;
261
+ }
262
+ const state = readState(cwd);
263
+ state.dispositions ??= {};
264
+ const now = new Date().toISOString();
265
+ const capturesFixContext = disposition === "flagged";
266
+ const lineText = capturesFixContext
267
+ ? (target.content?.split(/\r?\n/)[target.line !== undefined ? target.line - 1 : -1] ?? existing?.lineText)?.trim()
268
+ : existing?.lineText;
269
+ state.dispositions[anchor] = {
270
+ disposition,
271
+ reason: reason ?? existing?.reason,
272
+ createdAt: existing?.createdAt ?? now,
273
+ lastSeenAt: now,
274
+ line: capturesFixContext ? (target.line ?? existing?.line) : existing?.line,
275
+ lineText,
276
+ };
277
+ writeState(cwd, state);
278
+ return anchor;
279
+ }
280
+ export function getDisposition(cwd, anchor) {
281
+ return readState(cwd).dispositions?.[anchor];
282
+ }
283
+ export function isDeferredThisSession(anchor) {
284
+ return deferredThisSession.has(anchor);
285
+ }
286
+ /** Test-only escape hatch — defer state is module-level (one process = one
287
+ * session), so tests need to reset it between cases. */
288
+ export function _resetDeferredForTests() {
289
+ deferredThisSession.clear();
290
+ }
291
+ /**
292
+ * Drop diagnostics disposed false-positive/suppress, or deferred this session,
293
+ * from `diagnostics`. `flagged` diagnostics are kept as-is — callers that want
294
+ * to surface the flag (e.g. lens_diagnostics' rendering) look it up separately
295
+ * via getDisposition on the WEAK anchor (anchorsForDiagnostic(...).weak).
296
+ *
297
+ * Computes both anchors per diagnostic (cheap — same hash primitive, twice)
298
+ * since false-positive is keyed strict while defer/suppress are keyed weak;
299
+ * see module doc for why each disposition binds the way it does.
300
+ */
301
+ export function applyDispositions(diagnostics, cwd, filePath, content) {
302
+ if (!diagnostics.length)
303
+ return diagnostics;
304
+ const dispositions = readState(cwd).dispositions;
305
+ if (!dispositions && deferredThisSession.size === 0)
306
+ return diagnostics;
307
+ return diagnostics.filter((d) => {
308
+ const { strict, weak } = anchorsForDiagnostic(cwd, filePath, d, content);
309
+ if (deferredThisSession.has(weak))
310
+ return false;
311
+ if (dispositions?.[strict]?.disposition === "false-positive")
312
+ return false;
313
+ // Belt-and-braces: the inline `pi-lens-ignore` comment is the real
314
+ // suppress enforcement (see suppress-writer.ts) and normally already
315
+ // dropped this finding upstream via applyInlineSuppressions. This is a
316
+ // harmless second cover for the store-only audit trail case.
317
+ if (dispositions?.[weak]?.disposition === "suppress")
318
+ return false;
319
+ return true;
320
+ });
321
+ }
322
+ /**
323
+ * WEAK-anchor-only disposition filter for the "instant" (cache-only)
324
+ * lens_diagnostics modes (delta/all). Drops diagnostics disposed `suppress`
325
+ * or deferred this session — both WEAK-anchored (`file|tool|rule|message`, no
326
+ * line-content hash; see module doc), so this needs ZERO file I/O: it computes
327
+ * only the weak anchor and never touches the diagnostic's line content.
328
+ *
329
+ * `false-positive` is deliberately NOT filtered here: it is STRICT-anchored,
330
+ * which requires the flagged line's content to re-derive its hash, and reading
331
+ * every findings file just for that would defeat the instant contract of these
332
+ * cache-only modes. A false-positive mark still filters at the next per-edit
333
+ * dispatch (`dispatcher.ts`) and in `mode=full`'s merge — both of which already
334
+ * have file content in hand and call `applyDispositions` (the full,
335
+ * content-based filter). suppress/defer, being intent-level and weak-anchored,
336
+ * are the marks that must apply the instant a query re-serves cached findings,
337
+ * and they do so here without any read.
338
+ */
339
+ export function applyWeakDispositions(diagnostics, cwd, filePath) {
340
+ if (!diagnostics.length)
341
+ return diagnostics;
342
+ const dispositions = readState(cwd).dispositions;
343
+ if (!dispositions && deferredThisSession.size === 0)
344
+ return diagnostics;
345
+ return diagnostics.filter((d) => {
346
+ const weak = computeWeakAnchor({
347
+ cwd,
348
+ filePath,
349
+ tool: d.tool,
350
+ rule: d.rule,
351
+ message: d.message,
352
+ line: d.line,
353
+ });
354
+ if (deferredThisSession.has(weak))
355
+ return false;
356
+ if (dispositions?.[weak]?.disposition === "suppress")
357
+ return false;
358
+ return true;
359
+ });
360
+ }
@@ -17,6 +17,7 @@
17
17
  * `role:"auxiliary"` (clients/lsp/server.ts) + one profile entry here.
18
18
  */
19
19
  import { shouldDegradeAuxiliaryLsp } from "../lsp-budget.js";
20
+ import { isSubagentSession } from "../subagent-mode.js";
20
21
  import { findLocalOpengrepConfig } from "../opengrep-config.js";
21
22
  import { findLocalTyposConfig } from "../typos-config.js";
22
23
  import { findLocalZizmorConfig } from "../zizmor-config.js";
@@ -89,6 +90,11 @@ export const AUXILIARY_LSP_PROFILES = [
89
90
  sourceMatch: /ast[-_]?grep/i,
90
91
  killSwitchFlag: "no-ast-grep",
91
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,
92
98
  // The ast-grep LSP runs either the repo's own sgconfig (when present) or
93
99
  // pi-lens's shipped baseline sgconfig. In both cases the rule severity is
94
100
  // deliberate, so preserve ast-grep's severity semantics: ERROR can block,
@@ -144,7 +150,12 @@ export const AUXILIARY_LSP_PROFILES = [
144
150
  * per-file: once over budget, this session never spawns its auxiliary fleet,
145
151
  * rather than flip-flopping file to file. */
146
152
  export function enabledAuxiliaryLspServerIds(getFlag) {
147
- if (shouldDegradeAuxiliaryLsp())
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())
148
159
  return [];
149
160
  return AUXILIARY_LSP_PROFILES.flatMap((p) => p.enabledByDefault &&
150
161
  !(p.killSwitchFlag && getFlag(p.killSwitchFlag) === true)
@@ -194,7 +205,78 @@ export function isAuxiliaryDiagnosticSuppressed(d, content) {
194
205
  * equivalent) suppresses a finding identically whether it's seen via the
195
206
  * per-edit dispatch runner or a standalone diagnostics query — previously
196
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.
197
248
  */
198
- export function applyAuxiliarySuppressions(diagnostics, content) {
199
- return diagnostics.filter((d) => !isAuxiliaryDiagnosticSuppressed(d, content));
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;
200
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);
@@ -19,7 +19,8 @@ import { fileURLToPath } from "node:url";
19
19
  import { formatCascadeNeighborDiagnostics } from "../cascade-format.js";
20
20
  import { logCascade } from "../cascade-logger.js";
21
21
  import { getDiagnosticTracker } from "../diagnostic-tracker.js";
22
- import { classifyCascadeWaitTier, isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
22
+ import { isTierAwareCascadeEnabled, recordOutstandingCascadeTouch, } from "../lsp/cascade-tier.js";
23
+ import { classifyCascadeWaitTier } from "../lsp/wait-policy/index.js";
23
24
  import { getServersForFileWithConfig } from "../lsp/config.js";
24
25
  import { getLSPService } from "../lsp/index.js";
25
26
  import { isExternalOrVendorFile, normalizeMapKey } from "../path-utils.js";
@@ -821,7 +822,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
821
822
  coldSnapshotPaths.push(neighborPath);
822
823
  continue;
823
824
  }
824
- const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
825
+ // #692: `source: "cascade"` used to be passed here to label `rule`
826
+ // (`cascade:<code>`) — that override is gone (identity must come from
827
+ // the diagnostic's own source; see `scanOrigin`'s doc comment), and
828
+ // cascade neighbor diagnostics are ephemeral display-only output
829
+ // (never reconciled into persisted widget/dedup state), so the label
830
+ // had no remaining purpose and is simply dropped rather than migrated.
831
+ const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
825
832
  producedLspData = true;
826
833
  const durationMs = Date.now() - neighborStart;
827
834
  logCascade({
@@ -994,7 +1001,11 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
994
1001
  });
995
1002
  if (!rawDiags)
996
1003
  return undefined;
997
- const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
1004
+ // #692: `source: "cascade"` no longer overrides `rule` (see the
1005
+ // doc comment on the sibling call above) — dropped rather than
1006
+ // migrated to `scanOrigin` since cascade output never touches
1007
+ // persisted widget/dedup state.
1008
+ const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
998
1009
  const durationMs = Date.now() - neighborStart;
999
1010
  // Update cache for this neighbor at the current write sequence
1000
1011
  if (writeSeq != null) {
@@ -1051,10 +1062,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1051
1062
  error: String(result.reason),
1052
1063
  });
1053
1064
  const entry = allDiags.get(normalizeMapKey(neighborPath));
1065
+ // #692: `source: "cascade"` dropped (see the doc comment above the
1066
+ // first cascade call site in this file) — no longer affects `rule`
1067
+ // and cascade output never touches persisted widget/dedup state.
1054
1068
  const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
1055
1069
  ? convertLspDiagnostics(entry.diags
1056
1070
  .filter((d) => d.severity === 1)
1057
- .slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" })
1071
+ .slice(0, MAX_PER_FILE), neighborPath)
1058
1072
  : [];
1059
1073
  neighbors.push({
1060
1074
  filePath: neighborPath,
@@ -1162,7 +1176,9 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1162
1176
  continue;
1163
1177
  if (now - ts > CASCADE_TTL_MS)
1164
1178
  continue;
1165
- const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath, { source: "cascade" });
1179
+ // #692: `source: "cascade"` dropped — see the doc comment above the
1180
+ // first cascade `convertLspDiagnostics` call site in this file.
1181
+ const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
1166
1182
  if (errors.length === 0)
1167
1183
  continue;
1168
1184
  neighbors.push({