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.
- package/CHANGELOG.md +58 -0
- package/README.md +57 -38
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bus-publish.js +110 -0
- package/dist/clients/deps/ast-grep-napi.js +20 -1
- package/dist/clients/deps/pi-tui.js +4 -1
- package/dist/clients/deps/typebox.js +5 -2
- package/dist/clients/deps/web-tree-sitter.js +22 -1
- package/dist/clients/diagnostic-logger.js +2 -2
- package/dist/clients/diagnostics-publish.js +180 -0
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/installer/index.js +32 -1
- package/dist/clients/instance-reaper.js +516 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +44 -10
- package/dist/clients/lsp/cascade-tier.js +254 -0
- package/dist/clients/lsp/client.js +81 -3
- package/dist/clients/lsp/index.js +3 -0
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +71 -0
- package/dist/clients/lsp/server.js +119 -6
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/module-report.js +163 -18
- package/dist/clients/path-utils.js +25 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +82 -2
- package/dist/clients/project-diagnostics/extractors.js +30 -4
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +168 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/review-graph/builder.js +20 -2
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +154 -48
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/runtime-turn.js +9 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +246 -38
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +62 -14
- package/dist/clients/subagent-mode.js +87 -0
- package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +27 -30
- package/dist/clients/word-index.js +296 -1
- package/dist/index.js +62809 -1633
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +377 -43
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +42 -12
- package/dist/tools/lsp-diagnostics.js +117 -4
- package/dist/tools/module-report.js +14 -11
- package/dist/tools/symbol-search.js +110 -0
- package/package.json +3 -2
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- 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,
|
|
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
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
+
}
|
package/dist/clients/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
62
|
-
* actually contributed (for the snapshot's `runners` list)
|
|
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
|
-
|
|
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
|
}
|