pi-lens 3.8.61 → 3.8.63

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +5 -4
  3. package/banner.png +0 -0
  4. package/banner.svg +54 -46
  5. package/dist/clients/ast-grep-client.js +160 -3
  6. package/dist/clients/ast-grep-tool-logger.js +1 -1
  7. package/dist/clients/ast-grep-yaml-synth.js +30 -6
  8. package/dist/clients/bootstrap.js +99 -33
  9. package/dist/clients/complexity-client.js +1 -1
  10. package/dist/clients/dead-code-client.js +304 -0
  11. package/dist/clients/dead-code-logger.js +46 -0
  12. package/dist/clients/deadline-utils.js +70 -0
  13. package/dist/clients/dependency-checker.js +55 -16
  14. package/dist/clients/deps/ast-grep-napi.js +3 -0
  15. package/dist/clients/deps/js-yaml.js +9 -0
  16. package/dist/clients/deps/minimatch.js +4 -0
  17. package/dist/clients/deps/pi-tui.js +5 -0
  18. package/dist/clients/deps/typebox.js +6 -0
  19. package/dist/clients/deps/typescript.js +15 -0
  20. package/dist/clients/deps/vscode-jsonrpc.js +1 -0
  21. package/dist/clients/deps/web-tree-sitter.js +3 -0
  22. package/dist/clients/dispatch/fact-runner.js +59 -0
  23. package/dist/clients/dispatch/facts/comment-facts.js +1 -1
  24. package/dist/clients/dispatch/facts/function-facts.js +1 -1
  25. package/dist/clients/dispatch/facts/import-facts.js +1 -1
  26. package/dist/clients/dispatch/facts/try-catch-facts.js +1 -1
  27. package/dist/clients/dispatch/integration.js +7 -20
  28. package/dist/clients/dispatch/rules/quality-rules.js +1 -1
  29. package/dist/clients/dispatch/rules/sonar-rules.js +1 -1
  30. package/dist/clients/dispatch/runners/ast-grep-napi.js +2 -1
  31. package/dist/clients/dispatch/runners/yaml-rule-parser.js +1 -1
  32. package/dist/clients/event-loop-monitor.js +18 -3
  33. package/dist/clients/file-utils.js +1 -1
  34. package/dist/clients/grammar-source.js +71 -0
  35. package/dist/clients/install-diagnostics.js +141 -0
  36. package/dist/clients/installer/index.js +114 -9
  37. package/dist/clients/knip-client.js +62 -43
  38. package/dist/clients/lsp/client.js +113 -62
  39. package/dist/clients/lsp/index.js +35 -8
  40. package/dist/clients/lsp/interactive-install.js +26 -24
  41. package/dist/clients/lsp/launch.js +2 -2
  42. package/dist/clients/lsp/server.js +30 -23
  43. package/dist/clients/mcp/session.js +2 -0
  44. package/dist/clients/module-report-lsp.js +1 -16
  45. package/dist/clients/module-report.js +1121 -59
  46. package/dist/clients/pipeline.js +58 -37
  47. package/dist/clients/project-diagnostics/runner-adapters/knip.js +3 -0
  48. package/dist/clients/project-diagnostics/scanner.js +23 -6
  49. package/dist/clients/read-expansion.js +1 -14
  50. package/dist/clients/read-guard-tool-lines.js +5 -1
  51. package/dist/clients/review-graph/builder.js +15 -4
  52. package/dist/clients/runtime-session.js +44 -4
  53. package/dist/clients/runtime-turn.js +75 -13
  54. package/dist/clients/tree-sitter-client.js +80 -37
  55. package/dist/clients/tree-sitter-symbol-extractor.js +86 -1
  56. package/dist/clients/ts-service.js +1 -1
  57. package/dist/clients/typescript-client.js +1 -1
  58. package/dist/clients/widget-state.js +1 -1
  59. package/dist/index.js +41 -11
  60. package/dist/tools/ast-dump.js +76 -29
  61. package/dist/tools/ast-grep-outline.js +148 -0
  62. package/dist/tools/ast-grep-replace.js +13 -1
  63. package/dist/tools/ast-grep-search.js +405 -112
  64. package/dist/tools/lens-diagnostics.js +57 -9
  65. package/dist/tools/lsp-diagnostics.js +17 -1
  66. package/dist/tools/lsp-navigation.js +14 -1
  67. package/dist/tools/module-report.js +257 -27
  68. package/dist/tools/render-compact.js +95 -0
  69. package/package.json +5 -1
  70. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
  71. package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
  72. package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
  73. package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
  74. package/scripts/install-selftest.mjs +173 -0
  75. package/scripts/rpc-load-check.mjs +80 -0
  76. package/skills/ast-grep/SKILL.md +26 -6
  77. package/skills/write-ast-grep-rule/SKILL.md +36 -26
package/CHANGELOG.md CHANGED
@@ -10,6 +10,43 @@ All notable changes to pi-lens will be documented in this file.
10
10
 
11
11
  ### Fixed
12
12
 
