pi-lens 3.8.67 → 3.8.69

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 (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +57 -38
  3. package/dist/clients/agent-nudge.js +262 -0
  4. package/dist/clients/biome-client.js +2 -2
  5. package/dist/clients/bus-publish.js +110 -0
  6. package/dist/clients/deps/ast-grep-napi.js +20 -1
  7. package/dist/clients/deps/pi-tui.js +4 -1
  8. package/dist/clients/deps/typebox.js +5 -2
  9. package/dist/clients/deps/web-tree-sitter.js +22 -1
  10. package/dist/clients/diagnostic-logger.js +2 -2
  11. package/dist/clients/diagnostics-publish.js +180 -0
  12. package/dist/clients/dispatch/dispatcher.js +2 -0
  13. package/dist/clients/dispatch/integration.js +153 -2
  14. package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
  15. package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
  16. package/dist/clients/file-utils.js +20 -7
  17. package/dist/clients/installer/index.js +32 -1
  18. package/dist/clients/instance-reaper.js +516 -0
  19. package/dist/clients/instance-registry.js +238 -0
  20. package/dist/clients/jscpd-client.js +2 -2
  21. package/dist/clients/lens-config.js +17 -0
  22. package/dist/clients/lens-engine.js +44 -10
  23. package/dist/clients/lsp/cascade-tier.js +254 -0
  24. package/dist/clients/lsp/client.js +81 -3
  25. package/dist/clients/lsp/index.js +3 -0
  26. package/dist/clients/lsp/launch.js +2 -0
  27. package/dist/clients/lsp/server-strategies.js +71 -0
  28. package/dist/clients/lsp/server.js +119 -6
  29. package/dist/clients/mcp/analyze.js +110 -1
  30. package/dist/clients/module-report.js +163 -18
  31. package/dist/clients/path-utils.js +25 -0
  32. package/dist/clients/persist-debounce.js +63 -0
  33. package/dist/clients/pipeline.js +82 -2
  34. package/dist/clients/project-diagnostics/extractors.js +30 -4
  35. package/dist/clients/project-snapshot.js +7 -2
  36. package/dist/clients/quiet-window.js +168 -0
  37. package/dist/clients/recent-touches.js +233 -0
  38. package/dist/clients/review-graph/builder.js +20 -2
  39. package/dist/clients/runtime-agent-end.js +51 -1
  40. package/dist/clients/runtime-coordinator.js +21 -0
  41. package/dist/clients/runtime-session.js +154 -48
  42. package/dist/clients/runtime-tool-result.js +46 -0
  43. package/dist/clients/runtime-turn.js +9 -0
  44. package/dist/clients/session-lifecycle.js +252 -0
  45. package/dist/clients/sgconfig.js +246 -38
  46. package/dist/clients/slow-fs.js +137 -0
  47. package/dist/clients/source-filter.js +62 -14
  48. package/dist/clients/subagent-mode.js +87 -0
  49. package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
  50. package/dist/clients/tui-fit.js +54 -0
  51. package/dist/clients/turn-summary-render.js +72 -0
  52. package/dist/clients/turn-summary.js +132 -0
  53. package/dist/clients/widget-state.js +27 -30
  54. package/dist/clients/word-index.js +296 -1
  55. package/dist/index.js +62809 -1633
  56. package/dist/mcp/build-staleness.js +123 -0
  57. package/dist/mcp/server.js +377 -43
  58. package/dist/tools/ast-grep-search.js +1 -1
  59. package/dist/tools/lens-diagnostics.js +42 -12
  60. package/dist/tools/lsp-diagnostics.js +117 -4
  61. package/dist/tools/module-report.js +14 -11
  62. package/dist/tools/symbol-search.js +110 -0
  63. package/package.json +3 -2
  64. package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
  65. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
  66. package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
  67. package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
  68. package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
  69. package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
  70. package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
  71. package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
  72. package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
  73. package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
  74. package/dist/clients/tree-sitter-fixer.js +0 -127
@@ -297,7 +297,7 @@ function coldImports(imports, languageId, absPath, projectRoot) {
297
297
  warnings,
298
298
  };
299
299
  }
