pi-lens 3.8.51 → 3.8.52
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
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-lens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [3.8.52] - 2026-06-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **read-guard: canonicalize path map keys — stops false `zero_read` blocks (closes #210)** — `ReadGuard` keyed its `reads`/`edits`/`exemptions` maps on the **raw** file-path string, relying on the read-path and edit-path strings being byte-for-byte identical. `resolveToolCallFilePath` returns absolute paths verbatim, so the key was whatever separator/casing the model emitted — and models freely mix `/` and `\` on Windows. The regression trigger: read-guard started recording reads from new sources that produce a *different* path form than the Edit tool — `ast_grep_search` matches (#169, slash-normalized from ast-grep output) and LSP-expanded synthetic reads (URI → forward slash). On Windows a file read via search/LSP got a `C:/…` key while the follow-up edit arrived `C:\…` → key miss → false `zero_read` ("Edit without read") despite the file having been read, repeatedly, in a real session (`pi-free`: reads logged `C:/…`, the blocking edit `C:\…`). Every map access now keys through `normalizeFilePath` (folds separators + Windows casing), so record and lookup always agree. **Why it slipped:** every read-guard test used the *same* POSIX path on both `recordRead` and `checkEdit`, so the raw keys always matched — no test exercised cross-separator/cross-source agreement. Closed by `tests/clients/read-guard-path-normalization.test.ts` (forward↔back-slash both directions, Windows case-folding, exemption parity, and a negative: a genuinely-unread file still blocks).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Live tool-smoke harness driving the real dispatch path (refs #209, layer 2)** — `scripts/smoke-tools.mjs` installs (via the real `ensureTool` auto-install) and runs each supported tool against a minimal real project per language (`tests/fixtures/tool-smoke/<lang>/`), driving pi-lens's **real** dispatch path so a smoke pass means the actual runner→spawn code worked (not a hand-rolled stand-in). Step 1 (default) asserts each target tool spawns and exits cleanly (no `timeout`/`exception`/`server_error`); Step 2 (`--step2`) additionally asserts a parseable diagnostic on the fixture's known defect. Per-runner truth comes from a new optional `onRunnerResult` sink threaded through `dispatchForFile`→`runGroup` (fires per executed runner with its exact `RunnerResult` incl. `failureKind`) exposed via `dispatchLintDetailed` — no duplication of dispatch's selection/gating. Opt-in/nightly (installs + spawns real tools), never a per-PR gate; not shipped in the npm tarball. Already surfaced a real wiring gap: `markdownlint` is registered (priority 30) and installs, but the markdown write-dispatch group is `["spellcheck","vale"]`, so it never runs on markdown writes.
|
|
14
|
+
|
|
15
|
+
- **Deterministic auto-install registry-consistency guard (refs #209, layer 1)** — the live install→run net for every supported tool is expensive and environment-dependent (deferred to layer 2); this catches the cheap-to-catch class per-PR. A new `tests/clients/installer/tool-registry-consistency.test.ts` exports the previously-private `TOOLS` array and locks the **install contract** that `installTool` silently depends on: each `npm` entry declares `packageName`+`binaryName`, each `pip`/`gem` entry declares `packageName`, each `github` entry declares an `owner/repo` + `assetMatch` + `binaryName` and no `packageName` — a half-wired entry compiles fine today but just `return false`s at install time, so it "looks registered" while never installing. It also asserts ids are globally unique, `checkCommand`/`binaryName` are clean executable tokens, and every `github` tool's `assetMatch` is total/safe (never throws across the platform×arch matrix incl. unsupported platforms, resolves at least one combo, rejects freebsd/sunos/aix). **Fixed a coverage drift it surfaced:** `GITHUB_TOOLS` (the curated list the asset-matrix value-test iterates) had drifted to 9 of the 14 actual `github`-strategy tools, leaving `hadolint`, `gitleaks`, `taplo`, and `vale` asset selection **completely untested** — they're now in `GITHUB_TOOLS` (so the full matrix test covers them), and a bidirectional sync assertion keeps the list ≡ "github tools with full cross-platform coverage" going forward (`swiftlint` is intentionally excluded — no Windows asset).
|
|
16
|
+
|
|
5
17
|
## [3.8.51] - 2026-06-14
|
|
6
18
|
|
|
7
19
|
### Added
|
|
@@ -389,7 +389,7 @@ export function formatLatencyReport(report) {
|
|
|
389
389
|
* Groups themselves are run in parallel by dispatchForFile, so this
|
|
390
390
|
* function must NOT mutate shared state.
|
|
391
391
|
*/
|
|
392
|
-
async function runGroup(ctx, group, registry) {
|
|
392
|
+
async function runGroup(ctx, group, registry, onRunnerResult) {
|
|
393
393
|
const diagnostics = [];
|
|
394
394
|
const latencies = [];
|
|
395
395
|
let hadBlocker = false;
|
|
@@ -458,6 +458,7 @@ async function runGroup(ctx, group, registry) {
|
|
|
458
458
|
continue;
|
|
459
459
|
}
|
|
460
460
|
const result = await runRunner(ctx, runner, semantic);
|
|
461
|
+
onRunnerResult?.(runnerId, result);
|
|
461
462
|
const runnerEnd = Date.now();
|
|
462
463
|
const duration = runnerEnd - runnerStart;
|
|
463
464
|
latencies.push({
|
|
@@ -508,7 +509,7 @@ async function runGroup(ctx, group, registry) {
|
|
|
508
509
|
return { diagnostics, latencies, hadBlocker };
|
|
509
510
|
}
|
|
510
511
|
// --- Main Dispatch Function ---
|
|
511
|
-
export async function dispatchForFile(ctx, groups, registry) {
|
|
512
|
+
export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
|
|
512
513
|
const _overallStart = Date.now();
|
|
513
514
|
if (ctx.fileRole === "generated") {
|
|
514
515
|
return {
|
|
@@ -543,7 +544,7 @@ export async function dispatchForFile(ctx, groups, registry) {
|
|
|
543
544
|
// each other's results. Within each group, mode:"fallback" semantics are
|
|
544
545
|
// preserved (sequential first-success). Results are merged in original
|
|
545
546
|
// group order so output is deterministic.
|
|
546
|
-
const groupResults = await Promise.all(groups.map((group) => runGroup(ctx, group, registry)));
|
|
547
|
+
const groupResults = await Promise.all(groups.map((group) => runGroup(ctx, group, registry, onRunnerResult)));
|
|
547
548
|
// Count baseline warnings before filtering (for delta count display)
|
|
548
549
|
const relativeKey = path.relative(ctx.cwd, ctx.filePath).replace(/\\/g, "/");
|
|
549
550
|
const baselineAbsKey = `session.baseline.${normalizeMapKey(ctx.filePath)}`;
|
|
@@ -1030,6 +1030,44 @@ export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges,
|
|
|
1030
1030
|
}
|
|
1031
1031
|
return result;
|
|
1032
1032
|
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Same real dispatch path as {@link dispatchLintWithResult} (real context, real
|
|
1035
|
+
* file-kind→runner selection, real `run()` → spawn → tool), but also returns
|
|
1036
|
+
* each runner's exact `RunnerResult` (status + `failureKind` + diagnostics) via
|
|
1037
|
+
* the `onRunnerResult` sink. The live tool-smoke harness (#209) uses this to
|
|
1038
|
+
* assert each supported tool spawned and exited cleanly without re-implementing
|
|
1039
|
+
* dispatch's selection/gating. Defaults to `blockingOnly: false` so every
|
|
1040
|
+
* applicable runner (not just blocking ones) executes.
|
|
1041
|
+
*/
|
|
1042
|
+
export async function dispatchLintDetailed(filePath, cwd, pi, options) {
|
|
1043
|
+
const empty = {
|
|
1044
|
+
diagnostics: [],
|
|
1045
|
+
blockers: [],
|
|
1046
|
+
warnings: [],
|
|
1047
|
+
baselineWarningCount: 0,
|
|
1048
|
+
fixed: [],
|
|
1049
|
+
resolvedCount: 0,
|
|
1050
|
+
output: "",
|
|
1051
|
+
blockerOutput: "",
|
|
1052
|
+
hasBlockers: false,
|
|
1053
|
+
};
|
|
1054
|
+
const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges);
|
|
1055
|
+
sessionFacts.clearFileFactsFor(ctx.filePath);
|
|
1056
|
+
const kind = ctx.kind;
|
|
1057
|
+
if (!kind)
|
|
1058
|
+
return { result: empty, runners: [] };
|
|
1059
|
+
const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
|
|
1060
|
+
if (groups.length === 0)
|
|
1061
|
+
return { result: empty, runners: [] };
|
|
1062
|
+
const runners = [];
|
|
1063
|
+
const sink = (runnerId, result) => {
|
|
1064
|
+
runners.push({ runnerId, result });
|
|
1065
|
+
};
|
|
1066
|
+
await runProviders(ctx);
|
|
1067
|
+
const result = await dispatchForFile(ctx, groups, sessionRunnerRegistry, sink);
|
|
1068
|
+
trackSessionSlopStats(ctx, result.diagnostics);
|
|
1069
|
+
return { result, runners };
|
|
1070
|
+
}
|
|
1033
1071
|
/**
|
|
1034
1072
|
* Check if a file should be processed by the dispatcher
|
|
1035
1073
|
* based on the file kind
|
|
@@ -83,7 +83,7 @@ function logSessionStart(msg) {
|
|
|
83
83
|
// best-effort logging
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
const TOOLS = [
|
|
86
|
+
export const TOOLS = [
|
|
87
87
|
// Core LSP servers
|
|
88
88
|
{
|
|
89
89
|
id: "typescript-language-server",
|
|
@@ -1940,6 +1940,18 @@ export async function checkAllTools() {
|
|
|
1940
1940
|
export function isKnownToolId(toolId) {
|
|
1941
1941
|
return TOOLS.some((tool) => tool.id === toolId);
|
|
1942
1942
|
}
|
|
1943
|
+
/**
|
|
1944
|
+
* GitHub-release tools that ship an asset for **every** supported
|
|
1945
|
+
* platform/arch combo (linux/darwin/win32 × x64/arm64). This is the set the
|
|
1946
|
+
* full asset-matrix test (tests/clients/installer/github-release.test.ts)
|
|
1947
|
+
* iterates, so membership must stay in lockstep with the registry — the
|
|
1948
|
+
* tool-registry-consistency test enforces that every `installStrategy: "github"`
|
|
1949
|
+
* entry resolving all six combos appears here, and vice versa.
|
|
1950
|
+
*
|
|
1951
|
+
* `swiftlint` is deliberately absent: it has no Windows asset (macOS + Linux
|
|
1952
|
+
* only), so it cannot satisfy the full matrix and is covered by the weaker
|
|
1953
|
+
* "at least one platform" guard instead.
|
|
1954
|
+
*/
|
|
1943
1955
|
export const GITHUB_TOOLS = [
|
|
1944
1956
|
"shellcheck",
|
|
1945
1957
|
"shfmt",
|
|
@@ -1950,6 +1962,10 @@ export const GITHUB_TOOLS = [
|
|
|
1950
1962
|
"tflint",
|
|
1951
1963
|
"terraform-ls",
|
|
1952
1964
|
"zls",
|
|
1965
|
+
"hadolint",
|
|
1966
|
+
"gitleaks",
|
|
1967
|
+
"taplo",
|
|
1968
|
+
"vale",
|
|
1953
1969
|
];
|
|
1954
1970
|
/**
|
|
1955
1971
|
* Resolve the GitHub asset filename substring for a tool on a given platform/arch.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import { createFileTime } from "./file-time.js";
|
|
14
|
+
import { normalizeFilePath } from "./path-utils.js";
|
|
14
15
|
import { logReadGuardEvent } from "./read-guard-logger.js";
|
|
15
16
|
// --- Constants ---
|
|
16
17
|
const DEFAULT_CONFIG = {
|
|
@@ -132,16 +133,31 @@ export class ReadGuard {
|
|
|
132
133
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
133
134
|
this.fileTime = createFileTime(sessionId);
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Canonical Map key for a file path. Read sources arrive with mixed
|
|
138
|
+
* separators/casing — the Read tool gives OS-native backslashes on Windows,
|
|
139
|
+
* while LSP-expanded and search-tool reads arrive slash-normalized from URIs.
|
|
140
|
+
* Keying the reads/edits/exemptions maps on the raw path made a read recorded
|
|
141
|
+
* under one form invisible to an edit checked under another, producing a false
|
|
142
|
+
* `zero_read` block despite the file having been read. `normalizeFilePath`
|
|
143
|
+
* folds separators and Windows casing to one key, so record and lookup always
|
|
144
|
+
* agree. Every map access in this class MUST key through here.
|
|
145
|
+
*/
|
|
146
|
+
key(filePath) {
|
|
147
|
+
return normalizeFilePath(filePath);
|
|
148
|
+
}
|
|
135
149
|
// --- Public API ---
|
|
136
150
|
/**
|
|
137
151
|
* Record that a file was read.
|
|
138
152
|
* Call this from the tool_call handler after any LSP expansion.
|
|
139
153
|
*/
|
|
140
154
|
recordRead(record) {
|
|
155
|
+
const filePath = this.key(record.filePath);
|
|
141
156
|
const storedRecord = {
|
|
142
157
|
...record,
|
|
158
|
+
filePath,
|
|
143
159
|
lineHashes: record.lineHashes ??
|
|
144
|
-
captureLineHashes(
|
|
160
|
+
captureLineHashes(filePath, record.effectiveOffset, record.effectiveLimit),
|
|
145
161
|
};
|
|
146
162
|
const arr = this.reads.get(storedRecord.filePath) ?? [];
|
|
147
163
|
arr.push(storedRecord);
|
|
@@ -174,6 +190,9 @@ export class ReadGuard {
|
|
|
174
190
|
* Returns verdict with action and optional reason for blocking.
|
|
175
191
|
*/
|
|
176
192
|
checkEdit(filePath, touchedLines, editRanges, options) {
|
|
193
|
+
// Canonicalize once: every map lookup below (and every private helper this
|
|
194
|
+
// passes filePath to) must agree with how recordRead keyed the read.
|
|
195
|
+
filePath = this.key(filePath);
|
|
177
196
|
// Check exemptions
|
|
178
197
|
if (this.exemptions.has(filePath)) {
|
|
179
198
|
this.exemptions.delete(filePath); // One-time use
|
|
@@ -370,7 +389,7 @@ export class ReadGuard {
|
|
|
370
389
|
* read so immediate follow-up edits are not blocked by zero_read.
|
|
371
390
|
*/
|
|
372
391
|
noteCreatedFile(filePath, turnIndex, writeIndex) {
|
|
373
|
-
this.pendingCreations.set(filePath, { turnIndex, writeIndex });
|
|
392
|
+
this.pendingCreations.set(this.key(filePath), { turnIndex, writeIndex });
|
|
374
393
|
}
|
|
375
394
|
/**
|
|
376
395
|
* Refresh the FileTime stamp after the model's own write lands on disk.
|
|
@@ -378,6 +397,7 @@ export class ReadGuard {
|
|
|
378
397
|
* file doesn't see "file_modified" caused by our own previous edit.
|
|
379
398
|
*/
|
|
380
399
|
recordWritten(filePath) {
|
|
400
|
+
filePath = this.key(filePath);
|
|
381
401
|
this.fileTime.read(filePath);
|
|
382
402
|
this.writtenThisSession.add(filePath);
|
|
383
403
|
const creation = this.pendingCreations.get(filePath);
|
|
@@ -391,7 +411,7 @@ export class ReadGuard {
|
|
|
391
411
|
* Called via /lens-allow-edit command.
|
|
392
412
|
*/
|
|
393
413
|
addExemption(filePath) {
|
|
394
|
-
this.exemptions.add(filePath);
|
|
414
|
+
this.exemptions.add(this.key(filePath));
|
|
395
415
|
logReadGuardEvent({
|
|
396
416
|
event: "exemption_added",
|
|
397
417
|
sessionId: this.sessionId,
|
|
@@ -441,13 +461,13 @@ export class ReadGuard {
|
|
|
441
461
|
* Get all read records for a file (for debugging).
|
|
442
462
|
*/
|
|
443
463
|
getReadHistory(filePath) {
|
|
444
|
-
return this.reads.get(filePath) ?? [];
|
|
464
|
+
return this.reads.get(this.key(filePath)) ?? [];
|
|
445
465
|
}
|
|
446
466
|
/**
|
|
447
467
|
* Get all edit records for a file (for debugging).
|
|
448
468
|
*/
|
|
449
469
|
getEditHistory(filePath) {
|
|
450
|
-
return this.edits.get(filePath) ?? [];
|
|
470
|
+
return this.edits.get(this.key(filePath)) ?? [];
|
|
451
471
|
}
|
|
452
472
|
// --- Private helpers ---
|
|
453
473
|
injectCreationRead(filePath, turnIndex, writeIndex) {
|
|
@@ -22,6 +22,20 @@ function escapeWindowsArg(arg) {
|
|
|
22
22
|
// Escape quotes by doubling them
|
|
23
23
|
return `"${arg.replace(/"/g, '""')}"`;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Build the `bash -c` argv that runs `cmd` with `allArgs` as POSITIONAL
|
|
27
|
+
* parameters. The script is the constant `"$0" "$@"`, so bash re-emits the
|
|
28
|
+
* command and every arg verbatim — no parameter expansion, no word-splitting.
|
|
29
|
+
*
|
|
30
|
+
* Two properties this guarantees, neither of which string-interpolation could:
|
|
31
|
+
* - ast-grep `$METAVAR` patterns reach the binary literally (not shell-expanded).
|
|
32
|
+
* - an environment-derived command path (PATH-resolved `ast-grep`/`sg`/`npx`)
|
|
33
|
+
* cannot inject shell — it's argv[0], never part of the script string
|
|
34
|
+
* (CodeQL js/shell-command-injection-from-environment).
|
|
35
|
+
*/
|
|
36
|
+
export function buildBashRunArgs(cmd, allArgs) {
|
|
37
|
+
return ["-c", '"$0" "$@"', cmd, ...allArgs];
|
|
38
|
+
}
|
|
25
39
|
function sgExcludeArgsForProject(rootDir) {
|
|
26
40
|
return getProjectIgnoreGlobs(rootDir).flatMap((glob) => [
|
|
27
41
|
"--globs",
|
|
@@ -265,24 +279,15 @@ export class SgRunner {
|
|
|
265
279
|
const hasBash = process.env.MSYSTEM || process.env.GIT_SHELL;
|
|
266
280
|
let proc;
|
|
267
281
|
if (isWindows && hasBash) {
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
278
|
-
}
|
|
279
|
-
return arg;
|
|
280
|
-
});
|
|
281
|
-
const escapedCmd = /[\s"]/g.test(command.cmd)
|
|
282
|
-
? `"${command.cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
283
|
-
: command.cmd;
|
|
284
|
-
const bashCommand = `${escapedCmd} ${escapedArgs.join(" ")}`;
|
|
285
|
-
proc = spawn("bash", ["-c", bashCommand], {
|
|
282
|
+
// Run via bash (Git Bash/MSYS2) so $-metavariables in ast-grep
|
|
283
|
+
// patterns aren't shell-expanded. Pass the command + args as
|
|
284
|
+
// POSITIONAL parameters (`"$0"`/`"$@"`) instead of interpolating
|
|
285
|
+
// them into the -c string: bash re-emits `"$@"` verbatim — no
|
|
286
|
+
// parameter expansion, no word-splitting — so patterns stay literal
|
|
287
|
+
// AND an environment-derived command path cannot inject shell
|
|
288
|
+
// (fixes CodeQL js/shell-command-injection-from-environment). This
|
|
289
|
+
// also removes the brittle hand-rolled quoting it replaced.
|
|
290
|
+
proc = spawn("bash", buildBashRunArgs(command.cmd, allArgs), {
|
|
286
291
|
stdio: ["ignore", "pipe", "pipe"],
|
|
287
292
|
windowsHide: true,
|
|
288
293
|
});
|