pi-lens 3.8.39 → 3.8.40
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 +41 -5
- package/clients/biome-client.ts +5 -4
- package/clients/dispatch/integration.ts +2 -0
- package/clients/lsp/client.ts +62 -27
- package/clients/lsp/index.ts +12 -1
- package/clients/lsp/launch.ts +107 -34
- package/clients/lsp/server.ts +76 -57
- package/clients/pipeline.ts +21 -5
- package/clients/read-guard-tool-lines.ts +15 -2
- package/clients/read-guard.ts +56 -36
- package/clients/runtime-session.ts +2 -0
- package/clients/runtime-tool-result.ts +24 -1
- package/clients/tool-policy.ts +1982 -1936
- package/commands/booboo.ts +33 -1
- package/index.ts +31 -9
- package/package.json +2 -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
package/clients/lsp/server.ts
CHANGED
|
@@ -12,10 +12,11 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
|
12
12
|
import { access, appendFile, mkdir, stat } from "node:fs/promises";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { KIND_EXTENSIONS } from "../file-kinds.js"
|
|
15
|
+
import { KIND_EXTENSIONS } from "../file-kinds.js";
|
|
16
16
|
import { ensureTool, getToolEnvironment } from "../installer/index.js";
|
|
17
17
|
import { logLatency } from "../latency-logger.js";
|
|
18
18
|
import { type LSPProcess, launchLSP } from "./launch.js";
|
|
19
|
+
import { normalizeMapKey } from "./path-utils.js";
|
|
19
20
|
|
|
20
21
|
// --- Types ---
|
|
21
22
|
|
|
@@ -489,56 +490,90 @@ export function NearestRoot(
|
|
|
489
490
|
excludePatterns?: string[],
|
|
490
491
|
stopDir?: string,
|
|
491
492
|
): RootFunction {
|
|
493
|
+
// Per-instance caches — each NearestRoot(markers) call gets its own Map so
|
|
494
|
+
// different servers (e.g. TypeScript vs Go) with different marker sets never
|
|
495
|
+
// share entries. vi.resetModules() in tests resets module state between cases.
|
|
496
|
+
const cache = new Map<string, string>();
|
|
497
|
+
const inFlight = new Map<string, Promise<string | undefined>>();
|
|
498
|
+
|
|
492
499
|
return async (file: string): Promise<string | undefined> => {
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
const
|
|
500
|
+
// Cache key is the resolved directory — all files in the same dir share a root.
|
|
501
|
+
const startDir = path.resolve(path.dirname(file));
|
|
502
|
+
const dirKey = normalizeMapKey(startDir);
|
|
503
|
+
|
|
504
|
+
// Fast path: already resolved for this directory.
|
|
505
|
+
const cached = cache.get(dirKey);
|
|
506
|
+
if (cached !== undefined) return cached;
|
|
507
|
+
|
|
508
|
+
// In-flight deduplication: if N parallel pipelines edit files in the same
|
|
509
|
+
// directory simultaneously, only one stat-walk runs; the rest await the same
|
|
510
|
+
// promise. This is the main fix for parallel-turn LSP timeout spikes.
|
|
511
|
+
const flying = inFlight.get(dirKey);
|
|
512
|
+
if (flying) return flying;
|
|
513
|
+
|
|
514
|
+
const promise = (async (): Promise<string | undefined> => {
|
|
515
|
+
let currentDir = startDir;
|
|
516
|
+
const fsRoot = path.parse(currentDir).root;
|
|
517
|
+
const stop = stopDir ? path.resolve(stopDir) : fsRoot;
|
|
518
|
+
|
|
519
|
+
while (true) {
|
|
520
|
+
if (
|
|
521
|
+
stop !== fsRoot &&
|
|
522
|
+
currentDir.startsWith(stop + path.sep) === false &&
|
|
523
|
+
currentDir !== stop
|
|
524
|
+
) {
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
496
527
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
528
|
+
// Check exclude patterns — skip this dir (but keep walking up)
|
|
529
|
+
if (excludePatterns) {
|
|
530
|
+
let excluded = false;
|
|
531
|
+
for (const pattern of excludePatterns) {
|
|
532
|
+
try {
|
|
533
|
+
await stat(path.join(currentDir, pattern));
|
|
534
|
+
excluded = true;
|
|
535
|
+
break;
|
|
536
|
+
} catch {
|
|
537
|
+
/* not found */
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (excluded) {
|
|
541
|
+
currentDir = path.dirname(currentDir);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
505
545
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
let excluded = false;
|
|
509
|
-
for (const pattern of excludePatterns) {
|
|
546
|
+
// Check include patterns
|
|
547
|
+
for (const pattern of includePatterns) {
|
|
510
548
|
try {
|
|
511
549
|
await stat(path.join(currentDir, pattern));
|
|
512
|
-
|
|
513
|
-
break;
|
|
550
|
+
return currentDir;
|
|
514
551
|
} catch {
|
|
515
552
|
/* not found */
|
|
516
553
|
}
|
|
517
554
|
}
|
|
518
|
-
if (excluded) {
|
|
519
|
-
currentDir = path.dirname(currentDir);
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
555
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
try {
|
|
527
|
-
await stat(path.join(currentDir, pattern));
|
|
528
|
-
return currentDir;
|
|
529
|
-
} catch {
|
|
530
|
-
/* not found */
|
|
556
|
+
if (currentDir === stop || currentDir === fsRoot) {
|
|
557
|
+
break;
|
|
531
558
|
}
|
|
532
|
-
}
|
|
533
559
|
|
|
534
|
-
|
|
535
|
-
break;
|
|
560
|
+
currentDir = path.dirname(currentDir);
|
|
536
561
|
}
|
|
537
562
|
|
|
538
|
-
|
|
539
|
-
}
|
|
563
|
+
return undefined;
|
|
564
|
+
})();
|
|
540
565
|
|
|
541
|
-
|
|
566
|
+
inFlight.set(dirKey, promise);
|
|
567
|
+
try {
|
|
568
|
+
const result = await promise;
|
|
569
|
+
// Only cache successful hits. Undefined results are not cached so that
|
|
570
|
+
// a newly-created root marker (e.g. package.json added mid-session) is
|
|
571
|
+
// detected on the next call.
|
|
572
|
+
if (result !== undefined) cache.set(dirKey, result);
|
|
573
|
+
return result;
|
|
574
|
+
} finally {
|
|
575
|
+
inFlight.delete(dirKey);
|
|
576
|
+
}
|
|
542
577
|
};
|
|
543
578
|
}
|
|
544
579
|
|
|
@@ -1362,11 +1397,7 @@ export const NixServer = createInteractiveServer({
|
|
|
1362
1397
|
export const BashServer: LSPServerInfo = {
|
|
1363
1398
|
id: "bash",
|
|
1364
1399
|
name: "Bash Language Server",
|
|
1365
|
-
extensions: [
|
|
1366
|
-
".bash",
|
|
1367
|
-
".sh",
|
|
1368
|
-
".zsh",
|
|
1369
|
-
],
|
|
1400
|
+
extensions: [".bash", ".sh", ".zsh"],
|
|
1370
1401
|
root: FileDirRoot,
|
|
1371
1402
|
spawn(root, options) {
|
|
1372
1403
|
return resolveAndLaunch(
|
|
@@ -1384,10 +1415,7 @@ export const BashServer: LSPServerInfo = {
|
|
|
1384
1415
|
export const DockerServer: LSPServerInfo = {
|
|
1385
1416
|
id: "docker",
|
|
1386
1417
|
name: "Dockerfile Language Server",
|
|
1387
|
-
extensions: [
|
|
1388
|
-
".dockerfile",
|
|
1389
|
-
"Dockerfile",
|
|
1390
|
-
],
|
|
1418
|
+
extensions: [".dockerfile", "Dockerfile"],
|
|
1391
1419
|
root: RootWithFallback(
|
|
1392
1420
|
PriorityRoot([
|
|
1393
1421
|
[
|
|
@@ -1525,9 +1553,7 @@ export const PrismaServer: LSPServerInfo = {
|
|
|
1525
1553
|
export const VueServer: LSPServerInfo = {
|
|
1526
1554
|
id: "vue",
|
|
1527
1555
|
name: "Vue Language Server",
|
|
1528
|
-
extensions: [
|
|
1529
|
-
".vue",
|
|
1530
|
-
],
|
|
1556
|
+
extensions: [".vue"],
|
|
1531
1557
|
root: RootWithFallback(
|
|
1532
1558
|
IgnoreHomeRoot(
|
|
1533
1559
|
createRootDetector([
|
|
@@ -1577,9 +1603,7 @@ export const VueServer: LSPServerInfo = {
|
|
|
1577
1603
|
export const SvelteServer: LSPServerInfo = {
|
|
1578
1604
|
id: "svelte",
|
|
1579
1605
|
name: "Svelte Language Server",
|
|
1580
|
-
extensions: [
|
|
1581
|
-
".svelte",
|
|
1582
|
-
],
|
|
1606
|
+
extensions: [".svelte"],
|
|
1583
1607
|
root: RootWithFallback(
|
|
1584
1608
|
IgnoreHomeRoot(
|
|
1585
1609
|
createRootDetector([
|
|
@@ -1621,12 +1645,7 @@ export const ESLintServer: LSPServerInfo = {
|
|
|
1621
1645
|
id: "eslint",
|
|
1622
1646
|
name: "ESLint Language Server",
|
|
1623
1647
|
// Note: .ts/.tsx handled by TypeScript LSP + Biome
|
|
1624
|
-
extensions: [
|
|
1625
|
-
".js",
|
|
1626
|
-
".jsx",
|
|
1627
|
-
".svelte",
|
|
1628
|
-
".vue",
|
|
1629
|
-
],
|
|
1648
|
+
extensions: [".js", ".jsx", ".svelte", ".vue"],
|
|
1630
1649
|
root: IgnoreHomeRoot(
|
|
1631
1650
|
createRootDetector([
|
|
1632
1651
|
".eslintrc",
|
package/clients/pipeline.ts
CHANGED
|
@@ -725,7 +725,12 @@ export async function runFormatPhase(
|
|
|
725
725
|
if (result.anyChanged) {
|
|
726
726
|
formatChanged = true;
|
|
727
727
|
dbg(
|
|
728
|
-
"autoformat: " +
|
|
728
|
+
"autoformat: " +
|
|
729
|
+
result.formatters
|
|
730
|
+
.map(
|
|
731
|
+
(f) => f.name + "(" + (f.changed ? "changed" : "unchanged") + ")",
|
|
732
|
+
)
|
|
733
|
+
.join(", "),
|
|
729
734
|
);
|
|
730
735
|
}
|
|
731
736
|
if (!result.allSucceeded) {
|
|
@@ -734,7 +739,10 @@ export async function runFormatPhase(
|
|
|
734
739
|
...failures.map((f) => `${f.name}: ${f.error ?? "unknown error"}`),
|
|
735
740
|
);
|
|
736
741
|
dbg(
|
|
737
|
-
"autoformat: " +
|
|
742
|
+
"autoformat: " +
|
|
743
|
+
failures
|
|
744
|
+
.map((f) => f.name + " failed: " + (f.error ?? "unknown error"))
|
|
745
|
+
.join("; "),
|
|
738
746
|
);
|
|
739
747
|
}
|
|
740
748
|
} catch (err) {
|
|
@@ -946,9 +954,17 @@ export async function runPipeline(
|
|
|
946
954
|
const changedList = [...piChangedFiles].map((changedFile) =>
|
|
947
955
|
toRunnerDisplayPath(cwd, changedFile),
|
|
948
956
|
);
|
|
949
|
-
const topFiles = changedList
|
|
950
|
-
|
|
951
|
-
|
|
957
|
+
const topFiles = changedList
|
|
958
|
+
.slice(0, 8)
|
|
959
|
+
.map((f) => " - " + f)
|
|
960
|
+
.join("\n");
|
|
961
|
+
const overflow =
|
|
962
|
+
changedList.length > 8
|
|
963
|
+
? "\n - ... and " + (changedList.length - 8) + " more"
|
|
964
|
+
: "";
|
|
965
|
+
const fileList = changedList.length
|
|
966
|
+
? "\nModified files:\n" + topFiles + overflow
|
|
967
|
+
: "";
|
|
952
968
|
output += `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
|
|
953
969
|
}
|
|
954
970
|
phase.end("dispatch_lint", {
|
|
@@ -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
|
|
|
@@ -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
|
@@ -136,6 +136,7 @@ export class ReadGuard {
|
|
|
136
136
|
checkEdit(
|
|
137
137
|
filePath: string,
|
|
138
138
|
touchedLines?: [number, number],
|
|
139
|
+
editRanges?: [number, number][],
|
|
139
140
|
): ReadGuardVerdict {
|
|
140
141
|
// Check exemptions
|
|
141
142
|
if (this.exemptions.has(filePath)) {
|
|
@@ -195,44 +196,50 @@ export class ReadGuard {
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
// 3. Range coverage check
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
199
|
+
// When the edit touches multiple disjoint spots (e.g. rename across 4 tool
|
|
200
|
+
// registrations), check each spot independently. Collapsing to a bounding
|
|
201
|
+
// box would falsely flag reads that cover exactly the right lines.
|
|
202
|
+
const rangesToCheck: [number, number][] =
|
|
203
|
+
editRanges && editRanges.length > 1 ? editRanges : [touchedLines];
|
|
204
|
+
|
|
205
|
+
let viaSymbol = false;
|
|
206
|
+
for (const range of rangesToCheck) {
|
|
207
|
+
const coverage = this.checkCoverage(filePath, range);
|
|
208
|
+
if (!coverage.covered) {
|
|
209
|
+
const lastRead = fileReads[fileReads.length - 1];
|
|
210
|
+
const [editStart, editEnd] = range;
|
|
211
|
+
const lastReadEnd =
|
|
212
|
+
lastRead.effectiveOffset + lastRead.effectiveLimit - 1;
|
|
213
|
+
const verdict = this.blockOrWarn(
|
|
214
|
+
"out-of-range",
|
|
215
|
+
`🔴 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\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}"\``,
|
|
216
|
+
{
|
|
217
|
+
editRange: range,
|
|
218
|
+
readRanges: fileReads.map((r) => ({
|
|
219
|
+
start: r.effectiveOffset,
|
|
220
|
+
end: r.effectiveOffset + r.effectiveLimit - 1,
|
|
221
|
+
})),
|
|
222
|
+
symbolRanges: fileReads
|
|
223
|
+
.filter((r) => r.enclosingSymbol)
|
|
224
|
+
.map((r) => ({
|
|
225
|
+
name: r.enclosingSymbol!.name,
|
|
226
|
+
start: r.enclosingSymbol!.startLine,
|
|
227
|
+
end: r.enclosingSymbol!.endLine,
|
|
228
|
+
})),
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
232
|
+
reasonKind: "out_of_range",
|
|
233
|
+
});
|
|
234
|
+
return verdict;
|
|
235
|
+
}
|
|
236
|
+
if (coverage.viaSymbol) viaSymbol = true;
|
|
207
237
|
}
|
|
208
238
|
|
|
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
|
-
);
|
|
239
|
+
const verdict = this.allow();
|
|
234
240
|
this.recordVerdict(filePath, "edit", touchedLines, verdict, {
|
|
235
|
-
reasonKind: "
|
|
241
|
+
reasonKind: viaSymbol ? "symbol_coverage" : "range_coverage",
|
|
242
|
+
viaSymbol,
|
|
236
243
|
});
|
|
237
244
|
return verdict;
|
|
238
245
|
}
|
|
@@ -249,6 +256,15 @@ export class ReadGuard {
|
|
|
249
256
|
}
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Refresh the FileTime stamp after the model's own write lands on disk.
|
|
261
|
+
* Call this from the tool_result handler so the next checkEdit on the same
|
|
262
|
+
* file doesn't see "file_modified" caused by our own previous edit.
|
|
263
|
+
*/
|
|
264
|
+
recordWritten(filePath: string): void {
|
|
265
|
+
this.fileTime.read(filePath);
|
|
266
|
+
}
|
|
267
|
+
|
|
252
268
|
/**
|
|
253
269
|
* Add a one-time exemption for a file.
|
|
254
270
|
* Called via /lens-allow-edit command.
|
|
@@ -473,7 +489,11 @@ export class ReadGuard {
|
|
|
473
489
|
const reads = this.reads.get(filePath) ?? [];
|
|
474
490
|
logReadGuardEvent({
|
|
475
491
|
event:
|
|
476
|
-
verdict.action === "allow"
|
|
492
|
+
verdict.action === "allow"
|
|
493
|
+
? "edit_allowed"
|
|
494
|
+
: verdict.action === "warn"
|
|
495
|
+
? "edit_warned"
|
|
496
|
+
: "edit_blocked",
|
|
477
497
|
sessionId: this.sessionId,
|
|
478
498
|
filePath,
|
|
479
499
|
metadata: {
|
|
@@ -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";
|
|
@@ -440,6 +441,7 @@ export async function handleSessionStart(
|
|
|
440
441
|
|
|
441
442
|
metricsClient.reset();
|
|
442
443
|
getDiagnosticTracker().reset();
|
|
444
|
+
clearFileTimeSessions();
|
|
443
445
|
runtime.complexityBaselines.clear();
|
|
444
446
|
resetDispatchBaselines();
|
|
445
447
|
runtime.resetForSession();
|
|
@@ -4,6 +4,7 @@ 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";
|
|
8
9
|
import { resolveLanguageRootForFile } from "./language-profile.js";
|
|
9
10
|
import { logLatency } from "./latency-logger.js";
|
|
@@ -35,6 +36,7 @@ interface ToolResultDeps {
|
|
|
35
36
|
resetLSPService: () => void;
|
|
36
37
|
agentBehaviorRecord: (toolName: string, filePath?: string) => unknown[];
|
|
37
38
|
formatBehaviorWarnings: (warnings: unknown[]) => string;
|
|
39
|
+
readGuard?: ReadGuard;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
function parseDiffRanges(diff: string): { start: number; end: number }[] {
|
|
@@ -77,6 +79,13 @@ const lastAnalyzedStateByFile = new Map<
|
|
|
77
79
|
{ turnIndex: number; stateHash: string }
|
|
78
80
|
>();
|
|
79
81
|
|
|
82
|
+
// Called at turn_start — entries from the previous turn can never match the new
|
|
83
|
+
// turnIndex so they're dead weight. Clearing here keeps the map bounded to the
|
|
84
|
+
// files touched in the current turn only (typically < 20).
|
|
85
|
+
export function clearLastAnalyzedStateCache(): void {
|
|
86
|
+
lastAnalyzedStateByFile.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
function getFileStateHash(filePath: string): string {
|
|
81
90
|
try {
|
|
82
91
|
const content = nodeFs.readFileSync(filePath);
|
|
@@ -108,7 +117,9 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
108
117
|
const rawFilePath = (event.input as { path?: string }).path;
|
|
109
118
|
const workspaceRoot = runtime.projectRoot || process.cwd();
|
|
110
119
|
const filePath = rawFilePath
|
|
111
|
-
?
|
|
120
|
+
? path.isAbsolute(rawFilePath)
|
|
121
|
+
? rawFilePath
|
|
122
|
+
: path.resolve(workspaceRoot, rawFilePath)
|
|
112
123
|
: rawFilePath;
|
|
113
124
|
const behaviorWarnings = agentBehaviorRecord(event.toolName, filePath);
|
|
114
125
|
|
|
@@ -125,6 +136,10 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
125
136
|
return;
|
|
126
137
|
}
|
|
127
138
|
|
|
139
|
+
// Refresh the read-guard's FileTime stamp so that the model's own write
|
|
140
|
+
// doesn't trigger a spurious "file_modified" block on the next edit.
|
|
141
|
+
deps.readGuard?.recordWritten(filePath);
|
|
142
|
+
|
|
128
143
|
// Keep cachedExports in sync after each write/edit so the pre-write STOP
|
|
129
144
|
// check doesn't fire on names that were removed from this file this session.
|
|
130
145
|
if (runtime.cachedExports.size > 0 && nodeFs.existsSync(filePath)) {
|
|
@@ -177,6 +192,14 @@ export async function handleToolResult(deps: ToolResultDeps): Promise<{
|
|
|
177
192
|
// tool_result is emitted after write/edit has already been applied.
|
|
178
193
|
// Asserting pre-write stamps here produces false positives on rapid edits.
|
|
179
194
|
sessionFileTime.read(filePath);
|
|
195
|
+
if (!getFlag("no-read-guard")) {
|
|
196
|
+
const readGuard = (
|
|
197
|
+
runtime as {
|
|
198
|
+
readGuard?: { recordWritten?: (writtenPath: string) => void };
|
|
199
|
+
}
|
|
200
|
+
).readGuard;
|
|
201
|
+
readGuard?.recordWritten?.(filePath);
|
|
202
|
+
}
|
|
180
203
|
|
|
181
204
|
const toolResultStart = Date.now();
|
|
182
205
|
dbg(`tool_result: tracking turn state for ${event.toolName} on ${filePath}`);
|