300
- function toEntry(sym, displayPath, normalizedPath, graph, maxRefs, projectRoot) {
300
+ function toEntry(sym, normalizedPath, graph, maxRefs, projectRoot) {
301
301
  const startLine = sym.line;
302
302
  const endLine = sym.endLine ?? sym.line;
303
303
  const symbolNodeId = `${normalizedPath}:${sym.name}`;
@@ -311,14 +311,14 @@ function toEntry(sym, displayPath, normalizedPath, graph, maxRefs, projectRoot)
311
311
  : undefined;
312
312
  // A private/protected member of an exported class is reachable but NOT part
313
313
  // of the public API, so it must not count as `exported` for the api/internal
314
- // split, the "exported" flag, or read ranking (#258). The extractor's
315
- // sym.isExported is untouched (the review graph still sees the full surface);
316
- // this gating is local to the report's presentation.
314
+ // split or read ranking (#258). The extractor's sym.isExported is untouched
315
+ // (the review graph still sees the full surface); this gating is local to
316
+ // the report's presentation.
317
317
  const nonPublic = sym.visibility === "private" || sym.visibility === "protected";
318
318
  const exported = (sym.isExported || !!node?.exported) && !nonPublic;
319
+ // `flags` carries only non-derivable signals — "exported" is NOT pushed here
320
+ // since it duplicates the `exported` boolean field below (#512).
319
321
  const flags = [];
320
- if (exported)
321
- flags.push("exported");
322
322
  if (sym.isAsync)
323
323
  flags.push("async");
324
324
  if (fanout !== undefined && fanout >= 4)
@@ -346,7 +346,6 @@ function toEntry(sym, displayPath, normalizedPath, graph, maxRefs, projectRoot)
346
346
  // outline (~200 tok total). Omit when there's nothing to report.
347
347
  ...(flags.length > 0 ? { flags } : {}),
348
348
  usedBy: usedBy && usedBy.length > 0 ? usedBy : undefined,
349
- read: readArgsFor(displayPath, startLine, endLine),
350
349
  };
351
350
  }
352
351
  // Nest members under their container by line-range containment (#301), mirroring
@@ -399,8 +398,8 @@ function summarizeEntries(entries) {
399
398
  exported: entry.exported,
400
399
  ...(entry.visibility ? { visibility: entry.visibility } : {}),
401
400
  ...(entry.signature ? { signature: entry.signature } : {}),
401
+ ...(entry.doc ? { doc: entry.doc } : {}),
402
402
  ...(entry.flags ? { flags: entry.flags } : {}),
403
- read: entry.read,
404
403
  ...(entry.members
405
404
  ? {
406
405
  members: entry.members.map((member) => ({
@@ -411,7 +410,7 @@ function summarizeEntries(entries) {
411
410
  exported: member.exported,
412
411
  ...(member.visibility ? { visibility: member.visibility } : {}),
413
412
  ...(member.signature ? { signature: member.signature } : {}),
414
- read: member.read,
413
+ ...(member.doc ? { doc: member.doc } : {}),
415
414
  })),
416
415
  }
417
416
  : {}),
@@ -483,7 +482,8 @@ function rankRecommendedReads(entries, callbacks = [], limit = 5, focus) {
483
482
  return {
484
483
  reason: reasons.join(", ") || "public surface",
485
484
  symbol: entry.name,
486
- ...entry.read,
485
+ startLine: entry.startLine,
486
+ endLine: entry.endLine,
487
487
  };
488
488
  }
489
489
  const reasons = [];
@@ -500,7 +500,8 @@ function rankRecommendedReads(entries, callbacks = [], limit = 5, focus) {
500
500
  return {
501
501
  reason: reasons.join(", ") || item.callback.kind,
502
502
  symbol: item.callback.name,
503
- ...item.callback.read,
503
+ startLine: item.callback.startLine,
504
+ endLine: item.callback.endLine,
504
505
  };
505
506
  });
506
507
  }
@@ -1030,7 +1031,7 @@ function callbackRulesFor(languageId) {
1030
1031
  function callbackSupportFor(languageId) {
1031
1032
  return languageId && CALLBACK_RULES[languageId] ? "tuned" : "generic";
1032
1033
  }
1033
- function extractCallbacks(root, entries, filePath, languageId, warnings) {
1034
+ function extractCallbacks(root, entries, languageId, warnings) {
1034
1035
  if (!root)
1035
1036
  return [];
1036
1037
  const rules = callbackRulesFor(languageId);
@@ -1073,7 +1074,6 @@ function extractCallbacks(root, entries, filePath, languageId, warnings) {
1073
1074
  signature: firstLine(node.text),
1074
1075
  ...(owner ? { parentChain: [owner] } : {}),
1075
1076
  ...(cls.flags ? { flags: cls.flags } : {}),
1076
- read: readArgsFor(filePath, startLine, endLine),
1077
1077
  });
1078
1078
  }
1079
1079
  }
