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
@@ -16,8 +16,10 @@ 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
+ import { tryWarmAttachedCodeActions, tryWarmAttachedDiagnostics, } from "../../warm-attach.js";
22
+ import { contentHash, WARM_CODE_ACTION_LOOKUP_LIMIT, } from "../../mcp/ipc.js";
21
23
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
22
24
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
23
25
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
@@ -27,7 +29,6 @@ const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
27
29
  // after the cap still land in the client's cache and surface on the
28
30
  // next edit. Overridable via PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS.
29
31
  const LSP_DIAGNOSTICS_WAIT_MS = 2500;
30
- const MAX_CODE_ACTION_LOOKUPS = 6;
31
32
  const MAX_CODE_ACTION_TITLES = 3;
32
33
  function normalizeActionTitle(title) {
33
34
  return title.replace(/\s+/g, " ").trim();
@@ -109,6 +110,7 @@ const lspRunner = {
109
110
  // one spawned server) — an empty `lspDiags` in that case is NOT a
110
111
  // confirmed clean result and must not be reported as one (#570).
111
112
  let diagnosticsInconclusive = false;
113
+ let usedWarmAttach = false;
112
114
  let failureReason = "";
113
115
  const content = readFileContent(ctx.filePath);
114
116
  if (!content) {
@@ -124,21 +126,27 @@ const lspRunner = {
124
126
  // path so their warm diagnostics merge into this same result.
125
127
  const auxiliaryServerIds = enabledAuxiliaryLspServerIds((f) => ctx.pi.getFlag(f));
126
128
  try {
127
- const touched = await lspService.touchFile(ctx.filePath, content, {
128
- diagnostics: "document",
129
- collectDiagnostics: true,
130
- clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
131
- auxiliaryServerIds,
132
- maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
133
- maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
134
- source: "dispatch-lsp-runner",
135
- });
129
+ const attached = await tryWarmAttachedDiagnostics(ctx.filePath, content, Math.max(LSP_SPAWN_BUDGET_MS, LSP_DIAGNOSTICS_WAIT_MS));
130
+ usedWarmAttach = attached?.available === true;
131
+ const touched = attached?.available
132
+ ? attached.response.diagnostics
133
+ : await lspService.touchFile(ctx.filePath, content, {
134
+ diagnostics: "document",
135
+ collectDiagnostics: true,
136
+ clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
137
+ auxiliaryServerIds,
138
+ maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
139
+ maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
140
+ source: "dispatch-lsp-runner",
141
+ });
136
142
  if (touched === undefined) {
137
143
  lspClientReady = false;
138
144
  }
139
145
  else {
140
146
  lspDiags = touched;
141
- diagnosticsInconclusive = touched.inconclusive === true;
147
+ diagnosticsInconclusive =
148
+ touched
149
+ .inconclusive === true;
142
150
  }
143
151
  }
144
152
  catch (err) {
@@ -206,56 +214,50 @@ const lspRunner = {
206
214
  const blockingDiagIndexes = validLspDiags
207
215
  .map((d, idx) => ({ d, idx }))
208
216
  .filter(({ d }) => d.severity === 1)
209
- .slice(0, MAX_CODE_ACTION_LOOKUPS);
210
- await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
211
- try {
212
- const start = d.range.start;
213
- const end = d.range.end ?? d.range.start;
214
- const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
215
- const suggestion = buildCodeActionSuggestion(actions);
216
- if (suggestion) {
217
- fixSuggestionByIndex.set(idx, suggestion);
218
- }
219
- }
220
- catch {
221
- // Best-effort enrichment only; base diagnostics remain authoritative.
217
+ .slice(0, WARM_CODE_ACTION_LOOKUP_LIMIT);
218
+ if (usedWarmAttach) {
219
+ // Diagnostics have already succeeded. Code actions are optional
220
+ // enrichment, so ANY IPC failure degrades to today's skip without
221
+ // promoting the attached session to a local LSP fleet.
222
+ const ranges = blockingDiagIndexes.map(({ d }) => ({
223
+ start: d.range.start,
224
+ end: d.range.end ?? d.range.start,
225
+ }));
226
+ const result = await tryWarmAttachedCodeActions(ctx.filePath, contentHash(content), ranges, LSP_DIAGNOSTICS_WAIT_MS);
227
+ if (result?.available) {
228
+ result.response.actions.forEach((actions, responseIndex) => {
229
+ const diagnosticIndex = blockingDiagIndexes[responseIndex]?.idx;
230
+ const suggestion = buildCodeActionSuggestion(actions);
231
+ if (diagnosticIndex !== undefined && suggestion) {
232
+ fixSuggestionByIndex.set(diagnosticIndex, suggestion);
233
+ }
234
+ });
222
235
  }
223
- }));
236
+ }
237
+ else {
238
+ await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
239
+ try {
240
+ const start = d.range.start;
241
+ const end = d.range.end ?? d.range.start;
242
+ const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
243
+ const suggestion = buildCodeActionSuggestion(actions);
244
+ if (suggestion) {
245
+ fixSuggestionByIndex.set(idx, suggestion);
246
+ }
247
+ }
248
+ catch {
249
+ // Best-effort enrichment only; base diagnostics remain authoritative.
250
+ }
251
+ }));
252
+ }
224
253
  const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
225
254
  // convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
226
255
  // auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
227
256
  // 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;
257
+ // #692: shared with the scan/sweep reconcile paths (`retagAuxiliaryDiagnostics`
258
+ // in `../auxiliary-lsp.js`) so a scan-reconciled aux finding gets identical
259
+ // tool/semantic/defectClass tagging instead of keeping tool "lsp".
260
+ const keptDiagnostics = retagAuxiliaryDiagnostics(diagnostics, validLspDiags, content, { cwd: ctx.cwd, fileRole: ctx.fileRole });
259
261
  const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
260
262
  const resultSemantic = hasErrors
261
263
  ? "blocking"
@@ -1,3 +1,4 @@
1
+ import { resolvePackagePath } from "../../package-root.js";
1
2
  import { safeSpawnAsync } from "../../safe-spawn.js";
2
3
  import { getLinterPolicyForCwd, hasMarkdownlintConfig, } from "../../tool-policy.js";
3
4
  import { PRIORITY } from "../priorities.js";
@@ -93,9 +94,6 @@ const markdownlintRunner = {
93
94
  return { status: "skipped", diagnostics: [], semantic: "none" };
94
95
  }
95
96
  const hasConfig = hasMarkdownlintConfig(cwd);
96
- if (!hasConfig) {
97
- // Run with sensible defaults even without explicit config
98
- }
99
97
  let cmd = null;
100
98
  if (await (markdownlint.isAvailableAsync(cwd))) {
101
99
  cmd = markdownlint.getCommand(cwd);
@@ -105,7 +103,12 @@ const markdownlintRunner = {
105
103
  }
106
104
  if (!cmd)
107
105
  return { status: "skipped", diagnostics: [], semantic: "none" };
108
- const configArgs = hasConfig ? [] : ["--disable", "MD013"];
106
+ const configArgs = hasConfig
107
+ ? []
108
+ : [
109
+ "--config",
110
+ resolvePackagePath(import.meta.url, "config/markdownlint/core.json"),
111
+ ];
109
112
  const result = await safeSpawnAsync(cmd, [...configArgs, ctx.filePath], {
110
113
  timeout: 15000,
111
114
  cwd,
@@ -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);
@@ -82,6 +82,7 @@ const pyrightRunner = {
82
82
  ? "warning"
83
83
  : "none",
84
84
  };
85
+ // pi-lens-ignore: missing-error-propagation
85
86
  }
86
87
  catch {
87
88
  console.error(`[runner:pyright] JSON parse failed for ${ctx.filePath} — raw output: ${output.slice(0, 200)}`);
@@ -23,7 +23,7 @@ function runBlastRadiusInBackground(cwd, filePath, languageId, facts) {
23
23
  void (async () => {
24
24
  try {
25
25
  const graph = await buildOrUpdateGraph(cwd, [filePath], facts);
26
- const impact = computeImpactCascade(graph, filePath);
26
+ const impact = computeImpactCascade(graph, filePath, cwd);
27
27
  logTreeSitter({
28
28
  phase: "blast_radius",
29
29
  filePath,
@@ -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
+ }
@@ -43,3 +43,41 @@ export function toPositiveFinite(value) {
43
43
  const num = typeof value === "number" ? value : Number(value);
44
44
  return Number.isFinite(num) && num > 0 ? num : 0;
45
45
  }
46
+ /**
47
+ * Factory for the lazy-memoized "positive-finite-number env override, else a
48
+ * default" getter pattern repeated across `startup-scan.ts` (#699, #758) and
49
+ * originally documented as house style in `runtime-config.ts` / `slow-fs.ts` /
50
+ * `subagent-mode.ts` (#763).
51
+ *
52
+ * `get()` reads `process.env[envName]` through {@link toPositiveFinite} the
53
+ * first time it's called, memoizes the result (or `fallback` when the env var
54
+ * is unset/non-finite/non-positive), and returns the memo on every subsequent
55
+ * call. `_resetForTests()` clears the memo so a test can flip the env var and
56
+ * observe a fresh read (matching the existing `_resetForTests` convention).
57
+ *
58
+ * Deliberately does NOT touch `process.env` until `get()` is first called —
59
+ * that's the entire point of the lazy pattern: importing a module must never
60
+ * have a side effect on process env at load time.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const _ttl = lazyEnvNumber("PI_LENS_STARTUP_SCAN_VERDICT_TTL_MS", DEFAULT_TTL_MS);
65
+ * export const getStartupScanVerdictTtlMs = _ttl.get;
66
+ * export const _resetStartupScanVerdictTtlForTests = _ttl._resetForTests;
67
+ * ```
68
+ */
69
+ export function lazyEnvNumber(envName, fallback) {
70
+ let memo;
71
+ return {
72
+ get() {
73
+ if (memo !== undefined)
74
+ return memo;
75
+ const envValue = toPositiveFinite(process.env[envName]);
76
+ memo = envValue > 0 ? envValue : fallback;
77
+ return memo;
78
+ },
79
+ _resetForTests() {
80
+ memo = undefined;
81
+ },
82
+ };
83
+ }