pi-lens 3.8.70 → 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 (90) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +16 -0
  3. package/dist/clients/bus-events-logger.js +10 -8
  4. package/dist/clients/cache/rule-cache.js +9 -5
  5. package/dist/clients/cache-manager.js +19 -13
  6. package/dist/clients/diagnostic-dispositions.js +298 -0
  7. package/dist/clients/dispatch/auxiliary-lsp.js +85 -3
  8. package/dist/clients/dispatch/dispatcher.js +6 -1
  9. package/dist/clients/dispatch/integration.js +19 -4
  10. package/dist/clients/dispatch/runners/lsp.js +5 -32
  11. package/dist/clients/dispatch/runners/oxlint.js +1 -1
  12. package/dist/clients/dispatch/suppress-writer.js +68 -0
  13. package/dist/clients/dispatch/utils/lsp-diagnostics.js +4 -1
  14. package/dist/clients/disposition-logger.js +39 -0
  15. package/dist/clients/disposition-publish.js +109 -0
  16. package/dist/clients/file-utils.js +94 -33
  17. package/dist/clients/format-events-publish.js +299 -0
  18. package/dist/clients/git-tracked-ignore.js +208 -0
  19. package/dist/clients/instance-registry.js +74 -4
  20. package/dist/clients/json-cache-read.js +65 -0
  21. package/dist/clients/language-profile.js +4 -0
  22. package/dist/clients/lens-map.js +1079 -0
  23. package/dist/clients/lsp/aggregation.js +69 -2
  24. package/dist/clients/lsp/client.js +72 -7
  25. package/dist/clients/lsp/index.js +486 -14
  26. package/dist/clients/lsp/server.js +17 -1
  27. package/dist/clients/lsp/tsserver-sync.js +145 -0
  28. package/dist/clients/lsp/workspace-diagnostics-cache.js +172 -0
  29. package/dist/clients/mcp/session.js +8 -0
  30. package/dist/clients/opengrep-config.js +2 -10
  31. package/dist/clients/path-utils.js +26 -0
  32. package/dist/clients/pipeline.js +4 -0
  33. package/dist/clients/project-diagnostics/cache.js +5 -12
  34. package/dist/clients/project-snapshot.js +3 -6
  35. package/dist/clients/read-guard.js +1 -1
  36. package/dist/clients/review-graph/builder.js +78 -52
  37. package/dist/clients/review-graph/import-resolvers.js +53 -19
  38. package/dist/clients/runtime-agent-end.js +26 -0
  39. package/dist/clients/runtime-coordinator.js +9 -1
  40. package/dist/clients/runtime-session.js +53 -11
  41. package/dist/clients/runtime-tool-call.js +789 -0
  42. package/dist/clients/runtime-tool-result.js +15 -2
  43. package/dist/clients/runtime-turn.js +9 -2
  44. package/dist/clients/session-state-store.js +6 -9
  45. package/dist/clients/sgconfig.js +2 -9
  46. package/dist/clients/source-filter.js +5 -0
  47. package/dist/clients/startup-scan.js +68 -1
  48. package/dist/clients/typos-config.js +2 -11
  49. package/dist/clients/widget-state.js +22 -0
  50. package/dist/clients/zizmor-config.js +2 -10
  51. package/dist/index.js +21089 -18685
  52. package/dist/tools/lens-diagnostic-mark.js +158 -0
  53. package/dist/tools/lens-diagnostics.js +41 -7
  54. package/dist/tools/lsp-diagnostics.js +99 -119
  55. package/docs/agent-tools.md +11 -4
  56. package/docs/dispositions.md +85 -0
  57. package/docs/environment-variables.md +16 -0
  58. package/docs/fable.md +10 -9
  59. package/docs/servercapabilities.md +1 -1
  60. package/docs/tools.md +3 -0
  61. package/package.json +6 -1
  62. package/rules/ast-grep-rules/rule-tests/find-import-file-without-extension-test.yml +4 -0
  63. package/rules/ast-grep-rules/rule-tests/no-javascript-url-js-test.yml +5 -0
  64. package/rules/ast-grep-rules/rule-tests/no-javascript-url-test.yml +9 -0
  65. package/rules/ast-grep-rules/rules/find-import-file-without-extension.yml +3 -1
  66. package/rules/ast-grep-rules/rules/no-javascript-url-js.yml +12 -0
  67. package/rules/ast-grep-rules/rules/no-javascript-url.yml +12 -0
  68. package/docs/agent-tooling-improvement-plan.md +0 -660
  69. package/docs/inspiration_gograph.md +0 -601
  70. package/docs/inspiration_neovim.md +0 -1795
  71. package/docs/inspiration_neovim2.md +0 -402
  72. package/docs/inspiration_tree_sitter_rules.md +0 -369
  73. package/docs/inspiration_tree_sitter_rules_investigation.md +0 -328
  74. package/docs/inspiration_tree_sitter_rules_round2.md +0 -470
  75. package/docs/lsp-bridge-analysis.md +0 -444
  76. package/docs/newinspiration.md +0 -338
  77. package/grammars/tree-sitter-c.wasm +0 -0
  78. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  79. package/grammars/tree-sitter-cpp.wasm +0 -0
  80. package/grammars/tree-sitter-dart.wasm +0 -0
  81. package/grammars/tree-sitter-elixir.wasm +0 -0
  82. package/grammars/tree-sitter-kotlin.wasm +0 -0
  83. package/grammars/tree-sitter-lua.wasm +0 -0
  84. package/grammars/tree-sitter-ocaml.wasm +0 -0
  85. package/grammars/tree-sitter-php.wasm +0 -0
  86. package/grammars/tree-sitter-ruby.wasm +0 -0
  87. package/grammars/tree-sitter-swift.wasm +0 -0
  88. package/grammars/tree-sitter-toml.wasm +0 -0
  89. package/grammars/tree-sitter-vue.wasm +0 -0
  90. package/grammars/tree-sitter-zig.wasm +0 -0