13
+ ## [3.8.63] - 2026-07-01
14
+
15
+ ### Added
16
+
17
+ ### Changed
18
+
19
+ - **Consolidated the timeout-race helpers into one shared `clients/deadline-utils.ts` (#366)** — the "race a promise against a timer" pattern had drifted into three near-identical copies (`withTimeout` in the LSP client, `withBudget` in read-expansion, `withinRemaining` in module-report). They're now thin adapters over one `withDeadline` core, which also fixes two latent bugs the copies carried: `withBudget` didn't suppress the loser promise's late rejection (an unhandled rejection when the timer won first), and `withinRemaining` never cleared its timer. Behaviour at every call site is unchanged (covered by the consumer suites); the core's semantics are locked by dedicated tests including an explicit late-rejection-suppression probe.
20
+
21
+ ### Fixed
22
+
23
+ - **Bounded the remaining unbounded LSP requests — `workspace/symbol`, `textDocument/codeAction`, `workspace/executeCommand` (#365)** — like the pull-diagnostics fix (#364), these were sent via `safeSendRequest` with no `withTimeout` ceiling, so a language server that accepts a request but never replies (alive but hung) would make the `await` never resolve — hanging symbol search (`pilens_symbol_search`), code-action lookups, and server-command execution. `workspace/symbol` and `textDocument/codeAction` now route through the shared `navRequest` helper (its `withTimeout` ceiling + single-file stale-drop), timing out to `[]`. `workspace/executeCommand` — which is mutating and legitimately long-running — gets a separate, generous 30s anti-deadlock backstop (`PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS`) that returns an honest `executed:false, reason:"…may still be applying server-side"` rather than truncating valid work or pretending it ran; its allowlist-by-advertisement and server-edit-window hardening are preserved. Real (non-timeout) errors still propagate throughout.
24
+ - **Autofix project-snapshot walk no longer freezes the TUI on large repos (#368)** — `snapshotProjectFiles` (the `tool_result` autofix side-effect detection that snapshots the project tree before/after a formatter or fixer runs, to catch files it changed as a side effect) was a fully synchronous `readdirSync`/`statSync` walk bounded only by a 5,000-file cap — a ~130ms event-loop block at the cap (2–4× that under load), stalling keystrokes on large projects while autofix ran. It now walks asynchronously and yields to the event loop every 500 files, so it holds the loop only for a short chunk. The scan cap and directory-exclusion/confinement behavior are unchanged; a cap-scale (~5k-file) event-loop occupancy guard asserts the walk keeps yielding.
25
+ - **Bounded LSP pull-diagnostics request — a hung server can no longer hang `lens_diagnostics` (#349, #364)** — the pull path in `clientWaitForDiagnostics` awaited a `textDocument/diagnostic` request via `safeSendRequest`, which only settles on a reply or a *destroyed* stream. A pull-mode server that is alive but hung (accepts the request, never replies) made that `await` never resolve, hanging the diagnostics wait → the `dispatch_lint` pipeline phase → `flushDebouncedToolResults` → `lens_diagnostics`, forever (and `safeSpawnAsync`'s 30s cap doesn't apply — it's a pipe request, not a spawn). Unlike the navigation/init/shutdown callers, this request had no `withTimeout` ceiling; the `timeoutMs` passed into `clientWaitForDiagnostics` only bounded the push backstop and the pull *retry interval*, never the individual request. The request is now wrapped in the existing `withTimeout` helper, bounded by `min(PULL_REQUEST_TIMEOUT_MS, remaining caller budget)` (env `PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS`, default 10s, mirroring `NAV_REQUEST_TIMEOUT_MS`). On timeout the request is caught as `unavailable`, which per #240 is not read as clean and falls through to the already-bounded push backstop. Explains the intermittent repro — it only fires for pull-mode servers (rust-analyzer being the classic) stalling mid-analysis. Regression test: a pull server whose `sendRequest` never resolves now resolves within the caller's budget instead of hanging.
26
+ - **POSIX LSP teardown now cleans up the whole process tree, not just the direct child (#362, #363)** — on POSIX, LSP servers launched through wrappers (npm shims, shell/node launchers) could leave descendants alive after pi-lens reset or shut down an LSP service; observed most visibly as `vscode-html-language-server` processes accumulating across long-lived zellij sessions and pressuring memory. LSP servers are now spawned detached into their own process group and teardown signals the group (`process.kill(-pid, ...)`) before falling back to the direct child, bringing POSIX cleanup in line with the existing Windows `taskkill /T` behavior. Windows teardown is unchanged (`taskkill /T` mid-session, handle-only kill for `processExiting`). Guarded by the `pid <= 0` check so a group signal can never degrade into `process.kill(-0)` against pi-lens's own process group.
27
+
28
+ ## [3.8.62] - 2026-06-28
29
+
30
+ ### Added
31
+
32
+ - **Compact, blue-branded tool-result rendering (refs #345)** — the navigable/structural/diagnostic tools (`module_report`, `read_symbol`, `read_enclosing`, `ast_grep_search`, `ast_grep_replace`, `ast_grep_dump`/`ast_dump`, `ast_grep_outline`, `lsp_navigation`, `lsp_diagnostics`, `lens_diagnostics`) no longer flood the terminal with their full body. Each now defines a `renderResult` that shows a one-line summary by default — in pi-lens **blue characters** (bold blue text on the default tool-shell background) built from the tool's structured `details` (semantic counts/ranges, not blind truncation) — while the **model still receives the untouched full `content`**; the full output is one keystroke away via expand. Errors stay theme-red. The pi host only dumps a tool's `content` verbatim when it defines no `renderResult`, so supplying one decouples the model payload from the terminal view. Coexists with global renderer extensions (pi-tool-display / pi-claude-style-tools), which default to respecting a tool's own renderer. Shared helper `tools/render-compact.ts`, with unit tests; `pi-tui` `Text` routed through the `clients/deps/*` accessor (dep-centralization seam #285/#335).
33
+ - **Cross-file dead-code detection for non-JS/TS languages — Phase 1: Python via vulture (#127)** — Knip gives JS/TS projects project-wide unused exports/files/deps at session-start, but per-file dispatch linters can't catch "this exported symbol is unused anywhere in the project" for other languages. New `DeadCodeClient` interface (`clients/dead-code-client.ts`) parallels Knip's lifecycle (detect → ensureAvailable → analyze, cached at session-start, surfaced as a turn-end advisory), with a Python implementation backed by [`vulture`](https://github.com/jendrikseipp/vulture). Detection gates on a Python marker (`pyproject.toml`/`setup.py`/`requirements.txt`/…) with the same home-dir + VCS-boundary containment as Knip, so a scan launched from a bare cwd can't recurse `$HOME`. **Presence-gated, never auto-installed** — vulture is a pure-Python package with no standalone binary, so auto-installing would mean mutating the user's active Python environment (wrong for uv/poetry/conda/pipx); pi-lens uses it only when already present, probing both the `vulture` script and `python -m vulture` (mirrors `govulncheck`'s no-install gating). Its text output (`path:line: unused <kind> '<name>' (NN% confidence)`) is parsed into the uniform `DeadCodeResult` buckets. The turn-end advisory reads the cached session-start scan (project-wide scans are slow — no per-turn re-scan) and merges across languages for polyglot repos; advisory-only, never a blocker. Telemetry: one NDJSON event per scan to `~/.pi-lens/dead-code.log`. Future phases add Go/Rust/etc. by implementing the interface. Guards: parser unit tests against captured real vulture output + a guarded real-binary integration test.
34
+
35
+ ### Changed
36
+
37
+ - **Round-2 agent-tool ergonomics (#345): validation, summary tiers, and high-volume caps** — `ast_grep_search` gains `validateOnly` (compile a pattern/rule against a throwaway snippet to distinguish a bad pattern from a real no-match), `maxMatches` (per-call cap, default 50 / max 200; also the pagination step for `skip`), and `groupByFile` (compact one-line-per-file `L<line>:<col>` distribution instead of full match bodies — for high-volume searches; per-match read slices stay in `details.matchLocations`). The pattern/rule validator uses a per-language temp snippet so the throwaway file parses under the requested lang, rejects NUL/oversized inputs before spawning, and treats only line-anchored `error:` stderr as failure (not warnings like "contains ERROR node"). `module_report` gains a `summary` view tier (top-level read handles + `recommendedReads`; heavy callback/usedBy/blast-radius payloads omitted) and section-level `provenance` (`syntax` / `cached-review-graph` / `heuristic` / `none`); the unimplemented `deep` view tier was dropped. Unit tests cover each. (Block-unit selection from the same plan was deliberately deferred — per-language tree-sitter block queries across ~15 grammars are high-risk/low-marginal-value over the existing `read_enclosing onOversize=slice`.)
38
+ - **Agent-tool ergonomics for ast-grep search/debug flows** — `ast_grep_search` results now include `details.matchLocations[]` with ready `readSlice` handles so agents can jump from a structural hit to bounded context without manually computing offsets. Zero-match results now point at `ast_grep_dump` and include a bounded `suggestedDump` hint instead of leaving agents to guess node kinds. Added `ast_grep_dump` as the preferred AST dump tool name while keeping `ast_dump` as a compatibility alias; the ast-grep skill now includes lifecycle/callback search recipes. New `ast_grep_outline` exposes `ast-grep outline` as a syntax-only structure tool (symbols/imports/exports/members for files or directories, with `items`/`view`/`type`/`match`/`pubMembers`/`globs` and ready `read` handles) — fast, local, no index/LSP; `module_report` stays the pi-lens-aware default.
39
+
40
+ - **`module_report` now surfaces callback/closure handles, with per-language semantics** — reports include a `callbacks[]` section for high-signal inline executable nodes that normal symbol outlines miss: event handlers (`pi.on`/`*.on`), timers, promise callbacks, object/dict function properties, and assigned closures/lambdas/function literals. Each entry has stable synthetic `name`, flags such as `captures ctx.ui` / `detached timer`, and ready `read` args. `read_symbol` now accepts those handles, returns the exact body, and records read-guard coverage just like a named symbol. New `read_enclosing` bridges search/diagnostic line hits to the smallest enclosing symbol/callback body, also with read-guard coverage. `module_report.focus` can optionally rank existing symbols/callbacks in `recommendedReads` without expanding scope, building the graph, or calling LSP. The inline-executable *node kinds* are language-uniform over the tree-sitter WASMs, but the callback *semantics* are per-language via a `CALLBACK_RULES` table keyed like `SYMBOL_QUERIES`: JS/TS-tuned rules are the default, plus language slices for Go (goroutine/`defer` closures), Python (scheduler/future lambdas — `call_later`/`call_soon`/`Timer`/`add_done_callback`), Rust (`spawn` and `move` closures), Swift (strong-vs-`[weak self]` capture — the retain-cycle signal), C++ (`[&]` by-reference capture + `std::thread`/`std::async` launches), Kotlin (coroutine builders — `launch`/`async`/`withContext`/…), Java (`new Thread`, executor `submit`/`execute`/`schedule`, UI/event listeners), and C# (`Task.Run`/`StartNew` + `event += handler` subscriptions) that surface lifecycle callbacks the generic rules previously dropped. The report's `callbackSupport: "tuned" | "generic"` flag tells callers whether language-specific rules applied, so the list isn't over-trusted for untuned languages. (Named-symbol navigation — `module_report` outline, `read_symbol`, `read_enclosing` — already spans all ~19 tree-sitter `SYMBOL_QUERIES` languages.) Each symbol/member entry now also carries `decorators[]` — the declaration's decorators/attributes/annotations in source order (`@app.get("/x")`, `#[tokio::main]`, `@Override`), surfacing a symbol's role (route/test/fixture/entrypoint) without reading its body. Extracted structurally from the declaration node (preceding-sibling / own-child / `modifiers`-nested shapes), spanning Python/Rust/TS/Java/Kotlin/C# including nested method members. Async/suspend functions and methods now carry an `async` flag (structurally detected — `async` keyword node or `async`/`suspend` in a modifiers container), marking concurrency boundaries.
41
+
42
+ - **Fuller, more correct utilization of knip + madge (tool-utilization audit)** — an audit of our whole-project analyzers (validated by running them on this repo) found gaps and silent-failure modes. (1) **knip** now requests `enumMembers` in `--include` — finer-grained dead code (unused enum members) than file-level exports, advisory-only. *(The audit also caught a bug: knip 6.x has **no `classMembers` issue type** — requesting it makes knip exit 2 with zero output, silently disabling the scan. Verified against knip 6.20.)* (2) **madge** now passes `--ts-config <tsconfig.json>` when one exists, so TypeScript `paths` aliases (`@/foo`) resolve — previously alias-routed imports were silently unresolved and **cycles through an alias were missed**. (3) **madge** `--extensions` gained `mjs,cjs`. (4) **madge** now runs with `--warning` and we parse its stderr for **skipped (unresolvable) files** — previously `--json` mode hid these, so a skipped *local* file could silently drop an internal edge and hide a cycle; local skips are now logged (external package skips are expected and ignored). jscpd was already broad (≈20 languages, unrestricted scan since #126) — no gap there. Guards: knip member-type parse test, `buildMadgeArgs` + `parseMadgeSkips` unit tests.
43
+ - **Turn-end injects only this-turn, high-confidence findings — not the full project-wide warning set** — measured on this repo, the whole-project analyzers emit hundreds of findings (knip 390, jscpd 136 clones in `clients/` alone), most pre-existing and noisy. Injecting that wall every turn would drown the genuine blockers and burn context. So the turn-end knip advisory now surfaces only the **delta attributable to the agent's edits** (symbols in files it just touched that became unused) — low-volume and actionable — instead of the whole project. The full picture remains available on demand via `lens_diagnostics`, and the delta still feeds the session-slop record. (madge already operated in this blockers-only mode.)
44
+ - **Read-guard: every blocking verdict ends with a concrete next-action line (#328)** — an LLM recovers best when each blocking/retryable verdict tells it exactly what to do next. An audit found the read-guard verdicts were *almost* uniform — `read-guard.ts` (zero-read / file-modified / out-of-range / range-stale) and the oldText-not-found path all already end with a recovery instruction — except the `unsupported_hashline_edit_target` block (malformed/unsupported hashline anchors), which listed the errors with no next step. It now ends with a single concrete next-action ("Re-read `<file>` to get current #line anchors, then retry with `set_line` / `replace_lines` — or use a native ranged edit"). Message-only; a guard test asserts the next-action line is present.
45
+
46
+ ### Fixed
47
+
48
+ - **LSP idle reset no longer touches stale pi contexts after session replacement (#338)** — the detached 240s idle timer now captures any footer repaint callback while the `turn_end` event context is still active, skips resets from superseded session generations, and swallows timer-only cleanup errors so `ctx.newSession()` / `ctx.fork()` / `ctx.switchSession()` / `ctx.reload()` cannot crash later when the old `ctx.ui` getter becomes stale.
49
+
13
50
  ## [3.8.61] - 2026-06-25
14
51
 
15
52
  ### Added
package/README.md CHANGED
@@ -4,10 +4,6 @@
4
4
 
5
5
  # pi-lens
6
6
 
7
- <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
8
- [![All Contributors](https://img.shields.io/github/all-contributors/apmantza/pi-lens?color=ee8449&style=flat-square)](#contributors-)
9
- <!-- ALL-CONTRIBUTORS-BADGE:END -->
10
-
11
7
  pi-lens gives AI coding agents fast, language-aware feedback while they write/edit.
12
8
 
13
9
  ## What It Does
@@ -146,6 +142,11 @@ Thanks goes to these wonderful people:
146
142
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/JustlyAI"><img src="https://avatars.githubusercontent.com/u/12634140?v=4" width="100px;" alt=""/><br /><sub><b>Laurent Wiesel</b></sub></a><br /><a href="#bug-JustlyAI" title="Bug reports">🐛</a></td>
147
143
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/stark-bit"><img src="https://avatars.githubusercontent.com/u/44064758?v=4" width="100px;" alt=""/><br /><sub><b>Rei Starks</b></sub></a><br /><a href="#bug-stark-bit" title="Bug reports">🐛</a></td>
148
144
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/robertoecf"><img src="https://avatars.githubusercontent.com/u/54923863?v=4" width="100px;" alt=""/><br /><sub><b>Roberto Freitas</b></sub></a><br /><a href="#bug-robertoecf" title="Bug reports">🐛</a></td>
145
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/tmttodd"><img src="https://avatars.githubusercontent.com/u/160077416?v=4" width="100px;" alt=""/><br /><sub><b>tmttodd</b></sub></a><br /><a href="#bug-tmttodd" title="Bug reports">🐛</a></td>
146
+ </tr>
147
+ <tr>
148
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/smaharnav"><img src="https://avatars.githubusercontent.com/u/263328627?v=4" width="100px;" alt=""/><br /><sub><b>smaharnav</b></sub></a><br /><a href="#bug-smaharnav" title="Bug reports">🐛</a></td>
149
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/MzaGuille"><img src="https://avatars.githubusercontent.com/u/151482698?v=4" width="100px;" alt=""/><br /><sub><b>Mza.Guille</b></sub></a><br /><a href="#bug-MzaGuille" title="Bug reports">🐛</a></td>
149
150
  </tr>
150
151
  </tbody>
151
152
  </table>
package/banner.png CHANGED
Binary file
package/banner.svg CHANGED
@@ -24,65 +24,73 @@
24
24
  <rect x="0" y="0" width="1100" height="3" fill="url(#accent)" rx="1.5" clip-path="url(#bounds)"/>
25
25
 
26
26
  <!-- Decorative aperture rings (right side) -->
27
- <g opacity="0.07" clip-path="url(#bounds)">
28
- <circle cx="920" cy="155" r="180" fill="none" stroke="#2f81f7" stroke-width="1.5"/>
29
- <circle cx="920" cy="155" r="145" fill="none" stroke="#2f81f7" stroke-width="1"/>
30
- <circle cx="920" cy="155" r="110" fill="none" stroke="#2f81f7" stroke-width="1"/>
31
- <circle cx="920" cy="155" r="75" fill="none" stroke="#2f81f7" stroke-width="1"/>
32
- <line x1="740" y1="155" x2="1100" y2="155" stroke="#2f81f7" stroke-width="0.8"/>
33
- <line x1="920" y1="-40" x2="920" y2="350" stroke="#2f81f7" stroke-width="0.8"/>
34
- <line x1="793" y1="28" x2="1047" y2="282" stroke="#2f81f7" stroke-width="0.6"/>
35
- <line x1="1047" y1="28" x2="793" y2="282" stroke="#2f81f7" stroke-width="0.6"/>
27
+ <g opacity="0.06" clip-path="url(#bounds)">
28
+ <circle cx="980" cy="100" r="170" fill="none" stroke="#2f81f7" stroke-width="1.5"/>
29
+ <circle cx="980" cy="100" r="132" fill="none" stroke="#2f81f7" stroke-width="1"/>
30
+ <circle cx="980" cy="100" r="94" fill="none" stroke="#2f81f7" stroke-width="1"/>
31
+ <circle cx="980" cy="100" r="56" fill="none" stroke="#2f81f7" stroke-width="1"/>
32
+ <line x1="810" y1="100" x2="1100" y2="100" stroke="#2f81f7" stroke-width="0.8"/>
33
+ <line x1="980" y1="-70" x2="980" y2="270" stroke="#2f81f7" stroke-width="0.8"/>
36
34
  </g>
37
35
 
38
36
  <!-- Lens icon -->
39
- <!-- Outer ring glow -->
40
- <circle cx="142" cy="130" r="70" fill="#2f81f708"/>
41
- <!-- Lens circle -->
42
- <circle cx="142" cy="130" r="63" fill="#2f81f710" stroke="url(#accent)" stroke-width="4.5"/>
43
- <!-- Inner ring -->
44
- <circle cx="142" cy="130" r="48" fill="none" stroke="#2f81f740" stroke-width="1.5"/>
45
- <!-- Handle -->
46
- <line x1="191" y1="179" x2="222" y2="214" stroke="url(#accent)" stroke-width="8" stroke-linecap="round"/>
47
- <!-- Code brackets inside lens -->
48
- <text x="110" y="145" font-family="'Courier New', Courier, monospace" font-size="34" font-weight="700" fill="#58a6ff">&lt;/&gt;</text>
37
+ <circle cx="118" cy="88" r="56" fill="#2f81f708"/>
38
+ <circle cx="118" cy="88" r="50" fill="#2f81f710" stroke="url(#accent)" stroke-width="4"/>
39
+ <circle cx="118" cy="88" r="37" fill="none" stroke="#2f81f740" stroke-width="1.4"/>
40
+ <line x1="156" y1="126" x2="182" y2="155" stroke="url(#accent)" stroke-width="7" stroke-linecap="round"/>
41
+ <text x="93" y="98" font-family="'Courier New', Courier, monospace" font-size="27" font-weight="700" fill="#58a6ff">&lt;/&gt;</text>
49
42
 
50
43
  <!-- pi-lens wordmark -->
51
- <text x="258" y="122" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="74" font-weight="700" fill="#e6edf3" letter-spacing="-2">pi-lens</text>
44
+ <text x="208" y="82" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="58" font-weight="700" fill="#e6edf3" letter-spacing="-2">pi-lens</text>
52
45
 
53
- <!-- Tagline -->
54
- <text x="261" y="164" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="21" font-weight="400" fill="#8b949e" letter-spacing="0.3">Real-time code intelligence for AI agents</text>
55
-
56
- <!-- Feature pills — row 1 -->
57
- <g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="12" font-weight="500">
58
- <rect x="261" y="196" width="56" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
59
- <text x="289" y="213" fill="#58a6ff" text-anchor="middle">LSP</text>
46
+ <!-- Languages badge (header) -->
47
+ <rect x="446" y="44" width="132" height="28" rx="14" fill="#2f81f718" stroke="#2f81f755" stroke-width="1"/>
48
+ <text x="512" y="63" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="13" font-weight="600" fill="#58a6ff" text-anchor="middle">36+ languages</text>
60
49
 
61
- <rect x="327" y="196" width="68" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
62
- <text x="361" y="213" fill="#58a6ff" text-anchor="middle">Linters</text>
63
-
64
- <rect x="405" y="196" width="90" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
65
- <text x="450" y="213" fill="#58a6ff" text-anchor="middle">Formatters</text>
50
+ <!-- Tagline -->
51
+ <text x="210" y="116" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="19" font-weight="400" fill="#8b949e" letter-spacing="0.3">Real-time code intelligence for AI agents</text>
66
52
 
67
- <rect x="505" y="196" width="84" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
68
- <text x="547" y="213" fill="#58a6ff" text-anchor="middle">Auto-Fix</text>
53
+ <!-- Divider under header -->
54
+ <rect x="40" y="146" width="1020" height="1" fill="#2f81f730"/>
69
55
 
70
- <rect x="599" y="196" width="100" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
71
- <text x="649" y="213" fill="#58a6ff" text-anchor="middle">Read-Guard</text>
56
+ <!-- Column dividers -->
57
+ <g fill="#2f81f722">
58
+ <rect x="294" y="170" width="1" height="108"/>
59
+ <rect x="550" y="170" width="1" height="108"/>
60
+ <rect x="806" y="170" width="1" height="108"/>
72
61
  </g>
73
62
 
74
- <!-- Feature pills row 2 -->
75
- <g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif" font-size="12" font-weight="500">
76
- <rect x="261" y="228" width="152" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
77
- <text x="337" y="245" fill="#58a6ff" text-anchor="middle">Structural Analysis</text>
63
+ <g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif">
64
+ <!-- ANALYZE -->
65
+ <rect x="56" y="176" width="7" height="7" rx="1.5" fill="url(#accent)"/>
66
+ <text x="72" y="183" font-size="13.5" font-weight="700" fill="#58a6ff" letter-spacing="2">ANALYZE</text>
67
+ <text x="56" y="200" font-size="11" font-weight="400" fill="#6e7681" letter-spacing="0.2">inline feedback, every write/edit</text>
68
+ <text x="56" y="225" font-size="14.5" font-weight="400" fill="#c9d1d9">LSP diagnostics</text>
69
+ <text x="56" y="248" font-size="14.5" font-weight="400" fill="#c9d1d9">Linters &amp; type-checks</text>
70
+ <text x="56" y="271" font-size="14.5" font-weight="400" fill="#c9d1d9">ast-grep &#183; tree-sitter</text>
78
71
 
79
- <rect x="423" y="228" width="96" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
80
- <text x="471" y="245" fill="#58a6ff" text-anchor="middle">Cascade</text>
72
+ <!-- FIX & GUARD -->
73
+ <rect x="312" y="176" width="7" height="7" rx="1.5" fill="url(#accent)"/>
74
+ <text x="328" y="183" font-size="13.5" font-weight="700" fill="#58a6ff" letter-spacing="2">FIX &amp; GUARD</text>
75
+ <text x="312" y="200" font-size="11" font-weight="400" fill="#6e7681" letter-spacing="0.2">automatic</text>
76
+ <text x="312" y="225" font-size="14.5" font-weight="400" fill="#c9d1d9">Formatters &amp; auto-fix</text>
77
+ <text x="312" y="248" font-size="14.5" font-weight="400" fill="#c9d1d9">Read-guard</text>
78
+ <text x="312" y="271" font-size="14.5" font-weight="400" fill="#c9d1d9">Edit autopatch</text>
81
79
 
82
- <rect x="529" y="228" width="100" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
83
- <text x="579" y="245" fill="#58a6ff" text-anchor="middle">Tree-sitter</text>
80
+ <!-- UNDERSTAND -->
81
+ <rect x="568" y="176" width="7" height="7" rx="1.5" fill="url(#accent)"/>
82
+ <text x="584" y="183" font-size="13.5" font-weight="700" fill="#58a6ff" letter-spacing="2">UNDERSTAND</text>
83
+ <text x="568" y="200" font-size="11" font-weight="400" fill="#6e7681" letter-spacing="0.2">review-graph engine</text>
84
+ <text x="568" y="225" font-size="14.5" font-weight="400" fill="#c9d1d9">Review graph</text>
85
+ <text x="568" y="248" font-size="14.5" font-weight="400" fill="#c9d1d9">Impact cascade</text>
86
+ <text x="568" y="271" font-size="14.5" font-weight="400" fill="#c9d1d9">Centrality ranking</text>
84
87
 
85
- <rect x="639" y="228" width="96" height="24" rx="12" fill="#2f81f715" stroke="#2f81f740" stroke-width="1"/>
86
- <text x="687" y="245" fill="#58a6ff" text-anchor="middle">ast-grep</text>
88
+ <!-- AGENT TOOLS -->
89
+ <rect x="824" y="176" width="7" height="7" rx="1.5" fill="url(#accent)"/>
90
+ <text x="840" y="183" font-size="13.5" font-weight="700" fill="#58a6ff" letter-spacing="2">AGENT TOOLS</text>
91
+ <text x="824" y="200" font-size="11" font-weight="400" fill="#6e7681" letter-spacing="0.2">on demand</text>
92
+ <text x="824" y="225" font-size="14.5" font-weight="400" fill="#c9d1d9">Symbol search</text>
93
+ <text x="824" y="248" font-size="14.5" font-weight="400" fill="#c9d1d9">Module reports</text>
94
+ <text x="824" y="271" font-size="14.5" font-weight="400" fill="#c9d1d9">AST search &amp; replace</text>
87
95
  </g>
88
96
  </svg>
@@ -41,6 +41,44 @@ function snippetForRange(source, offsets, startLine0, startCol0, endLine0, endCo
41
41
  const text = source.slice(start, end).replace(/\s+/g, " ").trim();
42
42
  return text.length > 80 ? `${text.slice(0, 77)}...` : text;
43
43
  }
44
+ const MAX_VALIDATE_PATTERN_CHARS = 20_000;
45
+ const MAX_VALIDATE_RULE_CHARS = 200_000;
46
+ const VALIDATION_SNIPPETS = {
47
+ bash: { ext: "sh", source: "echo pi_lens_validate\n" },
48
+ c: { ext: "c", source: "int main(void) { return 0; }\n" },
49
+ cpp: { ext: "cpp", source: "int main() { return 0; }\n" },
50
+ csharp: { ext: "cs", source: "class C { static void Main() {} }\n" },
51
+ css: { ext: "css", source: ".pi-lens { color: black; }\n" },
52
+ go: { ext: "go", source: "package main\nfunc main() {}\n" },
53
+ html: { ext: "html", source: "<main>pi-lens</main>\n" },
54
+ java: { ext: "java", source: "class Main { void run() {} }\n" },
55
+ javascript: { ext: "js", source: "const piLensValidate = 1;\n" },
56
+ json: { ext: "json", source: "{\"piLensValidate\":true}\n" },
57
+ kotlin: { ext: "kt", source: "fun main() {}\n" },
58
+ lua: { ext: "lua", source: "local pi_lens_validate = 1\n" },
59
+ php: { ext: "php", source: "<?php $piLensValidate = 1;\n" },
60
+ python: { ext: "py", source: "pi_lens_validate = 1\n" },
61
+ ruby: { ext: "rb", source: "pi_lens_validate = 1\n" },
62
+ rust: { ext: "rs", source: "fn main() {}\n" },
63
+ tsx: { ext: "tsx", source: "export function App() { return <div />; }\n" },
64
+ typescript: { ext: "ts", source: "const piLensValidate = 1;\n" },
65
+ yaml: { ext: "yaml", source: "piLensValidate: true\n" },
66
+ };
67
+ function validationSnippetFor(language) {
68
+ const key = language.toLowerCase().replace(/^"|"$/g, "");
69
+ return VALIDATION_SNIPPETS[key] ?? { ext: key.replace(/[^a-z0-9_-]/gi, "") || "txt", source: "pi_lens_validate\n" };
70
+ }
71
+ function validateInputShape(value, maxChars, label) {
72
+ if (value.includes("\0"))
73
+ return `${label} contains NUL bytes`;
74
+ if (value.length > maxChars) {
75
+ return `${label} is too large (${value.length} chars, max ${maxChars})`;
76
+ }
77
+ return undefined;
78
+ }
79
+ function stderrHasError(stderr) {
80
+ return stderr.split(/\r?\n/).some((line) => /^\s*(error|Error):/.test(line));
81
+ }
44
82
  function formatDebugAst(tree, source) {
45
83
  const offsets = lineStartOffsets(source);
46
84
  return tree
@@ -94,16 +132,30 @@ export class AstGrepClient {
94
132
  // Stale-preview check: dry-run first
95
133
  const preCheck = await this.runner.tempScanAsync(scanPath, "agent-rule", ruleYaml);
96
134
  if (preCheck.length === 0) {
97
- return { matches: [], totalMatches: 0, applied: false, stalePreview: true };
135
+ return {
136
+ matches: [],
137
+ totalMatches: 0,
138
+ applied: false,
139
+ stalePreview: true,
140
+ };
98
141
  }
99
142
  }
100
143
  const result = await this.runner.tempScanWithFixAsync(scanPath, "agent-rule", ruleYaml, apply);
101
144
  if (result.error) {
102
- return { matches: allMatches, totalMatches: allMatches.length, applied: false, error: result.error };
145
+ return {
146
+ matches: allMatches,
147
+ totalMatches: allMatches.length,
148
+ applied: false,
149
+ error: result.error,
150
+ };
103
151
  }
104
152
  allMatches.push(...result.matches);
105
153
  }
106
- return { matches: allMatches, totalMatches: allMatches.length, applied: apply };
154
+ return {
155
+ matches: allMatches,
156
+ totalMatches: allMatches.length,
157
+ applied: apply,
158
+ };
107
159
  }
108
160
  /**
109
161
  * Search using a raw YAML rule (Phase 4 of #125).
@@ -160,6 +212,111 @@ export class AstGrepClient {
160
212
  fs.rmSync(tmpDir, { recursive: true, force: true });
161
213
  }
162
214
  }
215
+ async validatePattern(pattern, lang, options) {
216
+ const shapeError = validateInputShape(pattern, MAX_VALIDATE_PATTERN_CHARS, "pattern");
217
+ if (shapeError)
218
+ return { valid: false, error: shapeError };
219
+ const snippet = validationSnippetFor(lang);
220
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lens-sg-validate-"));
221
+ const tmpFile = path.join(tmpDir, `snippet.${snippet.ext}`);
222
+ try {
223
+ fs.writeFileSync(tmpFile, snippet.source, "utf-8");
224
+ const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
225
+ if (options?.selector)
226
+ args.push("--selector", options.selector);
227
+ if (options?.strictness)
228
+ args.push("--strictness", options.strictness);
229
+ args.push(tmpFile);
230
+ const result = await this.runner.execRaw(args);
231
+ const stderr = result.stderr.trim();
232
+ const stdout = result.stdout.trim();
233
+ if (result.error)
234
+ return { valid: false, error: result.error };
235
+ if (stderrHasError(stderr))
236
+ return { valid: false, error: stderr };
237
+ const warning = stderr || stdout || undefined;
238
+ return {
239
+ valid: true,
240
+ ...(warning ? { warning } : {}),
241
+ };
242
+ }
243
+ finally {
244
+ try {
245
+ fs.rmSync(tmpDir, { recursive: true, force: true });
246
+ }
247
+ catch {
248
+ // Best-effort cleanup; never mask the validation result.
249
+ }
250
+ }
251
+ }
252
+ async validateRule(ruleYaml) {
253
+ const shapeError = validateInputShape(ruleYaml, MAX_VALIDATE_RULE_CHARS, "rule");
254
+ if (shapeError)
255
+ return { valid: false, error: shapeError };
256
+ const language = /^\s*language:\s*([^\s#]+)/im.exec(ruleYaml)?.[1] ?? "typescript";
257
+ const snippet = validationSnippetFor(language);
258
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lens-sg-rule-"));
259
+ try {
260
+ fs.writeFileSync(path.join(tmpDir, `snippet.${snippet.ext}`), snippet.source, "utf-8");
261
+ await this.runner.tempScanAsync(tmpDir, "agent-rule", ruleYaml, 10000);
262
+ return { valid: true };
263
+ }
264
+ catch (err) {
265
+ return { valid: false, error: String(err) };
266
+ }
267
+ finally {
268
+ try {
269
+ fs.rmSync(tmpDir, { recursive: true, force: true });
270
+ }
271
+ catch {
272
+ // Best-effort cleanup; never mask the validation result.
273
+ }
274
+ }
275
+ }
276
+ /**
277
+ * Syntax-only code outline via `ast-grep outline` (#311) — symbols, imports,
278
+ * exports, and members for file or directory input. Raw, fast, no index/LSP;
279
+ * complements module_report (which adds the cached graph's who-uses-this,
280
+ * complexity, and blast radius). Returns parsed JSON; args go through
281
+ * `execRaw` (execFile-style, no shell), so no interpolation risk.
282
+ */
283
+ async outline(paths, options = {}) {
284
+ if (paths.length === 0)
285
+ return { error: "no paths provided" };
286
+ const args = ["outline", "--json=compact", "--color", "never"];
287
+ if (options.lang)
288
+ args.push("--lang", options.lang);
289
+ if (options.items)
290
+ args.push("--items", options.items);
291
+ if (options.view)
292
+ args.push("--view", options.view);
293
+ if (options.types?.length)
294
+ args.push("--type", options.types.join(","));
295
+ if (options.match)
296
+ args.push("--match", options.match);
297
+ if (options.pubMembers)
298
+ args.push("--pub-members");
299
+ for (const glob of options.globs ?? [])
300
+ args.push("--globs", glob);
301
+ args.push(...paths);
302
+ const result = await this.runner.execRaw(args);
303
+ const raw = (result.stdout ?? "").trim();
304
+ if (!raw) {
305
+ return {
306
+ error: result.error ||
307
+ result.stderr?.trim() ||
308
+ "ast-grep outline returned no output",
309
+ };
310
+ }
311
+ try {
312
+ return { output: JSON.parse(raw) };
313
+ }
314
+ catch (err) {
315
+ return {
316
+ error: `failed to parse ast-grep outline JSON: ${err instanceof Error ? err.message : String(err)}`,
317
+ };
318
+ }
319
+ }
163
320
  /**
164
321
  * Search for AST patterns in files
165
322
  */
@@ -102,7 +102,7 @@ export function astGrepRemediationHint(kind) {
102
102
  case "json_parse_failed":
103
103
  return "Hint: ast-grep produced output that could not be parsed — retry with a simpler pattern; the installed CLI version may be incompatible.";
104
104
  default:
105
- return "Hint: verify the pattern is a single valid AST node for this `lang` (use ast_dump to discover node kinds), or fall back to grep for plain-text search.";
105
+ return "Hint: verify the pattern is a single valid AST node for this `lang` (use ast_grep_dump to discover node kinds), or fall back to grep for plain-text search.";
106
106
  }
107
107
  }
108
108
  function rotateIfNeeded() {
@@ -13,7 +13,10 @@
13
13
  * Multiple constraints are combined directly on the rule object — ast-grep
14
14
  * evaluates all of them as an implicit AND.
15
15
  */
16
- import { dump } from "js-yaml";
16
+ import { dump } from "./deps/js-yaml.js";
17
+ const MAX_SYNTHESIZED_PATTERN_CHARS = 4_000;
18
+ const MAX_NODE_KIND_CHARS = 80;
19
+ const NODE_KIND_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
20
  /**
18
21
  * Returns true when at least one structural-intent field is present.
19
22
  */
@@ -38,24 +41,24 @@ export function synthesizeReplaceRule(intent) {
38
41
  * @throws if pattern is empty
39
42
  */
40
43
  export function synthesizeRule(intent) {
41
- if (!intent.pattern.trim()) {
42
- throw new Error("pattern is required for YAML synthesis");
43
- }
44
+ assertSafePattern(intent.pattern, "pattern");
44
45
  // Canonical language name for the YAML header (ast-grep is case-sensitive here).
45
46
  const language = canonicalLanguage(intent.lang);
46
47
  const rule = {
47
48
  pattern: intent.pattern,
48
49
  };
49
50
  if (intent.insideKind) {
50
- rule.inside = { kind: intent.insideKind, stopBy: "end" };
51
+ rule.inside = { kind: assertSafeNodeKind(intent.insideKind, "insideKind"), stopBy: "end" };
51
52
  }
52
53
  if (intent.hasKind) {
53
- rule.has = { kind: intent.hasKind };
54
+ rule.has = { kind: assertSafeNodeKind(intent.hasKind, "hasKind") };
54
55
  }
55
56
  if (intent.follows) {
57
+ assertSafePattern(intent.follows, "follows");
56
58
  rule.follows = { pattern: intent.follows };
57
59
  }
58
60
  if (intent.precedes) {
61
+ assertSafePattern(intent.precedes, "precedes");
59
62
  rule.precedes = { pattern: intent.precedes };
60
63
  }
61
64
  const doc = {
@@ -65,6 +68,27 @@ export function synthesizeRule(intent) {
65
68
  };
66
69
  return dump(doc, { lineWidth: -1 });
67
70
  }
71
+ function assertSafePattern(value, field) {
72
+ if (!value.trim()) {
73
+ throw new Error(`${field} is required for YAML synthesis`);
74
+ }
75
+ if (value.length > MAX_SYNTHESIZED_PATTERN_CHARS) {
76
+ throw new Error(`${field} is too long for YAML synthesis`);
77
+ }
78
+ if (value.includes("\0")) {
79
+ throw new Error(`${field} contains a NUL byte`);
80
+ }
81
+ }
82
+ function assertSafeNodeKind(value, field) {
83
+ const kind = value.trim();
84
+ if (kind.length === 0) {
85
+ throw new Error(`${field} is required for YAML synthesis`);
86
+ }
87
+ if (kind.length > MAX_NODE_KIND_CHARS || !NODE_KIND_RE.test(kind)) {
88
+ throw new Error(`${field} must be a single AST node kind like function_declaration`);
89
+ }
90
+ return kind;
91
+ }
68
92
  /**
69
93
  * Map a user-supplied lang value (e.g. "typescript", "TypeScript") to the
70
94
  * capitalisation ast-grep expects in the YAML `language:` field.