@herbertgao/pi-extensions 2026.8.7 → 2026.8.8
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/README.md +7 -5
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +63 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
- package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
- package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
- package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
- package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
- package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
- package/node_modules/@narumitw/pi-btw/package.json +2 -2
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
- package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
- package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
- package/node_modules/pi-lens/CHANGELOG.md +90 -0
- package/node_modules/pi-lens/README.md +106 -0
- package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
- package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
- package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
- package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
- package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
- package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
- package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
- package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
- package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
- package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
- package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
- package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
- package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
- package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
- package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
- package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
- package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
- package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
- package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
- package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
- package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
- package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
- package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
- package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
- package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
- package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
- package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
- package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
- package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
- package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
- package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
- package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
- package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
- package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
- package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
- package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
- package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
- package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
- package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
- package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
- package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
- package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
- package/node_modules/pi-lens/dist/index.js +1224 -393
- package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
- package/node_modules/pi-lens/package.json +18 -5
- package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
- package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
- package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
- package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
- package/node_modules/pi-web-access/CHANGELOG.md +14 -0
- package/node_modules/pi-web-access/README.md +6 -5
- package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
- package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
- package/node_modules/pi-web-access/extract.ts +12 -2
- package/node_modules/pi-web-access/firecrawl.ts +18 -2
- package/node_modules/pi-web-access/github-extract.ts +65 -36
- package/node_modules/pi-web-access/index.ts +19 -4
- package/node_modules/pi-web-access/openai-search.ts +17 -6
- package/node_modules/pi-web-access/package.json +1 -1
- package/node_modules/pi-web-access/pdf-extract.ts +18 -5
- package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
- package/package.json +9 -9
|
@@ -131,6 +131,35 @@ export function getRegisteredLogFiles() {
|
|
|
131
131
|
export function _resetRegisteredLogFilesForTest() {
|
|
132
132
|
registeredLogFiles.clear();
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Snapshot of unrecovered write losses, one entry per sink that has any
|
|
136
|
+
* (#1970). Pure in-memory read — no I/O, so a caller (`degradation-ledger.ts`)
|
|
137
|
+
* can fold this into a durable ledger entry without this module ever writing
|
|
138
|
+
* a record about its own failure through the sink that is failing. See
|
|
139
|
+
* `NdjsonWriterState.writeFailures` for the recursion-hazard rationale.
|
|
140
|
+
*/
|
|
141
|
+
export function getSinkWriteFailures() {
|
|
142
|
+
if (!ndjsonGlobalState)
|
|
143
|
+
return [];
|
|
144
|
+
const result = [];
|
|
145
|
+
for (const state of ndjsonGlobalState.writers.values()) {
|
|
146
|
+
if (state.writeFailures > 0) {
|
|
147
|
+
result.push({ file: state.file, droppedCount: state.writeFailures });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Session-boundary reset (catalog shape 17: a process-lifetime latch must
|
|
154
|
+
* re-arm at session_start). Wired into `resetDegradationLedger()` so both
|
|
155
|
+
* reset together; also used directly by tests.
|
|
156
|
+
*/
|
|
157
|
+
export function resetSinkWriteFailures() {
|
|
158
|
+
if (!ndjsonGlobalState)
|
|
159
|
+
return;
|
|
160
|
+
for (const state of ndjsonGlobalState.writers.values())
|
|
161
|
+
state.writeFailures = 0;
|
|
162
|
+
}
|
|
134
163
|
function requireCurrentGlobalState() {
|
|
135
164
|
if (ndjsonGlobalState)
|
|
136
165
|
return ndjsonGlobalState;
|
|
@@ -159,17 +188,46 @@ function assertCompatibleWriterOptions(existing, maxBytes, backupPath) {
|
|
|
159
188
|
throw new Error(`createNdjsonLogger: incompatible options for shared path ${existing.file}; ` +
|
|
160
189
|
`the first writer's maxBytes/backupPath must be reused`);
|
|
161
190
|
}
|
|
191
|
+
function writeQueueItemSync(state, item) {
|
|
192
|
+
if (item.kind === "truncate") {
|
|
193
|
+
fs.writeFileSync(state.file, "");
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
rotateIfNeeded(state);
|
|
197
|
+
fs.appendFileSync(state.file, item.line);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The pi-analyze #15 shape (#1970): a write that throws — including the
|
|
202
|
+
* `ERR_STREAM_DESTROYED` a torn-down sink produces — gets one reopen-and-
|
|
203
|
+
* retry before it counts as a loss. There is no persistent handle to close
|
|
204
|
+
* here (every write already opens, writes, and closes in one call), so
|
|
205
|
+
* "reopen" means dropping the cached `ensuredDir` assumption and
|
|
206
|
+
* re-verifying the parent directory before the retry — the one piece of
|
|
207
|
+
* cross-write state this module holds that a destroyed sink could have
|
|
208
|
+
* invalidated. An unrecovered write is counted, never thrown or retried a
|
|
209
|
+
* second time (`applyQueueItemSync`, `applyQueueItemAsync`, and the batched
|
|
210
|
+
* write in `drainLoop` skip DIFFERENT amounts of work per queue item, so
|
|
211
|
+
* each keeps its own copy of this two-step shape rather than sharing one
|
|
212
|
+
* generic retry wrapper across sync/async).
|
|
213
|
+
*/
|
|
162
214
|
function applyQueueItemSync(state, item) {
|
|
163
215
|
ensureDirSync(state);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
216
|
+
try {
|
|
217
|
+
writeQueueItemSync(state, item);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// fall through to the one reopen-and-retry
|
|
222
|
+
}
|
|
223
|
+
state.ensuredDir = false;
|
|
224
|
+
ensureDirSync(state);
|
|
225
|
+
try {
|
|
226
|
+
writeQueueItemSync(state, item);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
state.writeFailures += 1;
|
|
230
|
+
}
|
|
173
231
|
}
|
|
174
232
|
function flushStateSync(state) {
|
|
175
233
|
// Drain the in-memory queue synchronously — safe at process exit.
|
|
@@ -208,6 +266,9 @@ function createWriterState(file, maxBytes, backupPath) {
|
|
|
208
266
|
assertCompatibleWriterOptions(existing, maxBytes, backupPath);
|
|
209
267
|
if (!exitFlushers.has(existing.exitFlusher))
|
|
210
268
|
registerWriter(existing);
|
|
269
|
+
// A state adopted from a pre-#1970 module graph predates this field.
|
|
270
|
+
if (typeof existing.writeFailures !== "number")
|
|
271
|
+
existing.writeFailures = 0;
|
|
211
272
|
return existing;
|
|
212
273
|
}
|
|
213
274
|
const state = {};
|
|
@@ -220,6 +281,7 @@ function createWriterState(file, maxBytes, backupPath) {
|
|
|
220
281
|
state.syncRepairItems = null;
|
|
221
282
|
state.ensuredDir = false;
|
|
222
283
|
state.exitFlusher = () => flushStateSync(state);
|
|
284
|
+
state.writeFailures = 0;
|
|
223
285
|
globalState.writers.set(file, state);
|
|
224
286
|
registerWriter(state);
|
|
225
287
|
return state;
|
|
@@ -258,23 +320,36 @@ function rotateIfNeeded(state) {
|
|
|
258
320
|
// no file yet, or rename raced — nothing to rotate
|
|
259
321
|
}
|
|
260
322
|
}
|
|
323
|
+
async function writeQueueItemAsync(state, item) {
|
|
324
|
+
if (item.kind === "truncate") {
|
|
325
|
+
await fs.promises.writeFile(state.file, "");
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
// Rotation is deliberately synchronous here. This function is only
|
|
329
|
+
// reached from the already-deferred drain, and keeping stat/rm/rename
|
|
330
|
+
// in one synchronous section prevents flushSync from racing a late
|
|
331
|
+
// async rename after it has written new data.
|
|
332
|
+
rotateIfNeeded(state);
|
|
333
|
+
await fs.promises.appendFile(state.file, item.line);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** See `applyQueueItemSync`'s doc comment for the reopen-and-retry-once shape. */
|
|
261
337
|
async function applyQueueItemAsync(state, item) {
|
|
262
338
|
await ensureDirAsync(state);
|
|
263
339
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
// Rotation is deliberately synchronous here. This function is only
|
|
269
|
-
// reached from the already-deferred drain, and keeping stat/rm/rename
|
|
270
|
-
// in one synchronous section prevents flushSync from racing a late
|
|
271
|
-
// async rename after it has written new data.
|
|
272
|
-
rotateIfNeeded(state);
|
|
273
|
-
await fs.promises.appendFile(state.file, item.line);
|
|
274
|
-
}
|
|
340
|
+
await writeQueueItemAsync(state, item);
|
|
341
|
+
return;
|
|
275
342
|
}
|
|
276
343
|
catch {
|
|
277
|
-
//
|
|
344
|
+
// fall through to the one reopen-and-retry
|
|
345
|
+
}
|
|
346
|
+
state.ensuredDir = false;
|
|
347
|
+
await ensureDirAsync(state);
|
|
348
|
+
try {
|
|
349
|
+
await writeQueueItemAsync(state, item);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
state.writeFailures += 1;
|
|
278
353
|
}
|
|
279
354
|
}
|
|
280
355
|
async function drainLoop(state) {
|
|
@@ -292,7 +367,7 @@ async function drainLoop(state) {
|
|
|
292
367
|
? [item]
|
|
293
368
|
: state.queue.slice(0, pendingEnd);
|
|
294
369
|
state.inFlightBatch = pending;
|
|
295
|
-
|
|
370
|
+
const writeBatch = async () => {
|
|
296
371
|
if (item.kind === "truncate") {
|
|
297
372
|
await fs.promises.writeFile(state.file, "");
|
|
298
373
|
}
|
|
@@ -305,9 +380,25 @@ async function drainLoop(state) {
|
|
|
305
380
|
.map((queued) => queued.line)
|
|
306
381
|
.join(""));
|
|
307
382
|
}
|
|
383
|
+
};
|
|
384
|
+
try {
|
|
385
|
+
await writeBatch();
|
|
308
386
|
}
|
|
309
387
|
catch {
|
|
310
|
-
//
|
|
388
|
+
// Reopen-and-retry once (#1970, pi-analyze #15 shape): a destroyed
|
|
389
|
+
// sink (ERR_STREAM_DESTROYED) or a directory that vanished mid-session
|
|
390
|
+
// gets exactly one recovery attempt before the batch counts as a loss.
|
|
391
|
+
state.ensuredDir = false;
|
|
392
|
+
await ensureDirAsync(state);
|
|
393
|
+
try {
|
|
394
|
+
await writeBatch();
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Unrecovered: count every line in this batch as dropped. Purely
|
|
398
|
+
// in-memory — see `writeFailures`'s doc comment for why this must
|
|
399
|
+
// never itself attempt a durable write through this same sink.
|
|
400
|
+
state.writeFailures += pending.length;
|
|
401
|
+
}
|
|
311
402
|
}
|
|
312
403
|
for (const written of pending) {
|
|
313
404
|
// flushSync may have drained this prefix while the append is in
|
|
@@ -288,17 +288,26 @@ async function scanFileMajorRules(cwd, files, signal) {
|
|
|
288
288
|
// phase-major scans.
|
|
289
289
|
durationMs: Date.now() - startedAt - astGrepDurationMs,
|
|
290
290
|
stats,
|
|
291
|
+
// #1935 review: ast-grep's own cost is often a scan's most expensive
|
|
292
|
+
// phase (production evidence: 13168ms over 86 files, the single
|
|
293
|
+
// biggest contributor). Its duration is subtracted above so it stays
|
|
294
|
+
// comparable to the historical phase-major scans; carry it here too
|
|
295
|
+
// so it stays visible SOMEWHERE instead of disappearing when the
|
|
296
|
+
// vacuous `project_diagnostics_ast_grep_scan` cache_stats record
|
|
297
|
+
// (below) was removed.
|
|
298
|
+
astGrep: { durationMs: astGrepDurationMs, fileCount: astGrepFilesScanned },
|
|
291
299
|
});
|
|
292
300
|
});
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
// #1935: no `project_diagnostics_ast_grep_scan` cache_stats record here.
|
|
302
|
+
// `scanAstGrepFile` parses through ast-grep-napi's own `lang.parse()` — a
|
|
303
|
+
// separate native engine, not `TreeSitterClient`'s WASM `TreeCache` — so a
|
|
304
|
+
// cache_stats record for this scope would always read all-zero (0
|
|
305
|
+
// lookups, 0 hits, every counter 0) no matter how the scan behaves. That
|
|
306
|
+
// was a vacuous observability record, not a real one: it looked like a
|
|
307
|
+
// signal but could never carry information. `astGrepDurationMs` and
|
|
308
|
+
// `astGrepFilesScanned` still feed the `project_diagnostics_scan` record
|
|
309
|
+
// above (`astGrep` sub-field), so this cost stays observable without a
|
|
310
|
+
// second, always-zero record to carry it.
|
|
302
311
|
return { treeSitter, factRules, astGrep, filesScanned, wasmAborted };
|
|
303
312
|
}
|
|
304
313
|
export async function scanProjectDiagnostics(options) {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import * as path from "node:path";
|
|
37
37
|
import { normalizeMapKey, toProjectRelativePath } from "./path-utils.js";
|
|
38
38
|
import { loadProjectSnapshotWithoutWordIndex } from "./project-snapshot.js";
|
|
39
|
+
import { formatReviewGraphRevisionDriftNote, } from "./review-graph/revision-drift.js";
|
|
39
40
|
const DEFAULT_LIMIT = 10;
|
|
40
41
|
const STALE_THRESHOLD_MS = 15 * 60_000; // 15 minutes
|
|
41
42
|
const LOW_COVERAGE_THRESHOLD = 0.8;
|
|
@@ -113,7 +114,11 @@ function buildFileDegrees(graph) {
|
|
|
113
114
|
return { fanIn, fanOut };
|
|
114
115
|
}
|
|
115
116
|
// --- section 1: trust header --------------------------------------------------
|
|
116
|
-
function computeTrust(graph, cwd
|
|
117
|
+
function computeTrust(graph, cwd,
|
|
118
|
+
// Passed in rather than looked up here: the builder is imported dynamically
|
|
119
|
+
// by `projectReport` to keep the module graph acyclic, and the value must be
|
|
120
|
+
// computed per render, never cached (#1961 review F3).
|
|
121
|
+
drift) {
|
|
117
122
|
const filesCovered = graph.fileNodes.size;
|
|
118
123
|
const snapshot = loadProjectSnapshotWithoutWordIndex(cwd);
|
|
119
124
|
const snapshotFileCount = snapshot ? Object.keys(snapshot.files).length : 0;
|
|
@@ -151,6 +156,11 @@ function computeTrust(graph, cwd) {
|
|
|
151
156
|
const lowCoverage = coverage < LOW_COVERAGE_THRESHOLD ||
|
|
152
157
|
graph.persistCoverage?.partial === true;
|
|
153
158
|
const notes = [];
|
|
159
|
+
// #1961: the blind read now SERVES a snapshot stamped at a different HEAD
|
|
160
|
+
// rather than dropping it. Age alone would not catch a branch switch made
|
|
161
|
+
// minutes ago, so the revision difference gets its own note.
|
|
162
|
+
if (drift)
|
|
163
|
+
notes.push(formatReviewGraphRevisionDriftNote(drift));
|
|
154
164
|
if (stale) {
|
|
155
165
|
const ageMin = Math.round(ageMs / 60_000);
|
|
156
166
|
notes.push(`Graph is stale: built ${ageMin}m ago. Sections below may miss recent edits — run pilens_rebuild or re-analyze to refresh.`);
|
|
@@ -184,6 +194,7 @@ function computeTrust(graph, cwd) {
|
|
|
184
194
|
sampleSize,
|
|
185
195
|
},
|
|
186
196
|
stale,
|
|
197
|
+
...(drift ? { revisionDrift: drift } : {}),
|
|
187
198
|
lowCoverage,
|
|
188
199
|
persistCoverage: graph.persistCoverage?.partial
|
|
189
200
|
? graph.persistCoverage
|
|
@@ -493,25 +504,23 @@ const inFlightGraphBuilds = new Set();
|
|
|
493
504
|
export function _resetProjectReportBuildGuardForTests() {
|
|
494
505
|
inFlightGraphBuilds.clear();
|
|
495
506
|
}
|
|
496
|
-
function triggerBackgroundGraphBuild(cwd) {
|
|
507
|
+
function triggerBackgroundGraphBuild(cwd, deps) {
|
|
497
508
|
const key = normalizeMapKey(path.resolve(cwd));
|
|
498
509
|
if (inFlightGraphBuilds.has(key))
|
|
499
|
-
return
|
|
510
|
+
return "already_running";
|
|
511
|
+
// Ask the builder, not just this module's own guard: a build kicked off by
|
|
512
|
+
// the edit pipeline or another reader never touches `inFlightGraphBuilds`.
|
|
513
|
+
// The probe and the call are in ONE synchronous block on purpose — awaiting
|
|
514
|
+
// between them (the pre-#1962 dynamic imports did) reopens the race.
|
|
515
|
+
const deduped = deps.isGraphBuildInFlight(key, []);
|
|
500
516
|
inFlightGraphBuilds.add(key);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// buildOrUpdateGraph records the durable failure and surfaced status.
|
|
509
|
-
}
|
|
510
|
-
finally {
|
|
511
|
-
inFlightGraphBuilds.delete(key);
|
|
512
|
-
}
|
|
513
|
-
})();
|
|
514
|
-
return true;
|
|
517
|
+
const build = deps
|
|
518
|
+
.buildOrUpdateGraph(key, [], new deps.FactStore())
|
|
519
|
+
.catch(() => {
|
|
520
|
+
// buildOrUpdateGraph records the durable failure and surfaced status.
|
|
521
|
+
});
|
|
522
|
+
void build.finally(() => inFlightGraphBuilds.delete(key));
|
|
523
|
+
return deduped ? "already_running" : "started";
|
|
515
524
|
}
|
|
516
525
|
// --- entry point ---------------------------------------------------------------
|
|
517
526
|
/**
|
|
@@ -523,7 +532,7 @@ export async function projectReport(cwd, options) {
|
|
|
523
532
|
const limit = clampLimit(options?.limit);
|
|
524
533
|
const focusTerms = normalizeFocus(options?.focus);
|
|
525
534
|
const view = options?.view;
|
|
526
|
-
const { getCachedReviewGraph, getReviewGraphSizeSkipVerdict, getLastReviewGraphBuildAttempt, } = await import("./review-graph/builder.js");
|
|
535
|
+
const { getCachedReviewGraph, getReviewGraphSizeSkipVerdict, getLastReviewGraphBuildAttempt, buildOrUpdateGraph, isGraphBuildInFlight, getReviewGraphRevisionDrift, } = await import("./review-graph/builder.js");
|
|
527
536
|
let graph;
|
|
528
537
|
try {
|
|
529
538
|
graph = getCachedReviewGraph(cwd);
|
|
@@ -556,22 +565,48 @@ export async function projectReport(cwd, options) {
|
|
|
556
565
|
};
|
|
557
566
|
}
|
|
558
567
|
const previousAttempt = getLastReviewGraphBuildAttempt(cwd);
|
|
559
|
-
const
|
|
560
|
-
const
|
|
568
|
+
const { FactStore } = await import("./dispatch/fact-store.js");
|
|
569
|
+
const trigger = triggerBackgroundGraphBuild(cwd, {
|
|
570
|
+
buildOrUpdateGraph,
|
|
571
|
+
isGraphBuildInFlight,
|
|
572
|
+
FactStore,
|
|
573
|
+
});
|
|
574
|
+
const started = trigger === "started";
|
|
575
|
+
// #1962: report the attempt that is CURRENT. When this call started a
|
|
576
|
+
// build, `buildOrUpdateGraph` has already recorded a fresh `running`
|
|
577
|
+
// attempt synchronously, and the previous outcome belongs in the hint
|
|
578
|
+
// text, not in a field labelled "last attempt". When no build started,
|
|
579
|
+
// the earlier attempt genuinely IS the current one.
|
|
580
|
+
const lastBuildAttempt = started
|
|
581
|
+
? (getLastReviewGraphBuildAttempt(cwd) ?? previousAttempt)
|
|
582
|
+
: previousAttempt;
|
|
583
|
+
// A terminal previous outcome is the useful half of the message: it says
|
|
584
|
+
// WHY the graph is missing. It is prefixed to whichever verdict follows,
|
|
585
|
+
// rather than selecting between two parallel sentence pairs — the first
|
|
586
|
+
// version of this branch did that, and its terminal-plus-deduped arm was
|
|
587
|
+
// unreachable (#1962 review F1). A pending build always records a
|
|
588
|
+
// `running` attempt synchronously, so "terminal" and "not started" cannot
|
|
589
|
+
// both hold for the same key.
|
|
590
|
+
const terminal = previousAttempt?.outcome === "failed" ||
|
|
591
|
+
previousAttempt?.outcome === "skipped";
|
|
592
|
+
const cause = terminal
|
|
593
|
+
? `Review graph unavailable: ${previousAttempt?.reason ?? previousAttempt?.outcome}. `
|
|
594
|
+
: "";
|
|
561
595
|
return {
|
|
562
596
|
available: false,
|
|
563
|
-
hint:
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
: "No review graph cached for this workspace yet — the background build is still running; retry this call shortly.",
|
|
597
|
+
hint: started
|
|
598
|
+
? terminal
|
|
599
|
+
? `${cause}A retry was started.`
|
|
600
|
+
: "No review graph cached for this workspace yet — a build was kicked off in the background; retry this call shortly."
|
|
601
|
+
: `${cause}A build is already running for this workspace; retry this call shortly.`,
|
|
569
602
|
...(lastBuildAttempt ? { lastBuildAttempt } : {}),
|
|
570
603
|
...(view ? { view } : {}),
|
|
571
604
|
};
|
|
572
605
|
}
|
|
573
606
|
const degrees = buildFileDegrees(graph);
|
|
574
|
-
|
|
607
|
+
// Computed on THIS render, from the workspace entry's stamped commit and the
|
|
608
|
+
// worktree's HEAD right now — never from a value cached beside the graph.
|
|
609
|
+
const trust = computeTrust(graph, cwd, getReviewGraphRevisionDrift(cwd));
|
|
575
610
|
const hubs = await computeHubs(graph, degrees, cwd, limit, focusTerms);
|
|
576
611
|
const { entryPoints, entryPointFiles } = computeEntryPoints(graph, degrees, cwd, limit, focusTerms);
|
|
577
612
|
const subsystems = computeSubsystems(graph, cwd, limit);
|
|
@@ -112,7 +112,7 @@ export function createReadGuardEditBatchSummary(args) {
|
|
|
112
112
|
durationMs: Math.max(0, Math.min(args.durationMs ?? 0, 86_400_000)),
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
|
-
function shouldLogEvent(event) {
|
|
115
|
+
export function shouldLogEvent(event) {
|
|
116
116
|
if (VERBOSE_READ_GUARD_LOG)
|
|
117
117
|
return true;
|
|
118
118
|
if (event === "edit_allowed")
|
|
@@ -134,7 +134,12 @@ function shouldLogEvent(event) {
|
|
|
134
134
|
event === "edit_post_edit_pipeline_failed" ||
|
|
135
135
|
event === "edit_batch_summary" ||
|
|
136
136
|
event === "edit_batch_summary_overflow" ||
|
|
137
|
-
event === "touched_lines_missing"
|
|
137
|
+
event === "touched_lines_missing" ||
|
|
138
|
+
// #1913: read-guard record-cap evictions are rare by construction (only
|
|
139
|
+
// READ_GUARD_MAX_RECORDS_PER_FILE overflow triggers one), so this trim
|
|
140
|
+
// record bypasses the per-read verbosity gate — a live eviction
|
|
141
|
+
// regression must be visible without PI_LENS_READ_GUARD_VERBOSE=1.
|
|
142
|
+
event === "read_cap_trimmed");
|
|
138
143
|
}
|
|
139
144
|
const MAX_TELEMETRY_STRING = 512;
|
|
140
145
|
const MAX_TELEMETRY_KEYS = 64;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Falls back safely when LSP is unavailable.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
|
+
import { incrementDegradationCount } from "./degradation-ledger.js";
|
|
13
14
|
import { createFileTime } from "./file-time.js";
|
|
14
15
|
import { hashDiagnosticContent } from "./lsp/diagnostic-binding.js";
|
|
15
16
|
import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
|
|
@@ -48,29 +49,22 @@ const READ_GUARD_MAX_FILES = 256;
|
|
|
48
49
|
* age alone is the wrong order.
|
|
49
50
|
*/
|
|
50
51
|
const READ_GUARD_MAX_RECORDS_PER_FILE = 128;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* read-once-then-grep-often: one whole-file read followed by hundreds of cheap
|
|
57
|
-
* search credits. Pure age order evicts that whole-file read first, and it is
|
|
58
|
-
* the record `canIgnoreStalenessByHashes` needs to rescue an edit after an
|
|
59
|
-
* unrelated mtime touch. So spend the search credits first, oldest among them
|
|
60
|
-
* first, and only fall through to genuine reads when the credits run out.
|
|
61
|
-
* Genuine reads are then evicted oldest-first as before.
|
|
62
|
-
*
|
|
63
|
-
* Records are trimmed IN PLACE: `EditRecord.precedingReads` holds a reference
|
|
64
|
-
* to this same array, so a replacement array would silently detach it.
|
|
65
|
-
*/
|
|
52
|
+
const NO_TRIM = {
|
|
53
|
+
evictedCount: 0,
|
|
54
|
+
evictedCreditCount: 0,
|
|
55
|
+
evictedGenuineCount: 0,
|
|
56
|
+
};
|
|
66
57
|
function enforceRecordCapForFile(records) {
|
|
67
58
|
if (records.length <= READ_GUARD_MAX_RECORDS_PER_FILE)
|
|
68
|
-
return
|
|
59
|
+
return NO_TRIM;
|
|
69
60
|
const excess = records.length - READ_GUARD_MAX_RECORDS_PER_FILE;
|
|
70
61
|
const evicted = new Set();
|
|
62
|
+
let evictedCreditCount = 0;
|
|
71
63
|
for (let i = 0; i < records.length && evicted.size < excess; i++) {
|
|
72
|
-
if (records[i].searchCredit !== undefined)
|
|
64
|
+
if (records[i].searchCredit !== undefined) {
|
|
73
65
|
evicted.add(i);
|
|
66
|
+
evictedCreditCount++;
|
|
67
|
+
}
|
|
74
68
|
}
|
|
75
69
|
for (let i = 0; i < records.length && evicted.size < excess; i++) {
|
|
76
70
|
evicted.add(i);
|
|
@@ -79,7 +73,11 @@ function enforceRecordCapForFile(records) {
|
|
|
79
73
|
records.length = 0;
|
|
80
74
|
for (const record of kept)
|
|
81
75
|
records.push(record);
|
|
82
|
-
return
|
|
76
|
+
return {
|
|
77
|
+
evictedCount: excess,
|
|
78
|
+
evictedCreditCount,
|
|
79
|
+
evictedGenuineCount: excess - evictedCreditCount,
|
|
80
|
+
};
|
|
83
81
|
}
|
|
84
82
|
/**
|
|
85
83
|
* #1904 class sweep: `this.edits` is the same shape as `this.reads` — a
|
|
@@ -285,6 +283,8 @@ export class ReadGuard {
|
|
|
285
283
|
// happened to the file since. Pruned inside `evictFile` so it never
|
|
286
284
|
// outlives the record it points at.
|
|
287
285
|
knownPathIndex = new Map();
|
|
286
|
+
/** Running per-file record-cap trim totals for this session (#1913 F1). */
|
|
287
|
+
trimAccumulators = new Map();
|
|
288
288
|
sessionId;
|
|
289
289
|
sessionStartMs;
|
|
290
290
|
constructor(sessionId, config = {}) {
|
|
@@ -399,7 +399,7 @@ export class ReadGuard {
|
|
|
399
399
|
// eviction starts, so it stops showing growth. `rawReadCountForFile` keeps
|
|
400
400
|
// the real arrival count observable alongside `evictedRecordCount`.
|
|
401
401
|
const rawReadCountForFile = arr.length;
|
|
402
|
-
const evictedRecordCount = enforceRecordCapForFile(arr);
|
|
402
|
+
const { evictedCount: evictedRecordCount, evictedCreditCount, evictedGenuineCount } = enforceRecordCapForFile(arr);
|
|
403
403
|
this.touchFile(storedRecord.filePath);
|
|
404
404
|
this.enforceFileCap();
|
|
405
405
|
logReadGuardEvent({
|
|
@@ -434,6 +434,63 @@ export class ReadGuard {
|
|
|
434
434
|
}),
|
|
435
435
|
},
|
|
436
436
|
});
|
|
437
|
+
// #1913: the eviction counters above ride `read_recorded`, which is
|
|
438
|
+
// gated behind PI_LENS_READ_GUARD_VERBOSE — off by default. That left a
|
|
439
|
+
// live eviction regression with no trace at default verbosity.
|
|
440
|
+
//
|
|
441
|
+
// review F1: a naive "emit every trim" fix floods read-guard.log once a
|
|
442
|
+
// hot file sits past the cap — every later push trims exactly 1 record
|
|
443
|
+
// (the array is always AT the cap before the push, so `excess` is
|
|
444
|
+
// always exactly 1), so 300 recordRead calls on one file is ~172
|
|
445
|
+
// identical always-on lines, and can rotate `edit_blocked` records out
|
|
446
|
+
// of the 1MB cap. `rawReadCountForFile` also freezes at cap+1 forever,
|
|
447
|
+
// so a raw per-trim record can't even discriminate thrash severity.
|
|
448
|
+
//
|
|
449
|
+
// Fix: emit the read-guard.log line ONCE per file per session, on the
|
|
450
|
+
// FIRST trim only. `trimAccumulators` still updates on every trim,
|
|
451
|
+
// queryable via `getTrimStats` — the running totals a health surface or
|
|
452
|
+
// a future emission point would need are never lost, even though
|
|
453
|
+
// read-guard.log stops re-announcing them. "Have we already logged
|
|
454
|
+
// this file's first trim" routes through the degradation ledger's own
|
|
455
|
+
// rising-edge tally (`incrementDegradationCount` — the pattern
|
|
456
|
+
// CLAUDE.md names for repeated degradations) instead of a hand-rolled
|
|
457
|
+
// per-file Set; subsequent trims still call it, so the ledger's own
|
|
458
|
+
// entry for this (kind, subject) keeps its reason text and count
|
|
459
|
+
// current for the health/degradation summary even after read-guard.log
|
|
460
|
+
// goes quiet.
|
|
461
|
+
if (evictedRecordCount > 0) {
|
|
462
|
+
const key = storedRecord.filePath;
|
|
463
|
+
const acc = this.trimAccumulators.get(key) ?? {
|
|
464
|
+
totalEvicted: 0,
|
|
465
|
+
evictedCreditCount: 0,
|
|
466
|
+
evictedGenuineCount: 0,
|
|
467
|
+
trimEventCount: 0,
|
|
468
|
+
};
|
|
469
|
+
acc.totalEvicted += evictedRecordCount;
|
|
470
|
+
acc.evictedCreditCount += evictedCreditCount;
|
|
471
|
+
acc.evictedGenuineCount += evictedGenuineCount;
|
|
472
|
+
acc.trimEventCount += 1;
|
|
473
|
+
this.trimAccumulators.set(key, acc);
|
|
474
|
+
const isRisingEdge = incrementDegradationCount({
|
|
475
|
+
kind: "read-guard-record-cap-trim",
|
|
476
|
+
subject: key,
|
|
477
|
+
reason: `trim #${acc.trimEventCount}: evicted ${evictedRecordCount} (credit ${evictedCreditCount}, genuine ${evictedGenuineCount})`,
|
|
478
|
+
});
|
|
479
|
+
if (isRisingEdge) {
|
|
480
|
+
logReadGuardEvent({
|
|
481
|
+
event: "read_cap_trimmed",
|
|
482
|
+
sessionId: this.sessionId,
|
|
483
|
+
filePath: storedRecord.filePath,
|
|
484
|
+
metadata: {
|
|
485
|
+
trimEventCount: acc.trimEventCount,
|
|
486
|
+
evictedRecordCount: acc.totalEvicted,
|
|
487
|
+
evictedCreditCount: acc.evictedCreditCount,
|
|
488
|
+
evictedGenuineCount: acc.evictedGenuineCount,
|
|
489
|
+
rawReadCountForFile,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
437
494
|
// Also update FileTime stamp for this file
|
|
438
495
|
this.fileTime.read(storedRecord.filePath);
|
|
439
496
|
}
|
|
@@ -828,6 +885,18 @@ export class ReadGuard {
|
|
|
828
885
|
this.touchFile(key);
|
|
829
886
|
return this.reads.get(key) ?? [];
|
|
830
887
|
}
|
|
888
|
+
/**
|
|
889
|
+
* Session-lifetime record-cap trim totals for a file (#1913 review F1).
|
|
890
|
+
* `recordRead` only writes ONE `read_cap_trimmed` read-guard.log line per
|
|
891
|
+
* file per session (on the first trim), so this is the running-totals
|
|
892
|
+
* surface for everything after that — the exact split this class already
|
|
893
|
+
* computes on every trim, not re-derivable from `getReadHistory` alone
|
|
894
|
+
* (which only shows the SURVIVING records, not what was evicted).
|
|
895
|
+
*/
|
|
896
|
+
getTrimStats(filePath) {
|
|
897
|
+
const stats = this.trimAccumulators.get(this.key(filePath));
|
|
898
|
+
return stats ? { ...stats } : undefined;
|
|
899
|
+
}
|
|
831
900
|
/**
|
|
832
901
|
* Snapshot the read-set for persistence across a session resume (#1041).
|
|
833
902
|
* Mirrors widget-state's `exportWidgetState`: the Map is emitted as
|