pi-lens 3.8.39 → 3.8.41
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 +84 -5
- package/README.md +37 -1
- package/clients/biome-client.ts +5 -4
- package/clients/cache/rule-cache.ts +1 -1
- package/clients/complexity-client.ts +1 -1
- package/clients/dependency-checker.ts +1 -1
- package/clients/dispatch/diagnostic-taxonomy.ts +13 -1
- package/clients/dispatch/dispatcher.ts +9 -0
- package/clients/dispatch/fact-scheduler.ts +1 -1
- package/clients/dispatch/integration.ts +58 -3
- package/clients/dispatch/runners/index.ts +2 -0
- package/clients/dispatch/runners/semgrep.ts +269 -0
- package/clients/dispatch/runners/shellcheck.ts +2 -8
- package/clients/dispatch/runners/tree-sitter.ts +32 -11
- package/clients/dispatch/tool-profile.ts +1 -0
- package/clients/format-service.ts +10 -0
- package/clients/formatters.ts +22 -8
- package/clients/installer/index.ts +3 -3
- package/clients/knip-client.ts +360 -362
- package/clients/lsp/aggregation.ts +91 -0
- package/clients/lsp/client.ts +91 -38
- package/clients/lsp/index.ts +88 -72
- package/clients/lsp/launch.ts +107 -34
- package/clients/lsp/server-strategies.ts +71 -0
- package/clients/lsp/server.ts +76 -57
- package/clients/path-utils.ts +17 -0
- package/clients/pipeline.ts +23 -5
- package/clients/production-readiness.ts +2 -2
- package/clients/read-guard-logger.ts +41 -1
- package/clients/read-guard-tool-lines.ts +17 -4
- package/clients/read-guard.ts +95 -46
- package/clients/runtime-agent-end.ts +3 -0
- package/clients/runtime-session.ts +5 -0
- package/clients/runtime-tool-result.ts +48 -1
- package/clients/runtime-turn.ts +48 -4
- package/clients/sanitize.ts +1 -1
- package/clients/semgrep-config.ts +213 -0
- package/clients/tool-policy.ts +1982 -1936
- package/clients/tree-sitter-client.ts +1 -1
- package/clients/widget-state.ts +283 -0
- package/commands/booboo.ts +34 -2
- package/index.ts +231 -17
- package/package.json +3 -2
- package/rules/rule-catalog.json +25 -1
- package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
- package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
- package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
- package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
- package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
- package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
- package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
- package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
- package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
- package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
- package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
- package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
- package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
- package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
- package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
- package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
- package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
- package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
- package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
- package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
|
@@ -4,6 +4,9 @@ import { logReadGuardEvent } from "./read-guard-logger.js";
|
|
|
4
4
|
|
|
5
5
|
export interface GuardLineResult {
|
|
6
6
|
touchedLines: [number, number] | undefined;
|
|
7
|
+
// Individual ranges for multi-edit calls (e.g. rename at 4 scattered spots).
|
|
8
|
+
// When set, read-guard checks each range independently instead of the bounding box.
|
|
9
|
+
editRanges?: [number, number][];
|
|
7
10
|
preflightError?: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
@@ -98,7 +101,7 @@ function resolveOldTextEdits(
|
|
|
98
101
|
if (occurrenceLines.length === 0) {
|
|
99
102
|
const preview = oldText.trimStart().substring(0, 60).replace(/\n/g, "↵");
|
|
100
103
|
errors.push(
|
|
101
|
-
`edits[${editIndex}].oldText ("${preview}") was not found in the current file content. Re-read the file
|
|
104
|
+
`edits[${editIndex}].oldText ("${preview}") was not found in the current file content. Re-read the relevant section of the file to confirm the exact text, then retry with the verbatim content.`,
|
|
102
105
|
);
|
|
103
106
|
logReadGuardEvent({
|
|
104
107
|
event: "oldtext_not_found",
|
|
@@ -154,7 +157,7 @@ function resolveOldTextEdits(
|
|
|
154
157
|
errors.length > 0
|
|
155
158
|
? errors
|
|
156
159
|
: [
|
|
157
|
-
"One or more edit targets could not be resolved to exact lines. Re-read the
|
|
160
|
+
"One or more edit targets could not be resolved to exact lines. Re-read the relevant section and retry with the exact content as it appears in the file.",
|
|
158
161
|
];
|
|
159
162
|
return {
|
|
160
163
|
touchedLines: undefined,
|
|
@@ -182,6 +185,7 @@ function resolveOldTextEdits(
|
|
|
182
185
|
Math.min(...starts),
|
|
183
186
|
Math.max(...ends),
|
|
184
187
|
];
|
|
188
|
+
const editRanges = resolvedRanges.length > 1 ? resolvedRanges : undefined;
|
|
185
189
|
logReadGuardEvent({
|
|
186
190
|
event: "touched_lines_detected",
|
|
187
191
|
sessionId,
|
|
@@ -194,7 +198,7 @@ function resolveOldTextEdits(
|
|
|
194
198
|
totalEditCount: edits.length,
|
|
195
199
|
},
|
|
196
200
|
});
|
|
197
|
-
return { touchedLines };
|
|
201
|
+
return { touchedLines, editRanges };
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
/**
|
|
@@ -309,6 +313,7 @@ export function getTouchedLinesForGuard(
|
|
|
309
313
|
return { touchedLines: undefined };
|
|
310
314
|
}
|
|
311
315
|
let oldTextTouchedLines: [number, number] | undefined;
|
|
316
|
+
let oldTextEditRanges: [number, number][] | undefined;
|
|
312
317
|
if (unresolvedOldTextEdits.length > 0 && filePath) {
|
|
313
318
|
const resolved = resolveOldTextEdits(
|
|
314
319
|
unresolvedOldTextEdits,
|
|
@@ -319,6 +324,7 @@ export function getTouchedLinesForGuard(
|
|
|
319
324
|
return resolved;
|
|
320
325
|
}
|
|
321
326
|
oldTextTouchedLines = resolved.touchedLines;
|
|
327
|
+
oldTextEditRanges = resolved.editRanges;
|
|
322
328
|
}
|
|
323
329
|
const starts = rangedEdits.map(([start]) => start);
|
|
324
330
|
const ends = rangedEdits.map(([, end]) => end);
|
|
@@ -330,6 +336,13 @@ export function getTouchedLinesForGuard(
|
|
|
330
336
|
Math.min(...starts),
|
|
331
337
|
Math.max(...ends),
|
|
332
338
|
];
|
|
339
|
+
const allEditRanges = [...rangedEdits];
|
|
340
|
+
if (oldTextEditRanges?.length) {
|
|
341
|
+
allEditRanges.push(...oldTextEditRanges);
|
|
342
|
+
} else if (oldTextTouchedLines) {
|
|
343
|
+
allEditRanges.push(oldTextTouchedLines);
|
|
344
|
+
}
|
|
345
|
+
const editRanges = allEditRanges.length > 1 ? allEditRanges : undefined;
|
|
333
346
|
if (filePath) {
|
|
334
347
|
logReadGuardEvent({
|
|
335
348
|
event: "touched_lines_detected",
|
|
@@ -348,7 +361,7 @@ export function getTouchedLinesForGuard(
|
|
|
348
361
|
},
|
|
349
362
|
});
|
|
350
363
|
}
|
|
351
|
-
return { touchedLines };
|
|
364
|
+
return { touchedLines, editRanges };
|
|
352
365
|
}
|
|
353
366
|
if (filePath) {
|
|
354
367
|
logReadGuardEvent({
|
package/clients/read-guard.ts
CHANGED
|
@@ -43,6 +43,7 @@ export interface EditRecord {
|
|
|
43
43
|
precedingReads: ReadRecord[];
|
|
44
44
|
verdict: "allowed" | "blocked" | "warned";
|
|
45
45
|
reason?: string;
|
|
46
|
+
timestamp: number;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export interface ReadGuardVerdict {
|
|
@@ -78,6 +79,14 @@ const DEFAULT_CONFIG: ReadGuardConfig = {
|
|
|
78
79
|
],
|
|
79
80
|
};
|
|
80
81
|
|
|
82
|
+
const OWN_EDIT_STALE_GRACE_MS = Math.max(
|
|
83
|
+
0,
|
|
84
|
+
Number.parseInt(
|
|
85
|
+
process.env.PI_LENS_READ_GUARD_OWN_EDIT_GRACE_MS ?? "120000",
|
|
86
|
+
10,
|
|
87
|
+
) || 120000,
|
|
88
|
+
);
|
|
89
|
+
|
|
81
90
|
// --- ReadGuard Class ---
|
|
82
91
|
|
|
83
92
|
export class ReadGuard {
|
|
@@ -136,6 +145,7 @@ export class ReadGuard {
|
|
|
136
145
|
checkEdit(
|
|
137
146
|
filePath: string,
|
|
138
147
|
touchedLines?: [number, number],
|
|
148
|
+
editRanges?: [number, number][],
|
|
139
149
|
): ReadGuardVerdict {
|
|
140
150
|
// Check exemptions
|
|
141
151
|
if (this.exemptions.has(filePath)) {
|
|
@@ -163,7 +173,7 @@ export class ReadGuard {
|
|
|
163
173
|
if (!fileReads || fileReads.length === 0) {
|
|
164
174
|
const verdict = this.blockOrWarn(
|
|
165
175
|
"zero-read",
|
|
166
|
-
`🔴 BLOCKED — Edit without read\n\nYou are trying to edit \`${filePath}\` but have not read it in this conversation.\n\
|
|
176
|
+
`🔴 BLOCKED — Edit without read\n\nYou are trying to edit \`${filePath}\` but have not read it in this conversation.\n\nRead the file first, then retry the edit: \`read path="${filePath}"\``,
|
|
167
177
|
);
|
|
168
178
|
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
169
179
|
reasonKind: "zero_read",
|
|
@@ -172,17 +182,22 @@ export class ReadGuard {
|
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
// 2. FileTime check (actual staleness)
|
|
185
|
+
let ignoredOwnEditStaleness = false;
|
|
175
186
|
if (this.fileTime.hasChanged(filePath)) {
|
|
176
187
|
const lastRead = fileReads[fileReads.length - 1];
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
if (this.canTreatStalenessAsOwnPriorEdit(filePath, lastRead.timestamp)) {
|
|
189
|
+
ignoredOwnEditStaleness = true;
|
|
190
|
+
} else {
|
|
191
|
+
const verdict = this.blockOrWarn(
|
|
192
|
+
"file-modified",
|
|
193
|
+
`🔴 BLOCKED — File modified since read\n\nYou last read \`${filePath}\` at ${new Date(lastRead.timestamp).toISOString()}.\nThe file has been modified on disk since then (auto-format, external tool, or previous edit).\n\nYour mental model is out of sync with the actual file content.\nTo proceed:\n 1. Re-read the file: \`read path="${filePath}"\``,
|
|
194
|
+
);
|
|
195
|
+
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
196
|
+
reasonKind: "file_modified",
|
|
197
|
+
lastReadTimestamp: lastRead.timestamp,
|
|
198
|
+
});
|
|
199
|
+
return verdict;
|
|
200
|
+
}
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
// If no line range specified, we can only check zero-read and FileTime
|
|
@@ -195,44 +210,51 @@ export class ReadGuard {
|
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
// 3. Range coverage check
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
// When the edit touches multiple disjoint spots (e.g. rename across 4 tool
|
|
214
|
+
// registrations), check each spot independently. Collapsing to a bounding
|
|
215
|
+
// box would falsely flag reads that cover exactly the right lines.
|
|
216
|
+
const rangesToCheck: [number, number][] =
|
|
217
|
+
editRanges && editRanges.length > 1 ? editRanges : [touchedLines];
|
|
218
|
+
|
|
219
|
+
let viaSymbol = false;
|
|
220
|
+
for (const range of rangesToCheck) {
|
|
221
|
+
const coverage = this.checkCoverage(filePath, range);
|
|
222
|
+
if (!coverage.covered) {
|
|
223
|
+
const lastRead = fileReads[fileReads.length - 1];
|
|
224
|
+
const [editStart, editEnd] = range;
|
|
225
|
+
const lastReadEnd =
|
|
226
|
+
lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
|
|
227
|
+
const verdict = this.blockOrWarn(
|
|
228
|
+
"out-of-range",
|
|
229
|
+
`🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${lastRead.enclosingSymbol ? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)` : ""}, but your edit touches lines ${editStart}-${editEnd}.\n\nRead the relevant section first, then retry the edit:\n \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\``,
|
|
230
|
+
{
|
|
231
|
+
editRange: range,
|
|
232
|
+
readRanges: fileReads.map((r) => ({
|
|
233
|
+
start: r.effectiveOffset,
|
|
234
|
+
end: r.effectiveOffset + r.effectiveLimit - 1,
|
|
235
|
+
})),
|
|
236
|
+
symbolRanges: fileReads
|
|
237
|
+
.filter((r) => r.enclosingSymbol)
|
|
238
|
+
.map((r) => ({
|
|
239
|
+
name: r.enclosingSymbol!.name,
|
|
240
|
+
start: r.enclosingSymbol!.startLine,
|
|
241
|
+
end: r.enclosingSymbol!.endLine,
|
|
242
|
+
})),
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
246
|
+
reasonKind: "out_of_range",
|
|
247
|
+
});
|
|
248
|
+
return verdict;
|
|
249
|
+
}
|
|
250
|
+
if (coverage.viaSymbol) viaSymbol = true;
|
|
207
251
|
}
|
|
208
252
|
|
|
209
|
-
|
|
210
|
-
const lastRead = fileReads[fileReads.length - 1];
|
|
211
|
-
const [editStart, editEnd] = touchedLines;
|
|
212
|
-
const lastReadEnd = lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
|
|
213
|
-
const symbolCtx = lastRead.enclosingSymbol
|
|
214
|
-
? ` (${lastRead.enclosingSymbol.kind} \`${lastRead.enclosingSymbol.name}\`)`
|
|
215
|
-
: "";
|
|
216
|
-
const verdict = this.blockOrWarn(
|
|
217
|
-
"out-of-range",
|
|
218
|
-
`🔴 BLOCKED — Edit outside read range\n\nYou read \`${filePath}\` lines ${lastRead.effectiveOffset}-${lastReadEnd}${symbolCtx}, but your edit touches lines ${editStart}-${editEnd}.\n\nThe edit target is outside the context you previously read.\nTo proceed:\n 1. Read the relevant section: \`read path="${filePath}" offset=${Math.max(1, editStart - 5)} limit=${Math.min(30, editEnd - editStart + 10)}\`\n 2. Or read the full file: \`read path="${filePath}"\``,
|
|
219
|
-
{
|
|
220
|
-
editRange: touchedLines,
|
|
221
|
-
readRanges: fileReads.map((r) => ({
|
|
222
|
-
start: r.effectiveOffset,
|
|
223
|
-
end: r.effectiveOffset + r.effectiveLimit - 1,
|
|
224
|
-
})),
|
|
225
|
-
symbolRanges: fileReads
|
|
226
|
-
.filter((r) => r.enclosingSymbol)
|
|
227
|
-
.map((r) => ({
|
|
228
|
-
name: r.enclosingSymbol!.name,
|
|
229
|
-
start: r.enclosingSymbol!.startLine,
|
|
230
|
-
end: r.enclosingSymbol!.endLine,
|
|
231
|
-
})),
|
|
232
|
-
},
|
|
233
|
-
);
|
|
253
|
+
const verdict = this.allow();
|
|
234
254
|
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
235
|
-
reasonKind: "
|
|
255
|
+
reasonKind: viaSymbol ? "symbol_coverage" : "range_coverage",
|
|
256
|
+
viaSymbol,
|
|
257
|
+
ignoredOwnEditStaleness,
|
|
236
258
|
});
|
|
237
259
|
return verdict;
|
|
238
260
|
}
|
|
@@ -249,6 +271,15 @@ export class ReadGuard {
|
|
|
249
271
|
}
|
|
250
272
|
}
|
|
251
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Refresh the FileTime stamp after the model's own write lands on disk.
|
|
276
|
+
* Call this from the tool_result handler so the next checkEdit on the same
|
|
277
|
+
* file doesn't see "file_modified" caused by our own previous edit.
|
|
278
|
+
*/
|
|
279
|
+
recordWritten(filePath: string): void {
|
|
280
|
+
this.fileTime.read(filePath);
|
|
281
|
+
}
|
|
282
|
+
|
|
252
283
|
/**
|
|
253
284
|
* Add a one-time exemption for a file.
|
|
254
285
|
* Called via /lens-allow-edit command.
|
|
@@ -331,6 +362,19 @@ export class ReadGuard {
|
|
|
331
362
|
|
|
332
363
|
// --- Private helpers ---
|
|
333
364
|
|
|
365
|
+
private canTreatStalenessAsOwnPriorEdit(
|
|
366
|
+
filePath: string,
|
|
367
|
+
lastReadTimestamp: number,
|
|
368
|
+
): boolean {
|
|
369
|
+
const edits = this.edits.get(filePath) ?? [];
|
|
370
|
+
const latest = edits.at(-1);
|
|
371
|
+
if (!latest) return false;
|
|
372
|
+
if (latest.verdict !== "allowed" && latest.verdict !== "warned")
|
|
373
|
+
return false;
|
|
374
|
+
if (latest.timestamp < lastReadTimestamp) return false;
|
|
375
|
+
return Date.now() - latest.timestamp <= OWN_EDIT_STALE_GRACE_MS;
|
|
376
|
+
}
|
|
377
|
+
|
|
334
378
|
private checkCoverage(
|
|
335
379
|
filePath: string,
|
|
336
380
|
touchedLines: [number, number],
|
|
@@ -457,6 +501,7 @@ export class ReadGuard {
|
|
|
457
501
|
precedingReads: this.reads.get(filePath) ?? [],
|
|
458
502
|
verdict: mapVerdictAction(verdict.action),
|
|
459
503
|
reason: verdict.reason,
|
|
504
|
+
timestamp: Date.now(),
|
|
460
505
|
});
|
|
461
506
|
this.edits.set(filePath, arr);
|
|
462
507
|
}
|
|
@@ -473,7 +518,11 @@ export class ReadGuard {
|
|
|
473
518
|
const reads = this.reads.get(filePath) ?? [];
|
|
474
519
|
logReadGuardEvent({
|
|
475
520
|
event:
|
|
476
|
-
verdict.action === "allow"
|
|
521
|
+
verdict.action === "allow"
|
|
522
|
+
? "edit_allowed"
|
|
523
|
+
: verdict.action === "warn"
|
|
524
|
+
? "edit_warned"
|
|
525
|
+
: "edit_blocked",
|
|
477
526
|
sessionId: this.sessionId,
|
|
478
527
|
filePath,
|
|
479
528
|
metadata: {
|
|
@@ -84,6 +84,9 @@ export async function handleAgentEnd({
|
|
|
84
84
|
|
|
85
85
|
if (result.formatChanged) {
|
|
86
86
|
summary.changed.push(filePath);
|
|
87
|
+
if (!getFlag("no-read-guard")) {
|
|
88
|
+
runtime.readGuard.recordWritten(filePath);
|
|
89
|
+
}
|
|
87
90
|
try {
|
|
88
91
|
const content = nodeFs.readFileSync(filePath, "utf-8");
|
|
89
92
|
const lineCount = content.split("\n").length;
|
|
@@ -5,6 +5,7 @@ import type { BiomeClient } from "./biome-client.js";
|
|
|
5
5
|
import type { CacheManager } from "./cache-manager.js";
|
|
6
6
|
import type { DependencyChecker } from "./dependency-checker.js";
|
|
7
7
|
import { getDiagnosticTracker } from "./diagnostic-tracker.js";
|
|
8
|
+
import { clearAllSessions as clearFileTimeSessions } from "./file-time.js";
|
|
8
9
|
import { getKnipIgnorePatterns } from "./file-utils.js";
|
|
9
10
|
import type { GoClient } from "./go-client.js";
|
|
10
11
|
import type { JscpdClient } from "./jscpd-client.js";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
getDefaultStartupTools,
|
|
16
17
|
} from "./language-profile.js";
|
|
17
18
|
import { runLogCleanup } from "./log-cleanup.js";
|
|
19
|
+
import { setSessionLanguages } from "./widget-state.js";
|
|
18
20
|
import { initLSPConfig, loadLSPConfig } from "./lsp/config.js";
|
|
19
21
|
import { getLSPService } from "./lsp/index.js";
|
|
20
22
|
import type { MetricsClient } from "./metrics-client.js";
|
|
@@ -440,6 +442,7 @@ export async function handleSessionStart(
|
|
|
440
442
|
|
|
441
443
|
metricsClient.reset();
|
|
442
444
|
getDiagnosticTracker().reset();
|
|
445
|
+
clearFileTimeSessions();
|
|
443
446
|
runtime.complexityBaselines.clear();
|
|
444
447
|
resetDispatchBaselines();
|
|
445
448
|
runtime.resetForSession();
|
|
@@ -624,6 +627,8 @@ export async function handleSessionStart(
|
|
|
624
627
|
}
|
|
625
628
|
}
|
|
626
629
|
|
|
630
|
+
setSessionLanguages(languageProfile.detectedKinds);
|
|
631
|
+
|
|
627
632
|
dbg(
|
|
628
633
|
`session_start total: ${Date.now() - sessionStartMs}ms (interactive path; background tasks may continue)`,
|
|
629
634
|
);
|
|
@@ -4,7 +4,9 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { BiomeClient } from "./biome-client.js";
|
|
5
5
|
import type { CacheManager } from "./cache-manager.js";
|
|
6
6
|
import { createFileTime } from "./file-time.js";
|
|
7
|
+
import type { ReadGuard } from "./read-guard.js";
|
|
7
8
|
import { getFormatService } from "./format-service.js";
|
|
9
|
+
import { isExternalOrVendorFile } from "./path-utils.js";
|
|
8
10
|
import { resolveLanguageRootForFile } from "./language-profile.js";
|
|
9
11
|
import { logLatency } from "./latency-logger.js";
|
|
10
12
|
import type { MetricsClient } from "./metrics-client.js";
|
|
@@ -35,6 +37,7 @@ interface ToolResultDeps {
|
|
|
35
37
|
resetLSPService: () => void;
|
|
36
38
|
agentBehaviorRecord: (toolName: string, filePath?: string) => unknown[];
|
|
37
39
|
formatBehaviorWarnings: (warnings: unknown[]) => string;
|
|
40
|
+
readGuard?: ReadGuard;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function parseDiffRanges(diff: string): { start: number; end: number }[] {
|
|
@@ -77,6 +80,13 @@ const lastAnalyzedStateByFile = new Map<
|
|
|
77
80
|
{ turnIndex: number; stateHash: string }
|
|
78
81
|
>();
|
|
79
82
|
|
|
83
|
+
// Called at turn_start — entries from the previous turn can never match the new
|
|
84
|
+
// turnIndex so they're dead weight. Clearing here keeps the map bounded to the
|
|
85
|
+
// files touched in the current turn only (typically < 20).
|
|
86
|
+
export function clearLastAnalyzedStateCache(): void {
|
|
87
|
+
lastAnalyzedStateByFile.clear();
|
|
88
|
+
}
|
|
89
|
+
|
|
80
90
|
function getFileStateHash(filePath: string): string {
|
|
81
91
|
try {
|
|
82
92
|
const content = nodeFs.readFileSync(filePath);
|
|
@@ -108,7 +118,9 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
108
118
|
const rawFilePath = (event.input as { path?: string }).path;
|
|
109
119
|
const workspaceRoot = runtime.projectRoot || process.cwd();
|
|
110
120
|
const filePath = rawFilePath
|
|
111
|
-
?
|
|
121
|
+
? path.isAbsolute(rawFilePath)
|
|
122
|
+
? rawFilePath
|
|
123
|
+
: path.resolve(workspaceRoot, rawFilePath)
|
|
112
124
|
: rawFilePath;
|
|
113
125
|
const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath);
|
|
114
126
|
|
|
@@ -124,6 +136,16 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
124
136
|
);
|
|
125
137
|
return;
|
|
126
138
|
}
|
|
139
|
+
if (isExternalOrVendorFile(filePath, workspaceRoot)) {
|
|
140
|
+
dbg(
|
|
141
|
+
`tool_result: skipped pipeline - file outside project root or in node_modules: ${filePath}`,
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Refresh the read-guard's FileTime stamp so that the model's own write
|
|
147
|
+
// doesn't trigger a spurious "file_modified" block on the next edit.
|
|
148
|
+
deps.readGuard?.recordWritten(filePath);
|
|
127
149
|
|
|
128
150
|
// Keep cachedExports in sync after each write/edit so the pre-write STOP
|
|
129
151
|
// check doesn't fire on names that were removed from this file this session.
|
|
@@ -177,6 +199,14 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
177
199
|
// tool_result is emitted after write/edit has already been applied.
|
|
178
200
|
// Asserting pre-write stamps here produces false positives on rapid edits.
|
|
179
201
|
sessionFileTime.read(filePath);
|
|
202
|
+
if (!getFlag("no-read-guard")) {
|
|
203
|
+
const readGuard = (
|
|
204
|
+
runtime as {
|
|
205
|
+
readGuard?: { recordWritten?: (writtenPath: string) => void };
|
|
206
|
+
}
|
|
207
|
+
).readGuard;
|
|
208
|
+
readGuard?.recordWritten?.(filePath);
|
|
209
|
+
}
|
|
180
210
|
|
|
181
211
|
const toolResultStart = Date.now();
|
|
182
212
|
dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
|
|
@@ -307,6 +337,23 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
307
337
|
stateHash: getFileStateHash(filePath),
|
|
308
338
|
});
|
|
309
339
|
|
|
340
|
+
// The model's write/edit and pi-lens' own immediate format/autofix are now
|
|
341
|
+
// reflected on disk. Refresh read-guard staleness stamps so a follow-up edit
|
|
342
|
+
// is judged by read-range coverage, not by our own previous write.
|
|
343
|
+
if (!getFlag("no-read-guard")) {
|
|
344
|
+
const changedForReadGuard = new Set([
|
|
345
|
+
path.resolve(filePath),
|
|
346
|
+
...(result.changedFiles ?? []).map((changedFile) =>
|
|
347
|
+
path.resolve(changedFile),
|
|
348
|
+
),
|
|
349
|
+
]);
|
|
350
|
+
for (const changedFile of changedForReadGuard) {
|
|
351
|
+
if (nodeFs.existsSync(changedFile)) {
|
|
352
|
+
deps.readGuard?.recordWritten(changedFile);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
310
357
|
if (
|
|
311
358
|
!result.isError &&
|
|
312
359
|
!getFlag("no-autoformat") &&
|
package/clients/runtime-turn.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { getKnipIgnorePatterns } from "./file-utils.js";
|
|
12
12
|
import type { JscpdClient } from "./jscpd-client.js";
|
|
13
13
|
import type { KnipClient, KnipIssue } from "./knip-client.js";
|
|
14
|
+
import { logLatency } from "./latency-logger.js";
|
|
14
15
|
import { RUNTIME_CONFIG } from "./runtime-config.js";
|
|
15
16
|
import type { RuntimeCoordinator } from "./runtime-coordinator.js";
|
|
16
17
|
import type { TestRunnerClient } from "./test-runner-client.js";
|
|
@@ -117,6 +118,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
const turnEndStart = Date.now();
|
|
120
122
|
const blockerParts: string[] = [];
|
|
121
123
|
|
|
122
124
|
// Merge accumulated cascade results from all pipeline runs this turn.
|
|
@@ -124,6 +126,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
124
126
|
// 1. Primary-level: dedup by primary file (last writer wins).
|
|
125
127
|
// 2. Neighbor-level: each neighbor is claimed by the latest cascade result
|
|
126
128
|
// that covers it — suppresses stale neighbor state from earlier writes.
|
|
129
|
+
const t0 = Date.now();
|
|
127
130
|
const cascadeResults = runtime.consumeCascadeResults();
|
|
128
131
|
if (cascadeResults.length > 0) {
|
|
129
132
|
const seen = new Map<string, (typeof cascadeResults)[number]>();
|
|
@@ -163,7 +166,16 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
163
166
|
},
|
|
164
167
|
});
|
|
165
168
|
}
|
|
166
|
-
|
|
169
|
+
logLatency({
|
|
170
|
+
type: "phase",
|
|
171
|
+
toolName: "turn_end",
|
|
172
|
+
filePath: cwd,
|
|
173
|
+
phase: "cascade_merge",
|
|
174
|
+
durationMs: Date.now() - t0,
|
|
175
|
+
metadata: { resultCount: cascadeResults.length },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const t1 = Date.now();
|
|
167
179
|
if (runtime.isStartupScanInFlight("jscpd")) {
|
|
168
180
|
dbg("turn_end: skipping jscpd (startup scan still in flight)");
|
|
169
181
|
} else if (await jscpdClient.ensureAvailable()) {
|
|
@@ -238,7 +250,15 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
238
250
|
cacheManager.writeCache("jscpd", result, cwd);
|
|
239
251
|
}
|
|
240
252
|
}
|
|
241
|
-
|
|
253
|
+
logLatency({
|
|
254
|
+
type: "phase",
|
|
255
|
+
toolName: "turn_end",
|
|
256
|
+
filePath: cwd,
|
|
257
|
+
phase: "jscpd",
|
|
258
|
+
durationMs: Date.now() - t1,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const t2 = Date.now();
|
|
242
262
|
if (runtime.isStartupScanInFlight("knip")) {
|
|
243
263
|
dbg("turn_end: skipping knip (startup scan still in flight)");
|
|
244
264
|
} else if (await knipClient.ensureAvailable()) {
|
|
@@ -283,7 +303,15 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
283
303
|
}
|
|
284
304
|
}
|
|
285
305
|
}
|
|
286
|
-
|
|
306
|
+
logLatency({
|
|
307
|
+
type: "phase",
|
|
308
|
+
toolName: "turn_end",
|
|
309
|
+
filePath: cwd,
|
|
310
|
+
phase: "knip",
|
|
311
|
+
durationMs: Date.now() - t2,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const t3 = Date.now();
|
|
287
315
|
if (await depChecker.ensureAvailable()) {
|
|
288
316
|
const madgeFiles = cacheManager.getFilesForMadge(cwd);
|
|
289
317
|
if (madgeFiles.length > 0) {
|
|
@@ -308,6 +336,14 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
308
336
|
}
|
|
309
337
|
}
|
|
310
338
|
|
|
339
|
+
logLatency({
|
|
340
|
+
type: "phase",
|
|
341
|
+
toolName: "turn_end",
|
|
342
|
+
filePath: cwd,
|
|
343
|
+
phase: "madge",
|
|
344
|
+
durationMs: Date.now() - t3,
|
|
345
|
+
});
|
|
346
|
+
|
|
311
347
|
// --- Test runner: fire once per turn after all edits are done ---
|
|
312
348
|
// Runs for each unique test target across modified files; results appear
|
|
313
349
|
// in the next turn's context injection alongside jscpd/madge findings.
|
|
@@ -386,7 +422,7 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
386
422
|
`turn_end: ${blockerParts.length} blocker section(s) found, persisting for next context`,
|
|
387
423
|
);
|
|
388
424
|
const content = capTurnEndMessage(blockerParts.join("\n\n"));
|
|
389
|
-
const signature = `${files.slice().sort().join("|")}::${content}`;
|
|
425
|
+
const signature = `${files.slice().sort((a, b) => a.localeCompare(b)).join("|")}::${content}`;
|
|
390
426
|
const last = cacheManager.readCache<{ signature: string }>(
|
|
391
427
|
"turn-end-findings-last",
|
|
392
428
|
cwd,
|
|
@@ -407,5 +443,13 @@ export async function handleTurnEnd(deps: TurnEndDeps): Promise<void> {
|
|
|
407
443
|
}
|
|
408
444
|
|
|
409
445
|
runtime.fixedThisTurn.clear();
|
|
446
|
+
logLatency({
|
|
447
|
+
type: "tool_result",
|
|
448
|
+
toolName: "turn_end",
|
|
449
|
+
filePath: cwd,
|
|
450
|
+
durationMs: Date.now() - turnEndStart,
|
|
451
|
+
result: blockerParts.length > 0 ? "blockers_found" : "clean",
|
|
452
|
+
metadata: { fileCount: files.length, blockerSections: blockerParts.length },
|
|
453
|
+
});
|
|
410
454
|
resetFormatService();
|
|
411
455
|
}
|
package/clients/sanitize.ts
CHANGED
|
@@ -244,7 +244,7 @@ export function sanitizeGoOutput(output: string): string {
|
|
|
244
244
|
|
|
245
245
|
return extractDiagnosticLines(output, (line) => {
|
|
246
246
|
const clean = stripAnsi(line);
|
|
247
|
-
return
|
|
247
|
+
return /(?:^\.\/)|(?:\.go:)/.test(clean) || isErrorLine(clean);
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
250
|
|