@@ -1158,14 +1158,14 @@ export async function moduleReport(file, cwd, options) {
1158
1158
  // Flat entries first — ranking and cold-import resolution both read the full
1159
1159
  // list. `entries` is mutated by nestEntries (members attached); `topLevel` is
1160
1160
  // the api/internal split surface.
1161
- const entries = outlineSymbols.map((sym) => toEntry(sym, absPath, normalizedPath, graph, maxRefs, cwd));
1161
+ const entries = outlineSymbols.map((sym) => toEntry(sym, normalizedPath, graph, maxRefs, cwd));
1162
1162
  const topLevel = nestEntries(entries);
1163
1163
  const api = topLevel.filter((entry) => entry.exported);
1164
1164
  const internal = topLevel.filter((entry) => !entry.exported);
1165
1165
  let callbacks = [];
1166
1166
  const warnings = [...(extractionWarnings ?? [])];
1167
1167
  try {
1168
- callbacks = extractCallbacks(root, entries, absPath, languageId, warnings);
1168
+ callbacks = extractCallbacks(root, entries, languageId, warnings);
1169
1169
  }
1170
1170
  catch (err) {
1171
1171
  const message = diagnosticMessage(err);
@@ -1196,6 +1196,18 @@ export async function moduleReport(file, cwd, options) {
1196
1196
  }
1197
1197
  : warmImports;
1198
1198
  const hasGraphNode = graph?.fileNodes.has(normalizedPath) ?? false;
1199
+ // #511: distinguish two very different reasons `usedBy`/`semantic` degrade to
1200
+ // "none". A graph that doesn't exist at all (`!graph`) is an honest, expected
1201
+ // cold start — the edit pipeline hasn't warmed this workspace yet. But a graph
1202
+ // that DOES exist and just doesn't have a node for THIS file (e.g. the file was
1203
+ // added/renamed after the graph was last persisted) is silently
1204
+ // indistinguishable from "no who-uses-this data exists" unless we say so. Make
1205
+ // the second case actionable: a rebuild (pilens_rebuild) would populate it.
1206
+ if (graph && !hasGraphNode) {
1207
+ warnings.push("who-uses-this is unavailable for this file: the cached review graph " +
1208
+ "exists but has no node for it (likely added/changed after the graph " +
1209
+ "was last built). Run pilens_rebuild to refresh it.");
1210
+ }
1199
1211
  // Cross-file blast radius (#304): opt-in, read-only over the CACHED graph. Only
1200
1212
  // computed when requested AND the file is in a warm graph — a cold cache omits
1201
1213
  // the section entirely (never builds, same #256 contract as the rest of this
@@ -1205,6 +1217,11 @@ export async function moduleReport(file, cwd, options) {
1205
1217
  : undefined;
1206
1218
  const view = options?.view ?? "default";
1207
1219
  const summaryView = view === "summary";
1220
+ // "compact" computes the same full data as "default" — it's a rendering
1221
+ // instruction for the caller (renderCompactModuleReport), not a data tier —
1222
+ // so it only needs to echo back on the report; it never gates section content
1223
+ // the way summaryView does.
1224
+ const compactView = view === "compact";
1208
1225
  let importsProvenance = "none";
1209
1226
  if (coldImportResult) {
1210
1227
  importsProvenance = "syntax";
@@ -1232,7 +1249,9 @@ export async function moduleReport(file, cwd, options) {
1232
1249
  callbackSupport: callbackSupportFor(languageId),
1233
1250
  recommendedReads: rankRecommendedReads(entries, callbacks, 5, options?.focus),
1234
1251
  ...(summaryView ? { view: "summary" } : {}),
1252
+ ...(compactView ? { view: "compact" } : {}),
1235
1253
  ...(blastRadius && !summaryView ? { blastRadius } : {}),
1254
+ ...(graph ? { graphBuiltAt: graph.builtAt } : {}),
1236
1255
  provenance: {
1237
1256
  symbols: languageId ? "syntax" : "none",
1238
1257
  imports: importsProvenance,
@@ -1267,6 +1286,132 @@ export async function moduleReport(file, cwd, options) {
1267
1286
  });
1268
1287
  return report;
1269
1288
  }
1289
+ // --- Compact (line-oriented text) rendering (#512 slice 4) ------------------
1290
+ //
1291
+ // An opt-in `view: "compact"` alternative to the JSON report: one line per
1292
+ // symbol/callback instead of a repeated-keys JSON object, at roughly a quarter
1293
+ // of the token cost for the same information. Purely a rendering step over the
1294
+ // already-built ModuleReport — it changes no data, only presentation, so a
1295
+ // caller that wants JSON just skips this function. Default view stays JSON
1296
+ // (this is opt-in for dogfooding, not a default flip).
1297
+ function padRange(startLine, endLine, width) {
1298
+ return `${startLine}-${endLine}`.padEnd(width);
1299
+ }
1300
+ const KIND_ABBREV = {
1301
+ function: "fn",
1302
+ method: "fn",
1303
+ class: "class",
1304
+ interface: "iface",
1305
+ type: "type",
1306
+ variable: "var",
1307
+ property: "prop",
1308
+ };
1309
+ function compactKind(kind) {
1310
+ return KIND_ABBREV[kind] ?? kind;
1311
+ }
1312
+ function compactUsedBySuffix(usedBy) {
1313
+ if (!usedBy || usedBy.length === 0)
1314
+ return "";
1315
+ const counts = new Map();
1316
+ for (const u of usedBy)
1317
+ counts.set(u.file, (counts.get(u.file) ?? 0) + 1);
1318
+ const parts = [...counts.entries()].map(([file, n]) => n > 1 ? `${file}×${n}` : file);
1319
+ return ` used-by: ${parts.join(", ")}`;
1320
+ }
1321
+ function compactEntryLine(entry, width) {
1322
+ const range = padRange(entry.startLine, entry.endLine, width);
1323
+ const kind = compactKind(entry.kind).padEnd(6);
1324
+ const sig = entry.signature ? `${entry.name}${entry.signature}` : entry.name;
1325
+ const flagsSuffix = entry.flags && entry.flags.length > 0 ? ` [${entry.flags.join(", ")}]` : "";
1326
+ const docSuffix = entry.doc ? ` — ${entry.doc}` : "";
1327
+ const usedBySuffix = compactUsedBySuffix(entry.usedBy);
1328
+ return ` ${range}${kind}${sig}${flagsSuffix}${usedBySuffix}${docSuffix}`;
1329
+ }
1330
+ function compactMemberLines(entry, width, indent) {
1331
+ if (!entry.members || entry.members.length === 0)
1332
+ return [];
1333
+ return entry.members.map((m) => `${indent}${compactEntryLine(m, width).slice(2)}`);
1334
+ }
1335
+ function compactCallbackLine(callback, width) {
1336
+ const range = padRange(callback.startLine, callback.endLine, width);
1337
+ const kind = callback.kind.padEnd(20);
1338
+ const flagsSuffix = callback.flags && callback.flags.length > 0
1339
+ ? ` [${callback.flags.join(", ")}]`
1340
+ : "";
1341
+ const ownerSuffix = callback.parentChain?.length
1342
+ ? ` (in ${callback.parentChain.join(".")})`
1343
+ : "";
1344
+ return ` ${range}${kind}${callback.name}${flagsSuffix}${ownerSuffix}`;
1345
+ }
1346
+ /**
1347
+ * Render a ModuleReport as the line-oriented compact text view (#512 slice 4):
1348
+ * one line per symbol/member/callback instead of a JSON object per entry.
1349
+ * Example:
1350
+ * ```
1351
+ * clients/agent-nudge.ts jsts 266L — 8 symbols, 5 exported | imports: bus-publish, latency-logger
1352
+ * API:
1353
+ * 77-81 fn _resetAgentNudgeForTests() — Test-only: clear accumulator state.
1354
+ * INTERNAL:
1355
+ * 95-104 fn isValidPayload(data: unknown)
1356
+ * CALLBACKS:
1357
+ * 164-172 event_handler events.on@164 [lifecycle] (in wireAgentNudgeSubscriber)
1358
+ * ```
1359
+ * Purely presentational over an already-built report — call `moduleReport`
1360
+ * first (with `view: "compact"` or any other view) and pass its result here.
1361
+ */
1362
+ export function renderCompactModuleReport(report) {
1363
+ if (!report.available) {
1364
+ return `${report.path} — unavailable${report.error ? `: ${report.error}` : ""}`;
1365
+ }
1366
+ const allEntries = [...report.api, ...report.internal];
1367
+ const allRanges = allEntries.flatMap((e) => [
1368
+ e,
1369
+ ...(e.members ?? []),
1370
+ ]);
1371
+ const width = Math.max(5, ...allRanges.map((e) => `${e.startLine}-${e.endLine}`.length), ...report.callbacks.map((c) => `${c.startLine}-${c.endLine}`.length)) + 2;
1372
+ const importsList = [...report.imports.internal, ...report.imports.external];
1373
+ const importsSuffix = importsList.length > 0
1374
+ ? ` | imports: ${importsList
1375
+ .slice(0, 6)
1376
+ .map((i) => baseNameNoExt(i))
1377
+ .join(", ")}${importsList.length > 6 ? ", …" : ""}`
1378
+ : "";
1379
+ const lines = [
1380
+ `${report.path} ${report.language ?? "?"} ${report.lineCount ?? "?"}L — ` +
1381
+ `${report.summary.symbols} symbols, ${report.summary.exports} exported${importsSuffix}`,
1382
+ ];
1383
+ if (report.api.length > 0) {
1384
+ lines.push("API:");
1385
+ for (const entry of report.api) {
1386
+ lines.push(compactEntryLine(entry, width));
1387
+ lines.push(...compactMemberLines(entry, width, " "));
1388
+ }
1389
+ }
1390
+ if (report.internal.length > 0) {
1391
+ lines.push("INTERNAL:");
1392
+ for (const entry of report.internal) {
1393
+ lines.push(compactEntryLine(entry, width));
1394
+ lines.push(...compactMemberLines(entry, width, " "));
1395
+ }
1396
+ }
1397
+ if (report.callbacks.length > 0) {
1398
+ lines.push("CALLBACKS:");
1399
+ for (const callback of report.callbacks) {
1400
+ lines.push(compactCallbackLine(callback, width));
1401
+ }
1402
+ }
1403
+ if (report.recommendedReads.length > 0) {
1404
+ lines.push("RECOMMENDED:");
1405
+ for (const r of report.recommendedReads) {
1406
+ lines.push(` ${padRange(r.startLine, r.endLine, width)}${r.symbol ?? ""} — ${r.reason}`);
1407
+ }
1408
+ }
1409
+ return lines.join("\n");
1410
+ }
1411
+ function baseNameNoExt(p) {
1412
+ const base = p.split(/[\\/]/).pop() ?? p;
1413
+ return base.replace(/\.[^./\\]+$/, "");
1414
+ }
1270
1415
  /**
1271
1416
  * Return the verbatim body of a single symbol. Unlike moduleReport (which shows
1272
1417
  * shape, not content), this delivers the actual source lines — so the host can
@@ -1339,7 +1484,7 @@ export async function readSymbol(file, symbolName, cwd) {
1339
1484
  let callback;
1340
1485
  const callbackWarnings = [...(extractionWarnings ?? [])];
1341
1486
  try {
1342
- callback = extractCallbacks(root, owners, absPath, languageId, callbackWarnings).find((candidate) => candidate.name === symbolName);
1487
+ callback = extractCallbacks(root, owners, languageId, callbackWarnings).find((candidate) => candidate.name === symbolName);
1343
1488
  }
1344
1489
  catch (err) {
1345
1490
  const message = `Callback extraction failed: ${diagnosticMessage(err)}`;
@@ -1460,7 +1605,7 @@ function enclosingOutline(selected, symbols, callbacks, filters, filePath) {
1460
1605
  endLine: callback.endLine,
1461
1606
  signature: callback.signature,
1462
1607
  parentChain: callback.parentChain,
1463
- read: callback.read,
1608
+ read: readArgsFor(filePath, callback.startLine, callback.endLine),
1464
1609
  });
1465
1610
  }
1466
1611
  return items
@@ -1521,7 +1666,7 @@ export async function readEnclosing(file, line, cwd, options) {
1521
1666
  const warnings = [...(extractionWarnings ?? [])];
1522
1667
  let callbacks = [];
1523
1668
  try {
1524
- callbacks = extractCallbacks(root, owners, absPath, languageId, warnings);
1669
+ callbacks = extractCallbacks(root, owners, languageId, warnings);
1525
1670
  }
1526
1671
  catch (err) {
1527
1672
  const message = `Callback extraction failed: ${diagnosticMessage(err)}`;
@@ -124,6 +124,31 @@ export function pathToUri(filePath) {
124
124
  export function normalizeMapKey(filePath) {
125
125
  return normalizeFilePath(filePath);
126
126
  }
127
+ /**
128
+ * Cheap, syntactic-only Map key normalization: slash-fold + (on Windows)
129
+ * lowercase. No `realpathSync` / filesystem I/O.
130
+ *
131
+ * `normalizeMapKey` (via `normalizeFilePath`) calls `realpathSync.native()` to
132
+ * get canonical on-disk casing — correct for maps that key long-lived state
133
+ * shared across call sites (e.g. LSP/read-guard caches), but expensive when
134
+ * the *point* of the cache is to avoid filesystem calls in the first place:
135
+ * for a candidate path that does NOT exist (the common case for sibling-probe
136
+ * memos), `normalizeFilePath` walks up the directory tree doing its own
137
+ * `existsSync` calls to resolve the nearest existing ancestor — measured at
138
+ * ~11x slower than the single `existsSync` probe such a cache is trying to
139
+ * save (refs #191).
140
+ *
141
+ * Safe to use ONLY for ephemeral, single-process, single-walk caches whose
142
+ * keys are produced by this process's own `path.join`/`path.resolve` calls
143
+ * within the same run (so separators and casing are already consistent
144
+ * modulo simple slash direction) — never for state shared across processes,
145
+ * persisted, or compared against externally-supplied paths where symlink /
146
+ * real-casing resolution actually matters.
147
+ */
148
+ export function normalizeEphemeralMapKey(filePath) {
149
+ const slashed = filePath.replace(/\\/g, "/");
150
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
151
+ }
127
152
  /**
128
153
  * Compare two file paths for equality, handling Windows case-insensitivity
129
154
  * and mixed separators (backslash vs forward slash).
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Generalized debounced-persist coalescing scheduler (#348 phase 2), factored
3
+ * out of the review graph's #260 circuit-breaker so a second warm cache
4
+ * (the word index) doesn't grow a parallel copy of the same discipline:
5
+ * 1. Coalesce: a burst of edits schedules ONE write after a quiet window,
6
+ * instead of one write per edit.
7
+ * 2. Best-effort teardown flush: a process-exit hook flushes any pending
8
+ * writes synchronously so a debounced payload is never silently lost.
9
+ *
10
+ * What stayed OUT of this module (left to each caller, deliberately not
11
+ * generalized): the actual serialize+write. The review graph writes its own
12
+ * cache file (tmp+rename, size-cap check, git-stamp) while the word index
13
+ * merges into the shared project-snapshot file via `saveProjectSnapshot`
14
+ * (preserving unrelated fields, honoring the seq-laundering guard). Those
15
+ * writers are different enough (different files, different merge semantics)
16
+ * that forcing them through one write function would either leak
17
+ * graph-specific concerns into the snapshot path or vice versa. The
18
+ * scheduler only owns "when", never "how".
19
+ */
20
+ /**
21
+ * Build a debounce scheduler over `write(key, payload)`. `debounceMs()` is
22
+ * read fresh on every schedule call (not captured once) so a test that
23
+ * flips its env override mid-run takes effect immediately, same as the
24
+ * graph's `graphPersistDebounceMs()`.
25
+ */
26
+ export function createDebounceScheduler(args) {
27
+ const { write, debounceMs } = args;
28
+ const pending = new Map();
29
+ const timers = new Map();
30
+ function flush(key) {
31
+ const payload = pending.get(key);
32
+ if (payload === undefined)
33
+ return;
34
+ pending.delete(key);
35
+ const timer = timers.get(key);
36
+ if (timer) {
37
+ clearTimeout(timer);
38
+ timers.delete(key);
39
+ }
40
+ write(key, payload);
41
+ }
42
+ function flushAll() {
43
+ for (const key of [...pending.keys()])
44
+ flush(key);
45
+ }
46
+ function schedule(key, payload) {
47
+ pending.set(key, payload);
48
+ const debounce = debounceMs();
49
+ const existing = timers.get(key);
50
+ if (existing)
51
+ clearTimeout(existing);
52
+ if (debounce === 0) {
53
+ flush(key);
54
+ return;
55
+ }
56
+ const timer = setTimeout(() => flush(key), debounce);
57
+ // Don't keep the event loop alive solely for a debounced write.
58
+ if (typeof timer.unref === "function")
59
+ timer.unref();
60
+ timers.set(key, timer);
61
+ }
62
+ return { schedule, flush, flushAll };
63
+ }
@@ -27,6 +27,8 @@ import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
27
27
  import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
28
28
  import { logLatency } from "./latency-logger.js";
29
29
  import { emitLensAnalysisComplete } from "./lens-events.js";
30
+ import { publishFilesTouched } from "./bus-publish.js";
31
+ import { publishDiagnostics, wasPreviouslyReportedDirty, } from "./diagnostics-publish.js";
30
32
  import { getLSPService } from "./lsp/index.js";
31
33
  import { clearGraphCache } from "./review-graph/builder.js";
32
34
  import { RUNTIME_CONFIG } from "./runtime-config.js";
@@ -651,6 +653,23 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
651
653
  dbg(`LSP resync after autofix error: ${err}`);
652
654
  }
653
655
  }
656
+ /** Maps a dispatch `Diagnostic` (clients/dispatch/types.ts) to the #502 bus payload shape. */
657
+ function toPilensDiagnosticEntry(d) {
658
+ const entry = {
659
+ severity: d.severity,
660
+ message: d.message,
661
+ tool: d.tool,
662
+ };
663
+ if (d.rule !== undefined)
664
+ entry.ruleId = d.rule;
665
+ if (d.line !== undefined)
666
+ entry.line = d.line;
667
+ if (d.column !== undefined)
668
+ entry.col = d.column;
669
+ if (d.fixable !== undefined)
670
+ entry.fixable = d.fixable;
671
+ return entry;
672
+ }
654
673
  function buildAllClearOutput(_dispatchResult, elapsed, filePath) {
655
674
  const kind = detectFileKind(filePath);
656
675
  const langLabel = kind ? getFileKindLabel(kind) : path.extname(filePath);
@@ -766,8 +785,21 @@ export async function runPipeline(ctx, deps) {
766
785
  formattersUsed = formatResult.formattersUsed;
767
786
  formatFailures = formatResult.formatFailures;
768
787
  fileContent = formatResult.fileContent;
769
- if (formatChanged)
770
- piChangedFiles.add(path.resolve(filePath));
788
+ if (formatChanged) {
789
+ const absPath = path.resolve(filePath);
790
+ piChangedFiles.add(absPath);
791
+ publishFilesTouched({
792
+ reason: "format",
793
+ paths: [absPath],
794
+ cwd,
795
+ dbg,
796
+ fixes: formattersUsed.map((tool) => ({
797
+ path: absPath,
798
+ tool,
799
+ kind: "format",
800
+ })),
801
+ });
802
+ }
771
803
  }
772
804
  else if (formatDeferred) {
773
805
  dbg(`autoformat: deferred until agent_end for ${filePath}`);
@@ -783,6 +815,23 @@ export async function runPipeline(ctx, deps) {
783
815
  for (const changedFile of autofixChangedFiles) {
784
816
  piChangedFiles.add(path.resolve(changedFile));
785
817
  }
818
+ if (autofixChangedFiles.length > 0) {
819
+ // autofixTools entries are "tool:count" (e.g. "ruff:3"); runAutofix runs
820
+ // against ONE target filePath per call, so every tool that fired in this
821
+ // batch applies to every file the batch changed — best-effort attribution
822
+ // (not per-file precision) since the underlying tool runners don't report
823
+ // per-changed-file breakdown. Good enough for the "was this hunk
824
+ // mechanical" use case the fix-provenance field exists for.
825
+ const absChangedFiles = autofixChangedFiles.map((f) => path.resolve(f));
826
+ const toolNames = autofixTools.map((t) => t.split(":")[0]);
827
+ publishFilesTouched({
828
+ reason: "autofix",
829
+ paths: absChangedFiles,
830
+ cwd,
831
+ dbg,
832
+ fixes: absChangedFiles.flatMap((p) => toolNames.map((tool) => ({ path: p, tool, kind: "autofix" }))),
833
+ });
834
+ }
786
835
  if (fixRefresh) {
787
836
  try {
788
837
  fileContent = nodeFs.readFileSync(filePath, "utf-8");
@@ -820,6 +869,30 @@ export async function runPipeline(ctx, deps) {
820
869
  writeIndex: ctx.telemetry?.writeIndex ?? 0,
821
870
  });
822
871
  recordDiagnostics(filePath, dispatchResult.diagnostics);
872
+ // #502: emit the write batch's FINAL diagnostic state immediately after
873
+ // recordDiagnostics commits it — this call site runs after format,
874
+ // autofix, and dispatch have all completed for this batch (see the phase
875
+ // order above), so dispatchResult.diagnostics IS the latest post-batch
876
+ // picture, not an intermediate runner result. Full-replace semantics: we
877
+ // always pass the complete current set for this file, including an
878
+ // explicit `[]` on the transition from a previously-reported-dirty state
879
+ // to clean (see clients/diagnostics-publish.ts for the contract).
880
+ {
881
+ const absPath = path.resolve(filePath);
882
+ const wasDirty = wasPreviouslyReportedDirty(absPath);
883
+ if (dispatchResult.diagnostics.length > 0 || wasDirty) {
884
+ publishDiagnostics({
885
+ cwd,
886
+ files: [
887
+ {
888
+ path: absPath,
889
+ diagnostics: dispatchResult.diagnostics.map(toPilensDiagnosticEntry),
890
+ },
891
+ ],
892
+ dbg,
893
+ });
894
+ }
895
+ }
823
896
  const hasBlockers = dispatchResult.hasBlockers;
824
897
  const actionableWarnings = dispatchResult.warnings
825
898
  .map((diagnostic) => recordFromDispatchDiagnostic(diagnostic, cwd))
@@ -910,6 +983,9 @@ export async function runPipeline(ctx, deps) {
910
983
  turnSeq: ctx.telemetry?.turnIndex,
911
984
  writeSeq: ctx.telemetry?.writeIndex,
912
985
  seqState: ctx.seqState,
986
+ fileContent,
987
+ wordIndex: ctx.wordIndex,
988
+ onWordIndexUpdated: ctx.onWordIndexUpdated,
913
989
  }).catch((err) => {
914
990
  dbg(`cascade compute failed for ${filePath}: ${err}`);
915
991
  return {
@@ -958,5 +1034,9 @@ export async function runPipeline(ctx, deps) {
958
1034
  : undefined,
959
1035
  actionableWarnings,
960
1036
  codeQualityWarnings,
1037
+ diagnostics: dispatchResult.diagnostics,
1038
+ formattersUsed,
1039
+ fixedCount,
1040
+ autofixTools,
961
1041
  };
962
1042
  }
@@ -56,14 +56,38 @@ const EXTRACTORS = [
56
56
  adapt: (cwd, r) => deadCodeResultToProjectDiagnostics(cwd, r),
57
57
  },
58
58
  ];
59
+ /**
60
+ * #533: which trigger warms each extractor's cache, surfaced in the "cold"
61
+ * honesty note so the note is actionable (names what to do), matching the
62
+ * #511/#514 house shape. Keep in sync with `EXTRACTORS` — one line per id.
63
+ */
64
+ const WARM_TRIGGER = {
65
+ knip: "runs at session-start",
66
+ jscpd: "runs at session-start",
67
+ madge: "runs at session-start",
68
+ gitleaks: "runs at session-start",
69
+ govulncheck: "runs at session-start (Go projects only)",
70
+ trivy: "runs at session-start",
71
+ "dead-code": "runs at session-start (Python projects only)",
72
+ };
73
+ /** All registered extractor ids, in registry order — exported for tools/tests
74
+ * that need to enumerate "what could this section include" without reaching
75
+ * into the private `EXTRACTORS` table. */
76
+ export const PROJECT_DIAGNOSTIC_EXTRACTOR_IDS = EXTRACTORS.map((e) => e.id);
77
+ export function warmTriggerFor(extractorId) {
78
+ return WARM_TRIGGER[extractorId] ?? "runs at session-start";
79
+ }
59
80
  /**
60
81
  * Read every registered analyzer's cached result and adapt it to project
61
- * diagnostics. Returns the merged diagnostics plus the ids of the analyzers that
62
- * actually contributed (for the snapshot's `runners` list). Cache-only: no scans.
82
+ * diagnostics. Returns the merged diagnostics, the ids of the analyzers that
83
+ * actually contributed findings (for the snapshot's `runners` list), and the
84
+ * ids of analyzers with NO cache entry at all yet (`cold`) — distinct from an
85
+ * analyzer that ran and found nothing. Cache-only: no scans.
63
86
  */
64
87
  export function extractCachedProjectDiagnostics(cacheManager, cwd) {
65
88
  const diagnostics = [];
66
89
  const runners = [];
90
+ const cold = [];
67
91
  for (const extractor of EXTRACTORS) {
68
92
  let data;
69
93
  for (const key of extractor.cacheKeys) {
@@ -73,13 +97,15 @@ export function extractCachedProjectDiagnostics(cacheManager, cwd) {
73
97
  break;
74
98
  }
75
99
  }
76
- if (data === undefined)
100
+ if (data === undefined) {
101
+ cold.push(extractor.id);
77
102
  continue;
103
+ }
78
104
  const adapted = extractor.adapt(cwd, data);
79
105
  if (adapted.length > 0) {
80
106
  diagnostics.push(...adapted);
81
107
  runners.push(extractor.id);
82
108
  }
83
109
  }
84
- return { diagnostics, runners };
110
+ return { diagnostics, runners, cold };
85
111
  }
@@ -123,8 +123,13 @@ export function saveRuntimeProjectSnapshot(args) {
123
123
  snapshot.reverseDeps = existing.reverseDeps ?? {};
124
124
  // The word index is built by its own session task, which may not have
125
125
  // finished when another task triggers a save — keep the prior index
126
- // rather than clobbering it with undefined.
127
- if (!snapshot.wordIndex && existing.wordIndex) {
126
+ // rather than clobbering it with undefined. #348: only carry it forward
127
+ // when `existing` was built AT THIS SAME seq — otherwise a stale
128
+ // snapshot's leftover index (already correctly rejected as stale by
129
+ // isProjectSnapshotFresh on load, seq mismatch) would get silently
130
+ // re-stamped with the CURRENT seq by this save, "laundering" a stale
131
+ // index into looking fresh before the word-index task even runs.
132
+ if (!snapshot.wordIndex && existing.wordIndex && existing.seq === snapshot.seq) {
128
133
  snapshot.wordIndex = existing.wordIndex;
129
134
  }
130
135
  }