@@ -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);
@@ -821,7 +821,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
821
821
  coldSnapshotPaths.push(neighborPath);
822
822
  continue;
823
823
  }
824
- const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
824
+ // #692: `source: "cascade"` used to be passed here to label `rule`
825
+ // (`cascade:<code>`) — that override is gone (identity must come from
826
+ // the diagnostic's own source; see `scanOrigin`'s doc comment), and
827
+ // cascade neighbor diagnostics are ephemeral display-only output
828
+ // (never reconciled into persisted widget/dedup state), so the label
829
+ // had no remaining purpose and is simply dropped rather than migrated.
830
+ const diags = convertLspDiagnostics(entry.diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
825
831
  producedLspData = true;
826
832
  const durationMs = Date.now() - neighborStart;
827
833
  logCascade({
@@ -994,7 +1000,11 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
994
1000
  });
995
1001
  if (!rawDiags)
996
1002
  return undefined;
997
- const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" });
1003
+ // #692: `source: "cascade"` no longer overrides `rule` (see the
1004
+ // doc comment on the sibling call above) — dropped rather than
1005
+ // migrated to `scanOrigin` since cascade output never touches
1006
+ // persisted widget/dedup state.
1007
+ const diags = convertLspDiagnostics(rawDiags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), neighborPath);
998
1008
  const durationMs = Date.now() - neighborStart;
999
1009
  // Update cache for this neighbor at the current write sequence
1000
1010
  if (writeSeq != null) {
@@ -1051,10 +1061,13 @@ export async function computeCascadeForFile(filePath, cwd, options = {}) {
1051
1061
  error: String(result.reason),
1052
1062
  });
1053
1063
  const entry = allDiags.get(normalizeMapKey(neighborPath));
1064
+ // #692: `source: "cascade"` dropped (see the doc comment above the
1065
+ // first cascade call site in this file) — no longer affects `rule`
1066
+ // and cascade output never touches persisted widget/dedup state.
1054
1067
  const diags = entry && Date.now() - entry.ts < CASCADE_TTL_MS
