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(record.filePath, record.effectiveOffset, record.effectiveLimit),
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
- // Use bash -c with properly escaped command
269
- // In bash, use single quotes around arguments containing $ to prevent expansion
270
- const escapedArgs = allArgs.map((arg) => {
271
- // For bash, wrap $-containing args in single quotes
272
- if (arg.includes("$")) {
273
- return `'${arg.replace(/'/g, "'\\''")}'`;
274
- }
275
- // For other args with spaces/special chars, use double quotes
276
- if (/[\s"]/.test(arg)) {
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.51",
3
+ "version": "3.8.52",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {