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
@@ -5,8 +5,9 @@ import * as fs from "node:fs";
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { minimatch } from "./deps/minimatch.js";
8
+ import { collectTrackedFiles, getTrackedFilesSnapshot } from "./git-tracked-ignore.js";
8
9
  import { getGlobalIgnorePatterns, getPiLensGlobalConfigPath, } from "./lens-config.js";
9
- import { normalizeFilePath } from "./path-utils.js";
10
+ import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
10
11
  import { findPiLensProjectConfig, loadPiLensProjectConfig, } from "./project-lens-config.js";
11
12
  import { safeSpawnAsync } from "./safe-spawn.js";
12
13
  /**
@@ -167,7 +168,7 @@ function stripTrailingSpaces(value) {
167
168
  end -= 1;
168
169
  return value.slice(0, end).replace(/\\ /g, " ");
169
170
  }
170
- function parseGitignoreContent(content) {
171
+ function parseGitignoreContent(content, layer) {
171
172
  const patterns = [];
172
173
  for (const rawLine of content.split(/\r?\n/)) {
173
174
  let line = stripTrailingSpaces(rawLine.trimStart());
@@ -195,6 +196,7 @@ function parseGitignoreContent(content) {
195
196
  directoryOnly,
196
197
  rooted,
197
198
  hasSlash: line.includes("/"),
199
+ layer,
198
200
  });
199
201
  }
200
202
  return patterns;
@@ -225,10 +227,10 @@ function matchesGitignorePattern(pattern, relativePath, isDirectory) {
225
227
  return candidates.some((value) => minimatch(value, expanded, options));
226
228
  });
227
229
  }
228
- export function readGitignorePatterns(rootDir) {
230
+ export function readGitignorePatterns(rootDir, layer = "gitignore") {
229
231
  const gitignorePath = path.join(rootDir, ".gitignore");
230
232
  try {
231
- return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8"));
233
+ return parseGitignoreContent(fs.readFileSync(gitignorePath, "utf-8"), layer);
232
234
  }
233
235
  catch {
234
236
  return [];
@@ -264,7 +266,7 @@ function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
264
266
  });
265
267
  return nextPatterns;
266
268
  };
267
- // Per-matcher path → boolean memo. The matcher itself is cached by
269
+ // Per-matcher path → pattern-verdict memo. The matcher itself is cached by
268
270
  // `getProjectIgnoreMatcher` keyed on `.gitignore` mtime, so this Map's
269
271
  // lifetime is bounded to a single set of ignore rules — when any
270
272
  // `.gitignore` changes, the matcher is rebuilt and the memo is dropped
@@ -272,41 +274,97 @@ function buildProjectIgnoreMatcher(resolvedRoot, patterns) {
272
274
  // jscpd, call-graph, source-filter, pipeline) recomputes O(ancestorDirs ×
273
275
  // patterns) per file, multiplying into 2-3s of pure CPU on a 2k-file
274
276
  // project. With it, the second visitor of the same path is O(1).
275
- const isIgnoredMemo = new Map();
277
+ //
278
+ // #703: this memoizes only the PATTERN verdict (which is deterministic —
279
+ // it never changes for a given matcher instance), NOT the tracked-aware
280
+ // final verdict. The tracked-files set can transition from "not yet
281
+ // primed" to "primed" mid-process (a walker calls `ensureTrackedIndex()`
282
+ // partway through the matcher's lifetime), so baking the tracked check
283
+ // into this memo would let an early, pre-priming call poison every later
284
+ // lookup of the same path for this matcher's entire cache lifetime. The
285
+ // tracked-set lookup itself is a cheap Set#has over syntactically-folded
286
+ // keys (see `isTrackedAndRescued` — no `realpathSync` anywhere in this
287
+ // function), and — critically — it's only ever paid for paths a pattern
288
+ // already flagged as ignored, so it doesn't reintroduce the per-file cost
289
+ // this memo exists to avoid for the common (not-ignored) case.
290
+ const patternMemo = new Map();
291
+ // #703 perf follow-up: `normalizeEphemeralMapKey` (cheap slash-fold +
292
+ // Windows-lowercase, zero fs I/O), NOT `normalizeMapKey` (realpath-backed).
293
+ // This runs on every `isIgnored` call that reaches this branch — walks
294
+ // over this repo alone visit thousands of pattern-ignored compiled
295
+ // `*.js`/`*.d.ts` files, and `dispatch/integration.ts`'s per-edit cascade
296
+ // check hits it too — so a `realpathSync` here would violate the
297
+ // event-loop/slow-FS discipline `isIgnored` is required to keep (#462: 75x
298
+ // slower on 9p/drvfs). Both `resolved` (this matcher's own `path.resolve`,
299
+ // never realpath'd) and the tracked-set's keys (`git-tracked-ignore.ts`,
300
+ // realpath'd ONCE per fetch on the shared root, not per file) are
301
+ // self-consistent within one process/session, which is exactly
302
+ // `normalizeEphemeralMapKey`'s contract — see that function's doc and
303
+ // `git-tracked-ignore.ts`'s module doc for the full reasoning. Accepted
304
+ // edge case: a symlinked or 8.3-short-name project root can make the cheap
305
+ // fold miss even after the fetch side's one realpath — the file then stays
306
+ // pattern-ignored, i.e. degrades to today's (pre-#703) behavior, which is
307
+ // consistent with this whole feature's fail-open contract.
308
+ function isTrackedAndRescued(resolved) {
309
+ const snapshot = getTrackedFilesSnapshot(resolvedRoot);
310
+ if (!snapshot)
311
+ return false; // never primed / git unavailable: fail-open to pattern-only
312
+ return snapshot.has(normalizeEphemeralMapKey(resolved));
313
+ }
276
314
  return {
277
315
  rootDir: resolvedRoot,
278
316
  patterns,
317
+ ensureTrackedIndex() {
318
+ return collectTrackedFiles(resolvedRoot).then(() => undefined);
319
+ },
279
320
  isIgnored(filePath, isDirectory = false) {
280
321
  const resolved = path.resolve(filePath);
281
322
  // Two namespaces (D: for directory queries, F: for file queries)
282
323
  // because gitignore semantics differ for trailing-slash patterns.
283
324
  const memoKey = (isDirectory ? "D:" : "F:") + resolved;
284
- const cached = isIgnoredMemo.get(memoKey);
285
- if (cached !== undefined)
286
- return cached;
287
- const rootRelative = path.relative(resolvedRoot, resolved);
288
- if (!rootRelative ||
289
- rootRelative.startsWith("..") ||
290
- path.isAbsolute(rootRelative)) {
291
- isIgnoredMemo.set(memoKey, false);
292
- return false;
293
- }
294
- let ignored = false;
295
- const patternDirs = ancestorDirsBetween(resolvedRoot, path.dirname(resolved));
296
- for (const dir of patternDirs) {
297
- const dirPatterns = patternsForDir(dir);
298
- if (dirPatterns.length === 0)
299
- continue;
300
- const relative = path.relative(dir, resolved);
301
- const normalized = normalizeIgnorePath(relative);
302
- for (const pattern of dirPatterns) {
303
- if (!matchesGitignorePattern(pattern, normalized, isDirectory))
304
- continue;
305
- ignored = !pattern.negated;
325
+ let verdict = patternMemo.get(memoKey);
326
+ if (verdict === undefined) {
327
+ const rootRelative = path.relative(resolvedRoot, resolved);
328
+ if (!rootRelative ||
329
+ rootRelative.startsWith("..") ||
330
+ path.isAbsolute(rootRelative)) {
331
+ verdict = { ignored: false, layer: undefined };
332
+ }
333
+ else {
334
+ let ignored = false;
335
+ let layer;
336
+ const patternDirs = ancestorDirsBetween(resolvedRoot, path.dirname(resolved));
337
+ for (const dir of patternDirs) {
338
+ const dirPatterns = patternsForDir(dir);
339
+ if (dirPatterns.length === 0)
340
+ continue;
341
+ const relative = path.relative(dir, resolved);
342
+ const normalized = normalizeIgnorePath(relative);
343
+ for (const pattern of dirPatterns) {
344
+ if (!matchesGitignorePattern(pattern, normalized, isDirectory))
345
+ continue;
346
+ ignored = !pattern.negated;
347
+ layer = pattern.layer;
348
+ }
349
+ }
350
+ verdict = { ignored, layer };
306
351
  }
352
+ patternMemo.set(memoKey, verdict);
307
353
  }
308
- isIgnoredMemo.set(memoKey, ignored);
309
- return ignored;
354
+ if (!verdict.ignored)
355
+ return false;
356
+ // #703 layer semantics: a winning positive match from `global` or
357
+ // `gitignore` emulates git, so it inherits git's "a tracked file is
358
+ // never ignored" rule. A winning match from `pilens` is pi-lens-native
359
+ // intent and stays excluded regardless of tracked status. Directory
360
+ // queries are never tracked-rescued — the tracked set is a file-id
361
+ // set, not a directory set.
362
+ if (!isDirectory &&
363
+ verdict.layer !== "pilens" &&
364
+ isTrackedAndRescued(resolved)) {
365
+ return false;
366
+ }
367
+ return true;
310
368
  },
311
369
  };
312
370
  }
@@ -315,10 +373,13 @@ export function createProjectIgnoreMatcher(rootDir, extraPatterns = [], globalPa
315
373
  // Precedence is gitignore order: LATER patterns override earlier ones. So
316
374
  // global (lowest) → project .gitignore → project .pi-lens.json (highest),
317
375
  // which lets a project `!negation` re-include a globally-ignored path (#252).
376
+ // Each layer is tagged (#703) so `isIgnored` can tell a git-emulating match
377
+ // (`global`/`gitignore` — subject to "a tracked file is never ignored")
378
+ // apart from pi-lens-native intent (`pilens` — excludes regardless).
318
379
  const patterns = [
319
- ...parseGitignoreContent(globalPatterns.join("\n")),
320
- ...readGitignorePatterns(resolvedRoot),
321
- ...parseGitignoreContent(extraPatterns.join("\n")),
380
+ ...parseGitignoreContent(globalPatterns.join("\n"), "global"),
381
+ ...readGitignorePatterns(resolvedRoot, "gitignore"),
382
+ ...parseGitignoreContent(extraPatterns.join("\n"), "pilens"),
322
383
  ];
323
384
  return buildProjectIgnoreMatcher(resolvedRoot, patterns);
324
385
  }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Publishes `pilens:format:queued`, `pilens:format:start`, and
3
+ * `pilens:autofix:start` on pi's shared `pi.events` bus (#673, #684).
4
+ *
5
+ * Sibling to `clients/bus-publish.ts` (#482 `pilens:files:touched`) and
6
+ * `clients/diagnostics-publish.ts` (#502 `pilens:diagnostics`) rather than a
7
+ * new export crammed into either file: same emit plumbing shape and the same
8
+ * `PI_LENS_BUS_PUBLISH` kill switch (`isBusPublishEnabled`, imported from
9
+ * bus-publish.ts), but this producer owns its own emit-function singleton
10
+ * (wired separately from index.ts, same as diagnostics-publish.ts does) since
11
+ * it has nothing else in common with either sibling's module state.
12
+ *
13
+ * ## Motivation (#673)
14
+ *
15
+ * `pilens:files:touched` only fires AFTER deferred formatting has completed —
16
+ * it tells a same-process listener what pi-lens just mutated, in the past
17
+ * tense. #673 reported a concrete failure mode this leaves uncovered: another
18
+ * in-process extension (a review/snapshot controller) derives an immutable
19
+ * candidate tree from the live worktree mid-turn, unaware that pi-lens has a
20
+ * file queued for deferred formatting that will still land — silently
21
+ * invalidating the snapshot it just took. There was no bus signal for either
22
+ * of the two moments that matter to that use case:
23
+ *
24
+ * 1. A file NEWLY entering the deferred-format pending queue
25
+ * (`pilens:format:queued` — one event per file, only on first queue
26
+ * entry; a file re-touched by a second edit before `agent_end` does NOT
27
+ * re-emit, to avoid event spam for repeated edits to the same file).
28
+ * 2. The deferred-format phase actually starting at `agent_end`
29
+ * (`pilens:format:start` — one event per batch, only when there is at
30
+ * least one queued file to format).
31
+ *
32
+ * This is deliberately visibility-only: it lets a listener know pi-lens MIGHT
33
+ * or IS ABOUT TO mutate specific files via deferred formatting, so it can
34
+ * choose to wait, re-derive, or flag its own snapshot as provisional. It is
35
+ * NOT a synchronous flush/barrier API (a caller cannot block deferred
36
+ * formatting via these events) — that remains a separate, explicitly
37
+ * out-of-scope future feature. Completion is already covered by the existing
38
+ * `pilens:files:touched` (`reason: "format"`) event; these two do not
39
+ * duplicate it.
40
+ *
41
+ * ## `pilens:autofix:start` (#684)
42
+ *
43
+ * Follow-up to #673/#674, applying the identical fix to a sibling race: the
44
+ * `agent_end` actionable-warnings autofix batch (`clients/runtime-agent-end.ts`,
45
+ * gated by `getFlag("lens-actionable-warning-autofix")`) also reads a cached
46
+ * report and applies fixes as a batch well after the edit that produced the
47
+ * warnings — same "runs later, unpredictably" shape as deferred formatting.
48
+ * It only emitted `pilens:files:touched` (`reason: "autofix"`) AFTER the fact.
49
+ * `pilens:autofix:start` fires once per batch, only when the cached
50
+ * actionable-warnings report is fresh (`checkActionableWarningsReportFresh`)
51
+ * AND has at least one autofix-eligible warning to act on — mirroring
52
+ * `pilens:format:start`'s "only when there's genuine work" gate. Note: this is
53
+ * distinct from `pipeline.ts`'s per-edit `runAutofix` (biome/ruff/eslint fixes
54
+ * applied synchronously inside the `tool_result` hook, awaited before the
55
+ * tool result returns) — that path isn't deferred and doesn't have this race,
56
+ * so it doesn't get a `start` event.
57
+ *
58
+ * Versioning policy: frozen-additive per event, same discipline as #482/#502.
59
+ * New optional fields may be added under the same `v: 1` for any of the three
60
+ * events; a breaking change to an existing field's meaning must bump that
61
+ * event's `v` independently (each event versions separately since they're
62
+ * unrelated payloads).
63
+ *
64
+ * Fire-and-forget: publishing must never affect the write path's or
65
+ * `agent_end`'s success or latency. Any failure (bus unavailable, emit
66
+ * throws) is swallowed; an optional `dbg` callback is invoked at most once
67
+ * per event type on first failure so a wired caller can log it without
68
+ * spamming.
69
+ */
70
+ import { logBusEvent } from "./bus-events-logger.js";
71
+ import { isBusPublishEnabled } from "./bus-publish.js";
72
+ import { normalizeFilePath } from "./path-utils.js";
73
+ export const BUS_FORMAT_QUEUED_EVENT = "pilens:format:queued";
74
+ export const BUS_FORMAT_QUEUED_VERSION = 1;
75
+ export const BUS_FORMAT_START_EVENT = "pilens:format:start";
76
+ export const BUS_FORMAT_START_VERSION = 1;
77
+ export const BUS_AUTOFIX_START_EVENT = "pilens:autofix:start";
78
+ export const BUS_AUTOFIX_START_VERSION = 1;
79
+ let busEmit;
80
+ let hasLoggedQueuedFailure = false;
81
+ let hasLoggedQueuedUnwired = false;
82
+ let hasLoggedQueuedDisabled = false;
83
+ let hasLoggedStartFailure = false;
84
+ let hasLoggedStartUnwired = false;
85
+ let hasLoggedStartDisabled = false;
86
+ let hasLoggedAutofixStartFailure = false;
87
+ let hasLoggedAutofixStartUnwired = false;
88
+ let hasLoggedAutofixStartDisabled = false;
89
+ /**
90
+ * Wire the emit function from pi's `pi.events` bus. Called once at extension
91
+ * factory time from index.ts, alongside `wireBusEmitter` (#482) and
92
+ * `wireDiagnosticsBusEmitter` (#502) — all three producers share the
93
+ * identical `pi.events.emit` binding, wired separately per producer.
94
+ */
95
+ export function wireFormatEventsBusEmitter(emitFn) {
96
+ busEmit = emitFn;
97
+ }
98
+ /** Test-only: reset module state between test files. */
99
+ export function _resetFormatEventsPublishForTests() {
100
+ busEmit = undefined;
101
+ hasLoggedQueuedFailure = false;
102
+ hasLoggedQueuedUnwired = false;
103
+ hasLoggedQueuedDisabled = false;
104
+ hasLoggedStartFailure = false;
105
+ hasLoggedStartUnwired = false;
106
+ hasLoggedStartDisabled = false;
107
+ hasLoggedAutofixStartFailure = false;
108
+ hasLoggedAutofixStartUnwired = false;
109
+ hasLoggedAutofixStartDisabled = false;
110
+ }
111
+ /**
112
+ * Publish one `pilens:format:queued` event for a file newly entering the
113
+ * deferred-format pending queue. Callers MUST only invoke this on the
114
+ * NEW-entry branch of `RuntimeCoordinator.deferFormat` (not on a re-touch of
115
+ * an already-queued file) — see `clients/runtime-tool-result.ts`'s call site.
116
+ * Fire-and-forget: never throws, never awaited by the write path.
117
+ */
118
+ export function publishFormatQueued(args) {
119
+ if (!isBusPublishEnabled()) {
120
+ if (!hasLoggedQueuedDisabled) {
121
+ hasLoggedQueuedDisabled = true;
122
+ logBusEvent({
123
+ event: BUS_FORMAT_QUEUED_EVENT,
124
+ outcome: "skipped_disabled",
125
+ cwd: normalizeFilePath(args.cwd),
126
+ });
127
+ }
128
+ return;
129
+ }
130
+ if (!busEmit) {
131
+ if (!hasLoggedQueuedUnwired) {
132
+ hasLoggedQueuedUnwired = true;
133
+ logBusEvent({
134
+ event: BUS_FORMAT_QUEUED_EVENT,
135
+ outcome: "skipped_unwired",
136
+ cwd: normalizeFilePath(args.cwd),
137
+ });
138
+ }
139
+ return;
140
+ }
141
+ try {
142
+ const payload = {
143
+ v: BUS_FORMAT_QUEUED_VERSION,
144
+ source: "pi-lens",
145
+ filePath: normalizeFilePath(args.filePath),
146
+ cwd: normalizeFilePath(args.cwd),
147
+ tool: args.tool,
148
+ };
149
+ busEmit(BUS_FORMAT_QUEUED_EVENT, payload);
150
+ logBusEvent({
151
+ event: BUS_FORMAT_QUEUED_EVENT,
152
+ outcome: "emitted",
153
+ cwd: payload.cwd,
154
+ fileCount: 1,
155
+ });
156
+ }
157
+ catch (err) {
158
+ logBusEvent({
159
+ event: BUS_FORMAT_QUEUED_EVENT,
160
+ outcome: "emit_failed",
161
+ cwd: normalizeFilePath(args.cwd),
162
+ error: String(err),
163
+ });
164
+ if (!hasLoggedQueuedFailure) {
165
+ hasLoggedQueuedFailure = true;
166
+ args.dbg?.(`format-events-publish: pilens:format:queued emit failed (further failures suppressed): ${err}`);
167
+ }
168
+ }
169
+ }
170
+ /**
171
+ * Publish one `pilens:format:start` event when the `agent_end` deferred-
172
+ * format phase begins. Callers MUST only invoke this when `paths.length > 0`
173
+ * — see `clients/runtime-agent-end.ts`'s call site, placed at the same point
174
+ * as the existing `agent_end_deferred_format_start` latency-log phase so both
175
+ * signals represent the identical moment. Fire-and-forget: never throws,
176
+ * never awaited by `agent_end`.
177
+ */
178
+ export function publishFormatStart(args) {
179
+ if (args.paths.length === 0)
180
+ return;
181
+ if (!isBusPublishEnabled()) {
182
+ if (!hasLoggedStartDisabled) {
183
+ hasLoggedStartDisabled = true;
184
+ logBusEvent({
185
+ event: BUS_FORMAT_START_EVENT,
186
+ outcome: "skipped_disabled",
187
+ cwd: normalizeFilePath(args.cwd),
188
+ });
189
+ }
190
+ return;
191
+ }
192
+ if (!busEmit) {
193
+ if (!hasLoggedStartUnwired) {
194
+ hasLoggedStartUnwired = true;
195
+ logBusEvent({
196
+ event: BUS_FORMAT_START_EVENT,
197
+ outcome: "skipped_unwired",
198
+ cwd: normalizeFilePath(args.cwd),
199
+ });
200
+ }
201
+ return;
202
+ }
203
+ try {
204
+ const paths = args.paths.map((p) => normalizeFilePath(p));
205
+ const payload = {
206
+ v: BUS_FORMAT_START_VERSION,
207
+ source: "pi-lens",
208
+ cwd: normalizeFilePath(args.cwd),
209
+ paths,
210
+ fileCount: paths.length,
211
+ };
212
+ busEmit(BUS_FORMAT_START_EVENT, payload);
213
+ logBusEvent({
214
+ event: BUS_FORMAT_START_EVENT,
215
+ outcome: "emitted",
216
+ cwd: payload.cwd,
217
+ fileCount: payload.fileCount,
218
+ });
219
+ }
220
+ catch (err) {
221
+ logBusEvent({
222
+ event: BUS_FORMAT_START_EVENT,
223
+ outcome: "emit_failed",
224
+ cwd: normalizeFilePath(args.cwd),
225
+ error: String(err),
226
+ });
227
+ if (!hasLoggedStartFailure) {
228
+ hasLoggedStartFailure = true;
229
+ args.dbg?.(`format-events-publish: pilens:format:start emit failed (further failures suppressed): ${err}`);
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Publish one `pilens:autofix:start` event when the `agent_end`
235
+ * actionable-warnings autofix batch begins (#684). Callers MUST only invoke
236
+ * this when `paths.length > 0` — i.e. after the cached report has been
237
+ * confirmed fresh (`checkActionableWarningsReportFresh`) AND has at least one
238
+ * autofix-eligible warning to act on, the same "genuine work" gate
239
+ * `publishFormatStart` applies for deferred formatting — see
240
+ * `clients/runtime-agent-end.ts`'s call site, placed immediately before
241
+ * `applyConservativeActionableWarningFixes` is invoked. Fire-and-forget:
242
+ * never throws, never awaited by `agent_end`.
243
+ */
244
+ export function publishAutofixStart(args) {
245
+ if (args.paths.length === 0)
246
+ return;
247
+ if (!isBusPublishEnabled()) {
248
+ if (!hasLoggedAutofixStartDisabled) {
249
+ hasLoggedAutofixStartDisabled = true;
250
+ logBusEvent({
251
+ event: BUS_AUTOFIX_START_EVENT,
252
+ outcome: "skipped_disabled",
253
+ cwd: normalizeFilePath(args.cwd),
254
+ });
255
+ }
256
+ return;
257
+ }
258
+ if (!busEmit) {
259
+ if (!hasLoggedAutofixStartUnwired) {
260
+ hasLoggedAutofixStartUnwired = true;
261
+ logBusEvent({
262
+ event: BUS_AUTOFIX_START_EVENT,
263
+ outcome: "skipped_unwired",
264
+ cwd: normalizeFilePath(args.cwd),
265
+ });
266
+ }
267
+ return;
268
+ }
269
+ try {
270
+ const paths = args.paths.map((p) => normalizeFilePath(p));
271
+ const payload = {
272
+ v: BUS_AUTOFIX_START_VERSION,
273
+ source: "pi-lens",
274
+ cwd: normalizeFilePath(args.cwd),
275
+ paths,
276
+ fileCount: paths.length,
277
+ eligibleCount: args.eligibleCount,
278
+ };
279
+ busEmit(BUS_AUTOFIX_START_EVENT, payload);
280
+ logBusEvent({
281
+ event: BUS_AUTOFIX_START_EVENT,
282
+ outcome: "emitted",
283
+ cwd: payload.cwd,
284
+ fileCount: payload.fileCount,
285
+ });
286
+ }
287
+ catch (err) {
288
+ logBusEvent({
289
+ event: BUS_AUTOFIX_START_EVENT,
290
+ outcome: "emit_failed",
291
+ cwd: normalizeFilePath(args.cwd),
292
+ error: String(err),
293
+ });
294
+ if (!hasLoggedAutofixStartFailure) {
295
+ hasLoggedAutofixStartFailure = true;
296
+ args.dbg?.(`format-events-publish: pilens:autofix:start emit failed (further failures suppressed): ${err}`);
297
+ }
298
+ }
299
+ }