1055
1068
  ? convertLspDiagnostics(entry.diags
1056
1069
  .filter((d) => d.severity === 1)
1057
- .slice(0, MAX_PER_FILE), neighborPath, { source: "cascade" })
1070
+ .slice(0, MAX_PER_FILE), neighborPath)
1058
1071
  : [];
1059
1072
  neighbors.push({
1060
1073
  filePath: neighborPath,
@@ -1162,7 +1175,9 @@ function appendFallbackNeighbors(neighbors, allDiags, normalizedFileKey, cwd) {
1162
1175
  continue;
1163
1176
  if (now - ts > CASCADE_TTL_MS)
1164
1177
  continue;
1165
- const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath, { source: "cascade" });
1178
+ // #692: `source: "cascade"` dropped — see the doc comment above the
1179
+ // first cascade `convertLspDiagnostics` call site in this file.
1180
+ const errors = convertLspDiagnostics(diags.filter((d) => d.severity === 1).slice(0, MAX_PER_FILE), diagPath);
1166
1181
  if (errors.length === 0)
1167
1182
  continue;
1168
1183
  neighbors.push({
@@ -16,7 +16,7 @@ import { RUNTIME_CONFIG } from "../../runtime-config.js";
16
16
  import { PRIORITY } from "../priorities.js";
17
17
  import { resolveRunnerPath } from "../runner-context.js";
18
18
  import { convertLspDiagnostics } from "../utils/lsp-diagnostics.js";
19
- import { enabledAuxiliaryLspServerIds, findAuxiliaryProfileForSource, isAuxiliaryDiagnosticSuppressed, } from "../auxiliary-lsp.js";
19
+ import { enabledAuxiliaryLspServerIds, retagAuxiliaryDiagnostics, } from "../auxiliary-lsp.js";
20
20
  import { readFileContent } from "./utils.js";
21
21
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
22
22
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
@@ -225,37 +225,10 @@ const lspRunner = {
225
225
  // convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
226
226
  // auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
227
227
  // their tool id + semantic policy — language-server diagnostics keep "lsp".
228
- // blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
229
- const blockingAllowedByProfile = new Map();
230
- // Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
231
- // `# nosemgrep`, #441). Reuses `content` from the sync read above.
232
- const suppressedIndices = new Set();
233
- for (let i = 0; i < diagnostics.length; i++) {
234
- const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
235
- if (!profile)
236
- continue;
237
- if (isAuxiliaryDiagnosticSuppressed(validLspDiags[i], content)) {
238
- suppressedIndices.add(i);
239
- continue;
240
- }
241
- let blockingAllowed = blockingAllowedByProfile.get(profile);
242
- if (blockingAllowed === undefined) {
243
- blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
244
- blockingAllowedByProfile.set(profile, blockingAllowed);
245
- }
246
- const d = diagnostics[i];
247
- d.tool = profile.tool;
248
- d.semantic = profile.semantic(validLspDiags[i], { blockingAllowed });
249
- if (d.semantic !== "blocking" && d.severity === "error") {
250
- d.severity = "warning";
251
- }
252
- const defectClass = profile.defectClass?.(validLspDiags[i]);
253
- if (defectClass)
254
- d.defectClass = defectClass;
255
- }
256
- const keptDiagnostics = suppressedIndices.size
257
- ? diagnostics.filter((_, i) => !suppressedIndices.has(i))
258
- : diagnostics;
228
+ // #692: shared with the scan/sweep reconcile paths (`retagAuxiliaryDiagnostics`
229
+ // in `../auxiliary-lsp.js`) so a scan-reconciled aux finding gets identical
230
+ // tool/semantic/defectClass tagging instead of keeping tool "lsp".
231
+ const keptDiagnostics = retagAuxiliaryDiagnostics(diagnostics, validLspDiags, content, { cwd: ctx.cwd, fileRole: ctx.fileRole });
259
232
  const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
260
233
  const resultSemantic = hasErrors
261
234
  ? "blocking"
@@ -44,7 +44,7 @@ const oxlintRunner = {
44
44
  appliesTo: ["jsts"],
45
45
  priority: PRIORITY.LINT_SECONDARY,
46
46
  enabledByDefault: true,
47
- skipTestFiles: true,
47
+ skipTestFiles: false,
48
48
  async run(ctx) {
49
49
  const cwd = ctx.cwd || process.cwd();
50
50
  const policy = getJstsLintPolicyForCwd(cwd);
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Writes the `pi-lens-ignore` suppression comment that inline-suppressions.ts
3
+ * already knows how to read (#690's `suppress` disposition). The reader
4
+ * accepts either `//` or `#` on any line regardless of language, but the
5
+ * WRITER must pick the syntactically valid comment character for the target
6
+ * file — an errant `#` line breaks parsing in a `//`-comment language and
7
+ * vice versa.
8
+ */
9
+ import * as path from "node:path";
10
+ const HASH_COMMENT_EXTENSIONS = new Set([
11
+ ".py",
12
+ ".rb",
13
+ ".sh",
14
+ ".bash",
15
+ ".zsh",
16
+ ".yml",
17
+ ".yaml",
18
+ ".toml",
19
+ ".r",
20
+ ".pl",
21
+ ".pm",
22
+ ".ex",
23
+ ".exs",
24
+ ".elixir",
25
+ ".dockerfile",
26
+ ".gitignore",
27
+ ".conf",
28
+ ".cfg",
29
+ ".ini",
30
+ ]);
31
+ function commentPrefixFor(filePath) {
32
+ const ext = path.extname(filePath).toLowerCase();
33
+ return HASH_COMMENT_EXTENSIONS.has(ext) ? "#" : "//";
34
+ }
35
+ /**
36
+ * Insert a `pi-lens-ignore: <rule>` comment on the line immediately above
37
+ * `line` (1-based), matching inline-suppressions.ts's "same line or line
38
+ * above" read semantics. If the line above already carries a pi-lens-ignore
39
+ * comment, the rule is appended to its list instead of adding a duplicate
40
+ * comment line. Returns the updated content; throws if `line` is out of range.
41
+ */
42
+ export function insertSuppressComment(content, filePath, line, rule) {
43
+ const lines = content.split(/\r?\n/);
44
+ if (line < 1 || line > lines.length) {
45
+ throw new Error(`line ${line} is out of range (file has ${lines.length} lines)`);
46
+ }
47
+ const prefix = commentPrefixFor(filePath);
48
+ const aboveIdx = line - 2; // 0-based index of the line immediately above
49
+ const existingAbove = aboveIdx >= 0 ? lines[aboveIdx] : undefined;
50
+ const suppressRe = /((?:\/\/|#)\s*pi-lens-ignore:\s*)(.+)$/;
51
+ const match = existingAbove !== undefined ? suppressRe.exec(existingAbove) : null;
52
+ if (match && existingAbove !== undefined) {
53
+ const rules = match[2]
54
+ .split(",")
55
+ .map((r) => r.trim())
56
+ .filter(Boolean);
57
+ if (!rules.includes(rule))
58
+ rules.push(rule);
59
+ lines[aboveIdx] = existingAbove.slice(0, match.index) + match[1] + rules.join(", ");
60
+ return lines.join("\n");
61
+ }
62
+ // Match the indentation of the flagged line so the inserted comment lines
63
+ // up rather than sitting at column 0.
64
+ const targetLine = lines[line - 1] ?? "";
65
+ const indent = /^\s*/.exec(targetLine)?.[0] ?? "";
66
+ lines.splice(line - 1, 0, `${indent}${prefix} pi-lens-ignore: ${rule}`);
67
+ return lines.join("\n");
68
+ }
@@ -7,7 +7,9 @@ export function convertLspDiagnostics(diags, filePath, options = {}) {
7
7
  const severity = severityMap[d.severity] ?? "info";
8
8
  const semantic = d.severity === 1 ? "blocking" : (d.severity === 2 ? "warning" : "none");
9
9
  const code = String(d.code ?? "unknown");
10
- const source = options.source ?? d.source ?? tool;
10
+ // #692: identity ALWAYS derives from the diagnostic's own source — never
11
+ // from a caller-supplied scan label (see `scanOrigin`'s doc comment above).
12
+ const source = d.source ?? tool;
11
13
  const hasSuggestion = options.fixSuggestionByIndex?.has(idx) ?? false;
12
14
  return {
13
15
  id: `${tool}:${code}:${d.range.start.line}`,
@@ -23,6 +25,7 @@ export function convertLspDiagnostics(diags, filePath, options = {}) {
23
25
  autoFixAvailable: false,
24
26
  fixKind: hasSuggestion ? "suggestion" : undefined,
25
27
  fixSuggestion: options.fixSuggestionByIndex?.get(idx),
28
+ scanOrigin: options.scanOrigin,
26
29
  };
27
30
  });
28
31
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * NDJSON telemetry log for diagnostic disposition marks (#690). Same shape as
3
+ * clients/actionable-warnings-logger.ts: machine-global (getGlobalPiLensDir),
4
+ * size-capped with a single `.log.1` backup, isTestMode no-op.
5
+ *
6
+ * Why a log at all: #181 identified false-positive marks flowing to telemetry
7
+ * as THE highest-value rule-tuning signal — which shipped rules misfire, on
8
+ * what tools, how often — and this is that hookup. The disposition store
9
+ * itself is not enough: it keeps only the LATEST entry per anchor (a re-mark
10
+ * overwrites), and `defer` marks never touch the store at all (in-memory by
11
+ * design), so this log is the only durable trace of both re-mark history and
12
+ * defer activity.
13
+ */
14
+ import * as path from "node:path";
15
+ import { isTestMode } from "./env-utils.js";
16
+ import { getGlobalPiLensDir } from "./file-utils.js";
17
+ import { createNdjsonLogger } from "./ndjson-logger.js";
18
+ const DISPOSITION_LOG_DIR = getGlobalPiLensDir();
19
+ const DISPOSITION_LOG_FILE = path.join(DISPOSITION_LOG_DIR, "dispositions.log");
20
+ const DISPOSITION_LOG_BACKUP_FILE = path.join(DISPOSITION_LOG_DIR, "dispositions.log.1");
21
+ const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_DISPOSITION_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
22
+ const writer = createNdjsonLogger({
23
+ filePath: DISPOSITION_LOG_FILE,
24
+ maxBytes: MAX_LOG_BYTES,
25
+ backupPath: DISPOSITION_LOG_BACKUP_FILE,
26
+ });
27
+ export function logDispositionEvent(entry) {
28
+ if (isTestMode()) {
29
+ return;
30
+ }
31
+ writer.log({ ts: new Date().toISOString(), ...entry });
32
+ }
33
+ export function getDispositionLogPath() {
34
+ return DISPOSITION_LOG_FILE;
35
+ }
36
+ /** Resolve once all enqueued disposition writes are on disk. */
37
+ export function flushDispositionLog() {
38
+ return writer.flush();
39
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Publishes `pilens:diagnostic:disposition` on pi's shared `pi.events` bus
3
+ * (#690).
4
+ *
5
+ * Sibling to `clients/diagnostics-publish.ts` (#502) rather than a new export
6
+ * inside it, per the "owns nothing in common" rule spelled out in
7
+ * format-events-publish.ts's header: the event is diagnostic-DOMAIN, but it
8
+ * shares none of diagnostics-publish's module state or contract —
9
+ * dispositions are point-in-time FACTS ("a mark just happened"), not
10
+ * full-replace state snapshots, so the seq counter, the
11
+ * previously-reported-dirty set, and the whole staleness/replace consumer
12
+ * contract over there simply don't apply here. Only the emit plumbing shape
13
+ * and the `PI_LENS_BUS_PUBLISH` kill switch (`isBusPublishEnabled`, imported
14
+ * from bus-publish.ts) are shared, same as every other producer sibling.
15
+ *
16
+ * Fire-and-forget: publishing must never affect the mark path's success or
17
+ * latency. Any failure is swallowed; `dbg` fires at most once per process on
18
+ * first failure so a wired caller can log it without spamming.
19
+ *
20
+ * Versioning policy: frozen-additive, same discipline as #482/#502. New
21
+ * optional fields may be added under `v: 1`; a breaking change to an existing
22
+ * field's meaning must bump `v`.
23
+ */
24
+ import { logBusEvent } from "./bus-events-logger.js";
25
+ import { isBusPublishEnabled } from "./bus-publish.js";
26
+ import { normalizeFilePath } from "./path-utils.js";
27
+ export const BUS_DISPOSITION_EVENT = "pilens:diagnostic:disposition";
28
+ export const BUS_DISPOSITION_VERSION = 1;
29
+ let busEmit;
30
+ let hasLoggedFailure = false;
31
+ let hasLoggedUnwired = false;
32
+ let hasLoggedDisabled = false;
33
+ /**
34
+ * Wire the emit function from pi's `pi.events` bus. Called once at extension
35
+ * factory time from index.ts, same call as `wireDiagnosticsBusEmitter` (#502)
36
+ * — both producers share the identical `pi.events.emit` binding. Never called
37
+ * ⇒ `publishDisposition` no-ops, which is exactly the state unit tests and
38
+ * the MCP server path run in (no pi host, no `pi.events`).
39
+ */
40
+ export function wireDispositionBusEmitter(emitFn) {
41
+ busEmit = emitFn;
42
+ }
43
+ /** Test-only: reset module state between test files. */
44
+ export function _resetDispositionPublishForTests() {
45
+ busEmit = undefined;
46
+ hasLoggedFailure = false;
47
+ hasLoggedUnwired = false;
48
+ hasLoggedDisabled = false;
49
+ }
50
+ /**
51
+ * Publish one `pilens:diagnostic:disposition` event for a mark (one event per
52
+ * markDisposition call). Fire-and-forget: never throws, never awaited.
53
+ */
54
+ export function publishDisposition(args) {
55
+ if (!isBusPublishEnabled()) {
56
+ if (!hasLoggedDisabled) {
57
+ hasLoggedDisabled = true;
58
+ logBusEvent({
59
+ event: BUS_DISPOSITION_EVENT,
60
+ outcome: "skipped_disabled",
61
+ cwd: normalizeFilePath(args.cwd),
62
+ });
63
+ }
64
+ return;
65
+ }
66
+ if (!busEmit) {
67
+ if (!hasLoggedUnwired) {
68
+ hasLoggedUnwired = true;
69
+ logBusEvent({
70
+ event: BUS_DISPOSITION_EVENT,
71
+ outcome: "skipped_unwired",
72
+ cwd: normalizeFilePath(args.cwd),
73
+ });
74
+ }
75
+ return;
76
+ }
77
+ try {
78
+ const payload = {
79
+ v: BUS_DISPOSITION_VERSION,
80
+ source: "pi-lens",
81
+ cwd: normalizeFilePath(args.cwd),
82
+ filePath: normalizeFilePath(args.filePath),
83
+ disposition: args.disposition,
84
+ tool: args.tool,
85
+ rule: args.rule,
86
+ line: args.line,
87
+ anchor: args.anchor,
88
+ reason: args.reason,
89
+ };
90
+ busEmit(BUS_DISPOSITION_EVENT, payload);
91
+ logBusEvent({
92
+ event: BUS_DISPOSITION_EVENT,
93
+ outcome: "emitted",
94
+ cwd: payload.cwd,
95
+ });
96
+ }
97
+ catch (err) {
98
+ logBusEvent({
99
+ event: BUS_DISPOSITION_EVENT,
100
+ outcome: "emit_failed",
101
+ cwd: normalizeFilePath(args.cwd),
102
+ error: String(err),
103
+ });
104
+ if (!hasLoggedFailure) {
105
+ hasLoggedFailure = true;
106
+ args.dbg?.(`disposition-publish: pilens:diagnostic:disposition emit failed (further failures suppressed): ${err}`);
107
+ }
108
+ }
109
+ }