pi-ast-sgrep 1.3.2 → 1.4.0

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/README.md CHANGED
@@ -1,48 +1,212 @@
1
1
  # pi-ast-sgrep
2
2
 
3
- [![ast-sgrep for Pi](assets/preview.png)](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md)
3
+ Native Code Mode, structural, graph, and semantic code search for [Pi](https://github.com/earendil-works/pi).
4
4
 
5
- Native intent, structural, definition, caller, chain, and semantic code search for Pi.
5
+ [![pi-ast-sgrep: native code search inside Pi](https://cdn.jsdelivr.net/npm/pi-ast-sgrep/assets/preview.png)](https://pi.dev/packages/pi-ast-sgrep?name=pi-ast-sgrep)
6
+
7
+ `pi-ast-sgrep` gives Pi a warm, project-aware search engine for understanding code. It finds behavior by intent, resolves definitions and callers, traces relationships, matches syntax-aware patterns, and searches local semantic embeddings. The primary `asgrep_codemode` tool lets Pi compose several searches in one JavaScript program instead of spending one model round trip per lookup.
8
+
9
+ ## Install
6
10
 
7
11
  ```bash
8
12
  pi install npm:pi-ast-sgrep
9
13
  ```
10
14
 
11
- Requires Node.js `>=22.19.0`, Pi `>=0.80.6 <1`, and a packaged host: macOS arm64/x64, glibc Linux arm64/x64, or Windows x64. The extension, `ast-sgrep` launcher, and selected native package manifests are exact-version matched at `1.3.2`; the embedded CLI compatibility identity is `1.3.2`. Alpine/musl, Windows arm64, and other hosts fail with an actionable unsupported-platform error; there is no source build or runtime download fallback.
15
+ Restart Pi if the current session does not load newly installed package resources. For a project-local installation, add `-l`:
16
+
17
+ ```bash
18
+ pi install -l npm:pi-ast-sgrep
19
+ ```
20
+
21
+ No Rust toolchain or separate MCP server is required. The npm package selects the native binary and in-process addon for the current supported platform.
22
+
23
+ ## What this package adds
24
+
25
+ | Resource | Purpose |
26
+ |---|---|
27
+ | `asgrep_codemode` | Primary tool. Run a bounded JavaScript program that composes typed `asgrep.*` calls. |
28
+ | `asgrep_search` | Run one natural, structural, symbol, graph, semantic, word, literal, or regex lookup. |
29
+ | `asgrep_index` | Create, refresh, or explicitly rebuild the current project index. |
30
+ | `asgrep_status` | Inspect the selected root, index, backend, counts, and capabilities. |
31
+ | `ast-sgrep` skill | Teach Pi when and how to use Code Mode, direct search, or exact-text search. |
12
32
 
13
- ## First use
33
+ The package also registers `/asgrep-doctor`, `/asgrep-status`, `/asgrep-index`, and `/asgrep-reindex`.
14
34
 
15
- The package registers:
35
+ ## Start with Code Mode
16
36
 
17
- - `asgrep_search`, `asgrep_index`, and `asgrep_status` tools;
18
- - `/asgrep-doctor`, `/asgrep-status`, `/asgrep-index`, and `/asgrep-reindex` commands;
19
- - the `ast-sgrep` skill.
37
+ Ask Pi:
20
38
 
21
- Open Pi in a project and search. The first search lazily creates `.asgrep/`. Examples for `asgrep_search`:
39
+ > Use ast-sgrep Code Mode to find where access tokens are refreshed, trace the top result's callers, and return only the relevant files, symbols, and lines.
40
+
41
+ Pi can make one `asgrep_codemode` call like this:
22
42
 
23
43
  ```json
24
- {"query":"auth_refresh","mode":"defs"}
25
- {"query":"auth_refresh","mode":"callers"}
26
- {"query":"where are credentials renewed?","mode":"semantic"}
44
+ {
45
+ "code": "async () => {\n const seed = await asgrep.search({ query: 'where are access tokens refreshed?', limit: 5 });\n const symbol = seed.hits?.[0]?.symbol;\n if (!symbol) return { seed };\n const [defs, callers] = await Promise.all([\n asgrep.defs({ symbol, limit: 5 }),\n asgrep.callers({ symbol, limit: 10 }),\n ]);\n return { symbol, defs: defs.hits, callers: callers.hits };\n}"
46
+ }
47
+ ```
48
+
49
+ This workflow narrows the first result, runs independent follow-up searches together, and returns a small shaped value to the model.
50
+
51
+ ### Code Mode API
52
+
53
+ The sandbox exposes these asynchronous methods:
54
+
55
+ | Method | Use |
56
+ |---|---|
57
+ | `asgrep.search({ query, limit?, excerptLines? })` | Search by intent, symbol, or a prefixed structural query. |
58
+ | `asgrep.semantic({ query, limit?, excerptLines? })` | Search local semantic embeddings directly. |
59
+ | `asgrep.defs({ symbol, limit? })` | Find definitions for one symbol. |
60
+ | `asgrep.callers({ symbol, limit? })` | Find call sites for one symbol. |
61
+ | `asgrep.imports({ module, limit? })` | Find imports of one module. |
62
+ | `asgrep.chain({ query, limit? })` | Trace related symbols and graph edges. |
63
+ | `asgrep.indexStatus()` | Read index and backend state. |
64
+ | `asgrep.indexRepo({ force? })` | Create, refresh, or rebuild the index. |
65
+ | `asgrep.catalogSearch({ query })` | Discover less common ast-sgrep operations. |
66
+ | `asgrep.catalogDescribe({ name })` | Read the schema for a discovered operation. |
67
+
68
+ Use `Promise.all` for independent calls. Filter, map, sort, and slice intermediate values in JavaScript. Return only the evidence needed for the next reasoning step.
69
+
70
+ Code Mode includes `Promise`, `JSON`, arrays, objects, `Map`, `Set`, and `Math`. It does not expose `require`, `process`, `fetch`, or filesystem APIs. This is a capability boundary for generated code, not an OS sandbox for the installed Pi package.
71
+
72
+ ## Direct one-shot search
73
+
74
+ Use `asgrep_search` when one lookup is enough:
75
+
76
+ ```json
77
+ {"query":"auth_refresh","mode":"defs","limit":8}
78
+ {"query":"auth_refresh","mode":"callers","limit":8}
79
+ {"query":"where are credentials renewed?","mode":"semantic","limit":8}
80
+ {"query":"$CLIENT.post($URL)","mode":"pattern","limit":8}
81
+ ```
82
+
83
+ Available modes:
84
+
85
+ | Mode | Best for |
86
+ |---|---|
87
+ | `natural` | Intent or mixed code-language queries when exact spelling is unknown. |
88
+ | `pattern` | Syntax-aware ast-sgrep patterns with metavariables. |
89
+ | `defs`, `callers`, `imports` | Symbol and module navigation. |
90
+ | `chain` | Multi-hop relationship tracing. |
91
+ | `semantic` | Meaning-based local vector search. |
92
+ | `word`, `literal`, `regex` | Explicit text-oriented matching. |
93
+
94
+ `limit` accepts 1–100 and defaults to 8. Excerpts are disabled by default; set `excerptLines` only after narrowing the result set.
95
+
96
+ ## Why Code Mode is fast
97
+
98
+ Official platform packages include `ast-sgrep-codemode.node`. The extension loads an in-process native `CodeModeSession` and keeps one warm Searcher per project root for Code Mode, direct tools, and freshness checks. Normal searches do not spawn a CLI process.
99
+
100
+ Independent calls created in the same JavaScript turn are coalesced into a batch. `Promise.all` can therefore fan out several lookups while the model makes one tool call. If the native addon is unavailable, the bundled CLI service is a degraded fallback; `/asgrep-doctor` reports the active backend.
101
+
102
+ Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search core. Pi uses Code Mode directly and does not use an MCP adapter.
103
+
104
+ ## Indexing and freshness
105
+
106
+ Start Pi in the repository you want to search. The first search validates the index and lazily creates `<project-root>/.asgrep/` when needed. Run `/asgrep-index` if you want to build it before searching.
107
+
108
+ After a successful Pi `write` or `edit`, the extension marks the affected path dirty and refreshes it before the next search. Concurrent searches for the same root share one in-flight refresh. Use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a full rebuild.
109
+
110
+ The package never edits `.gitignore`. Add this entry yourself if index data must stay untracked:
111
+
112
+ ```gitignore
113
+ .asgrep/
27
114
  ```
28
115
 
29
- Run `/asgrep-doctor` to diagnose the runtime, binary, protocol, index, or configuration; run `/asgrep-status` to inspect the current project. The extension refreshes successful Pi write/edit changes before the next search and coalesces concurrent refreshes.
116
+ ## Commands
117
+
118
+ | Command | Action |
119
+ |---|---|
120
+ | `/asgrep-doctor` | Check package versions, native runtime, protocol, index, and project settings. |
121
+ | `/asgrep-status` | Show the current root and index state. |
122
+ | `/asgrep-index` | Create or incrementally refresh the index. |
123
+ | `/asgrep-reindex` | Build and atomically replace the index from scratch. |
124
+
125
+ These commands take no arguments.
126
+
127
+ ## Requirements
128
+
129
+ - Node.js `>=22.19.0`.
130
+ - Pi currently tested with `@earendil-works/pi-coding-agent >=0.80.6 <1`.
131
+ - macOS arm64 or x64, glibc Linux arm64 or x64, or Windows x64.
132
+
133
+ Alpine/musl Linux, Windows arm64, and other hosts are not packaged. The package does not compile Rust, search `PATH`, or download executables at runtime. On an unsupported host, run `/asgrep-doctor` for the exact platform error.
134
+
135
+ The extension, `ast-sgrep` launcher, platform package, native addon, and embedded CLI are exact-version matched. Update or reinstall the complete package if doctor reports a version or protocol mismatch.
136
+
137
+ ## Typed Code Mode API
138
+
139
+ Import the search-only programmatic surface from `pi-ast-sgrep/code-mode` and execute related lookups in one typed plan:
140
+
141
+ ```ts
142
+ import { AstSgrepRuntime } from "pi-ast-sgrep/runtime";
143
+ import { createSgrepCodeMode } from "pi-ast-sgrep/code-mode";
144
+
145
+ const mode = createSgrepCodeMode(new AstSgrepRuntime(pi), { cwd: process.cwd() });
146
+ const result = await mode.execute(async (sgrep) => {
147
+ const [text, ast, semantic] = await Promise.all([
148
+ sgrep.keywordSearch("refresh token"),
149
+ sgrep.astSearch("function_declaration"),
150
+ sgrep.semanticSearch("credential renewal"),
151
+ ]);
152
+ const bodies = await sgrep.codeRead(text.hits.slice(0, 3), { contextLines: 2 });
153
+ return { text, ast, semantic, bodies };
154
+ });
155
+ ```
156
+
157
+ - `keywordSearch` runs lexical retrieval only.
158
+ - `astSearch` runs `pattern:` structural search only.
159
+ - `semanticSearch` runs embedding retrieval only.
160
+ - `codeRead` streams bounded `file#Lx-Ly` refs inside the project, including adjacent context, symlink containment, strict UTF-8 validation, cancellation, and an aggregate output budget.
161
+ - `find`, `astFind`, `semantic`, and `read` remain typed aliases for the four methods above.
162
+
163
+ The agent chooses the retrieval granularity; these methods never auto-fuse channels. One-shot CLI search retains fusion for human/direct engine use. The API exposes no rewrite or mutation operation. Structural rewrites remain delegated to ast-grep. Search responses retain signal, contributor, score, and margin provenance.
30
164
 
31
165
  ## Local by default
32
166
 
33
- Local semantic indexing/search works offline with no credential, telemetry, first-use model download, executable download, PATH lookup, or MCP adapter. Optional external embedding providers are opt-in and may receive the source text and queries needed to create embeddings.
167
+ The default semantic backend works offline. It needs no credential, sends no telemetry, and downloads no model on first use. Search data stays under the project's `.asgrep/` directory.
168
+
169
+ External cloud, Ollama, and neural embedding providers are optional. If you enable one, source text and queries needed for embeddings can be sent to that provider. Its credential, retention, and privacy rules then apply.
170
+
171
+ Pi packages are trusted code. Installation grants this JavaScript extension and its native code the permissions of the OS user running Pi. Project-root confinement is a package policy, not an operating-system security boundary.
172
+
173
+ ## Configuration
174
+
175
+ Defaults are a 30-second operation timeout, 4 MiB output limit, and 30-second freshness interval. Supported environment settings are:
176
+
177
+ | Setting | Purpose |
178
+ |---|---|
179
+ | `ASGREP_ROOT` | Select the project root. |
180
+ | `ASGREP_TIMEOUT_MS` | Set the native operation timeout. |
181
+ | `ASGREP_MAX_OUTPUT_BYTES` | Bound native output. |
182
+ | `ASGREP_REFRESH_INTERVAL_MS` | Set the idle freshness-check interval. |
183
+ | `ASGREP_BIN` | Override the packaged binary for development. |
34
184
 
35
- Indexing writes database, embedding, metadata, and lock/rebuild files under the project's `.asgrep/`. The package never edits `.gitignore`; add `.asgrep/` yourself if you do not want it committed. Pi packages run with the OS user's full access and are not sandboxed.
185
+ Explicit project configuration can opt into `allowOutsideProject`; global settings and environment variables cannot relax the default project boundary. See the [complete package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md) for schema and precedence details.
36
186
 
37
- ## Update or remove
187
+ ## Update, rollback, or remove
38
188
 
39
189
  ```bash
40
190
  pi update npm:pi-ast-sgrep
41
191
  pi remove npm:pi-ast-sgrep
42
192
  ```
43
193
 
44
- Removal preserves every project's `.asgrep` data for reinstall or rollback. Delete that directory separately and explicitly only when you no longer need it. Compatible updates reuse validated data; incompatible formats rebuild atomically and preserve recoverable prior data on failure. Roll back by removing the package and installing `npm:pi-ast-sgrep@<previous-version>`, then run `/asgrep-doctor`.
194
+ Removal preserves each project's `.asgrep/` data for reinstall or rollback. Delete that directory separately only when you no longer need the index.
195
+
196
+ To roll back, install one prior version as a matched unit:
197
+
198
+ ```bash
199
+ pi remove npm:pi-ast-sgrep
200
+ pi install npm:pi-ast-sgrep@<previous-version>
201
+ ```
202
+
203
+ Then run `/asgrep-doctor`. Compatible updates reuse validated data. Incompatible formats rebuild atomically and preserve recoverable prior data when a rebuild fails.
204
+
205
+ ## More documentation
45
206
 
46
- Read the [complete install, configuration, security, recovery, and uninstall guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md). Release provenance and package order are documented in [RELEASING.md](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/RELEASING.md).
207
+ - [Complete Pi package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md)
208
+ - [Code Mode architecture and performance](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/codemode.md)
209
+ - [Query grammar](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/QUERY_GRAMMAR.md)
210
+ - [Release provenance](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/RELEASING.md)
47
211
 
48
212
  MIT
@@ -0,0 +1,81 @@
1
+ import { type AstSgrepRuntime, type MachineEnvelope, type RunOptions, type RuntimeContext } from "./runtime.js";
2
+ export type SgrepKind = "asgrep" | "def" | "caller" | "graph" | "anchor" | "import" | "pattern" | "embed";
3
+ export type SgrepSignal = "exact" | "structural" | "semantic";
4
+ export type SgrepRef = `${string}#L${number}-L${number}`;
5
+ export interface SgrepHit {
6
+ kind: SgrepKind;
7
+ signal: SgrepSignal;
8
+ contributors: SgrepKind[];
9
+ score: number;
10
+ margin: number;
11
+ file: string;
12
+ lines: {
13
+ start: number;
14
+ end: number;
15
+ };
16
+ ref: SgrepRef;
17
+ preview: string;
18
+ symbol?: string | null;
19
+ caller?: string | null;
20
+ callee?: string | null;
21
+ language?: string | null;
22
+ excerpt?: string;
23
+ }
24
+ export interface SgrepSearchResponse extends MachineEnvelope {
25
+ hits: SgrepHit[];
26
+ query?: string;
27
+ hit_count?: number;
28
+ }
29
+ export interface SgrepSearchOptions extends RunOptions {
30
+ limit?: number;
31
+ excerptLines?: number;
32
+ }
33
+ export interface SgrepReadOptions {
34
+ contextLines?: number;
35
+ /** Aggregate character budget across all refs. */
36
+ maxChars?: number;
37
+ signal?: AbortSignal;
38
+ }
39
+ export interface SgrepReadResult {
40
+ ref: SgrepRef;
41
+ file: string;
42
+ lines: {
43
+ start: number;
44
+ end: number;
45
+ };
46
+ content: string;
47
+ truncated: boolean;
48
+ }
49
+ export interface SgrepApi {
50
+ keywordSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
51
+ astSearch(pattern: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
52
+ semanticSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
53
+ codeRead(ids: SgrepRef | Pick<SgrepHit, "ref"> | readonly (SgrepRef | Pick<SgrepHit, "ref">)[], options?: SgrepReadOptions): Promise<SgrepReadResult[]>;
54
+ /** Alias for keywordSearch. */
55
+ find(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
56
+ /** Alias for astSearch. */
57
+ astFind(pattern: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
58
+ /** Alias for semanticSearch. */
59
+ semantic(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
60
+ /** Alias for codeRead. */
61
+ read(ids: SgrepRef | Pick<SgrepHit, "ref"> | readonly (SgrepRef | Pick<SgrepHit, "ref">)[], options?: SgrepReadOptions): Promise<SgrepReadResult[]>;
62
+ }
63
+ export type SgrepPlan<T> = (sgrep: Readonly<SgrepApi>) => T | Promise<T>;
64
+ type RuntimeLike = Pick<AstSgrepRuntime, "run" | "resolveRoot">;
65
+ export declare class SgrepCodeMode implements SgrepApi {
66
+ #private;
67
+ private readonly runtime;
68
+ private readonly context;
69
+ constructor(runtime: RuntimeLike, context: RuntimeContext);
70
+ execute<T>(plan: SgrepPlan<T>): Promise<T>;
71
+ keywordSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
72
+ astSearch(pattern: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
73
+ semanticSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
74
+ find(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
75
+ astFind(pattern: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
76
+ semantic(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
77
+ codeRead(ids: SgrepRef | Pick<SgrepHit, "ref"> | readonly (SgrepRef | Pick<SgrepHit, "ref">)[], options?: SgrepReadOptions): Promise<SgrepReadResult[]>;
78
+ read(ids: SgrepRef | Pick<SgrepHit, "ref"> | readonly (SgrepRef | Pick<SgrepHit, "ref">)[], options?: SgrepReadOptions): Promise<SgrepReadResult[]>;
79
+ }
80
+ export declare function createSgrepCodeMode(runtime: RuntimeLike, context: RuntimeContext): SgrepCodeMode;
81
+ export {};
@@ -0,0 +1,348 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, realpath } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { RuntimeError } from "./runtime.js";
5
+ const DEFAULT_LIMIT = 20;
6
+ const MAX_LIMIT = 100;
7
+ const MAX_EXCERPT_LINES = 100;
8
+ const DEFAULT_MAX_READ_CHARS = 100_000;
9
+ const MAX_READ_CHARS = 1_000_000;
10
+ const MAX_READ_REFS = 20;
11
+ const MAX_SCAN_BYTES = 64 * 1024 * 1024;
12
+ const MAX_LINE_NUMBER = 0xffff_ffff;
13
+ const REF_PATTERN = /^(.+?)#L([1-9]\d*)-L([1-9]\d*)$/;
14
+ const KINDS = new Set(["asgrep", "def", "caller", "graph", "anchor", "import", "pattern", "embed"]);
15
+ const SIGNALS = new Set(["exact", "structural", "semantic"]);
16
+ function boundedInteger(value, fallback, minimum, maximum, name) {
17
+ if (value === undefined)
18
+ return fallback;
19
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
20
+ throw new RuntimeError("INVALID_ARGUMENT", `${name} must be an integer from ${minimum} to ${maximum}`);
21
+ }
22
+ return value;
23
+ }
24
+ function requiredText(value, name) {
25
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > 4_096) {
26
+ throw new RuntimeError("INVALID_ARGUMENT", `${name} must contain 1 to 4096 characters`);
27
+ }
28
+ return value.trim();
29
+ }
30
+ function outputArgs(options) {
31
+ return [
32
+ "--json",
33
+ "--format",
34
+ "agent-capsule",
35
+ "--limit",
36
+ String(boundedInteger(options.limit, DEFAULT_LIMIT, 1, MAX_LIMIT, "limit")),
37
+ "--excerpt-lines",
38
+ String(boundedInteger(options.excerptLines, 0, 0, MAX_EXCERPT_LINES, "excerptLines")),
39
+ ];
40
+ }
41
+ function asSearchResponse(value) {
42
+ if (value.ok !== true || !Array.isArray(value.hits)) {
43
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response is missing hits");
44
+ }
45
+ if (value.query !== undefined && typeof value.query !== "string") {
46
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid query");
47
+ }
48
+ if (value.hit_count !== undefined
49
+ && (typeof value.hit_count !== "number" || !Number.isSafeInteger(value.hit_count)
50
+ || value.hit_count < 0 || value.hit_count !== value.hits.length)) {
51
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid hit_count");
52
+ }
53
+ const optionalText = (field) => field === undefined || field === null || typeof field === "string";
54
+ for (const candidate of value.hits) {
55
+ if (!candidate || typeof candidate !== "object") {
56
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
57
+ }
58
+ const hit = candidate;
59
+ const lines = hit.lines;
60
+ const validLines = !!lines && typeof lines === "object"
61
+ && Number.isSafeInteger(lines.start)
62
+ && Number.isSafeInteger(lines.end)
63
+ && Number(lines.start) > 0
64
+ && Number(lines.end) >= Number(lines.start);
65
+ const valid = typeof hit.kind === "string" && KINDS.has(hit.kind)
66
+ && typeof hit.signal === "string" && SIGNALS.has(hit.signal)
67
+ && Array.isArray(hit.contributors) && hit.contributors.length > 0
68
+ && hit.contributors.every((kind) => typeof kind === "string" && KINDS.has(kind))
69
+ && typeof hit.score === "number" && Number.isFinite(hit.score)
70
+ && typeof hit.margin === "number" && Number.isFinite(hit.margin) && hit.margin >= 0
71
+ && typeof hit.file === "string" && hit.file.length > 0 && !isAbsolute(hit.file)
72
+ && validLines
73
+ && typeof hit.ref === "string"
74
+ && typeof hit.preview === "string"
75
+ && optionalText(hit.symbol) && optionalText(hit.caller) && optionalText(hit.callee)
76
+ && optionalText(hit.language) && optionalText(hit.excerpt);
77
+ if (!valid)
78
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
79
+ const parsed = parseRef(hit.ref);
80
+ const hitLines = lines;
81
+ if (parsed.file !== hit.file || parsed.start !== hitLines.start || parsed.end !== hitLines.end) {
82
+ throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep hit ref does not match its file and lines");
83
+ }
84
+ }
85
+ return value;
86
+ }
87
+ function refValue(value) {
88
+ return typeof value === "string" ? value : value.ref;
89
+ }
90
+ function parseRef(ref) {
91
+ const match = REF_PATTERN.exec(ref);
92
+ if (!match)
93
+ throw new RuntimeError("INVALID_REF", `Invalid ast-sgrep ref: ${ref}`);
94
+ const file = match[1];
95
+ const start = Number(match[2]);
96
+ const end = Number(match[3]);
97
+ if (isAbsolute(file) || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
98
+ || start > MAX_LINE_NUMBER || end > MAX_LINE_NUMBER || end < start) {
99
+ throw new RuntimeError("INVALID_REF", `Invalid ast-sgrep ref: ${ref}`);
100
+ }
101
+ return { file, start, end };
102
+ }
103
+ function inside(root, path) {
104
+ const rel = relative(root, path);
105
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
106
+ }
107
+ function checkAbort(signal) {
108
+ if (signal?.aborted)
109
+ throw new RuntimeError("CANCELLED", "ast-sgrep read was cancelled");
110
+ }
111
+ function boundedPrefix(value, maxChars) {
112
+ let chars = 0;
113
+ let end = 0;
114
+ for (const codePoint of value) {
115
+ if (chars >= maxChars)
116
+ return { text: value.slice(0, end), chars, truncated: true };
117
+ end += codePoint.length;
118
+ chars += 1;
119
+ }
120
+ return { text: value, chars, truncated: false };
121
+ }
122
+ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
123
+ const stat = await handle.stat();
124
+ if (!stat.isFile())
125
+ throw new RuntimeError("READ_FAILED", `${parsed.file} is not a regular file`);
126
+ const wantedStart = Math.max(1, parsed.start - contextLines);
127
+ const wantedEnd = Math.min(MAX_LINE_NUMBER, parsed.end + contextLines);
128
+ const decoder = new TextDecoder("utf-8", { fatal: true });
129
+ const stream = handle.createReadStream({
130
+ autoClose: false,
131
+ highWaterMark: 64 * 1024,
132
+ ...(signal ? { signal } : {}),
133
+ });
134
+ let pending = "";
135
+ let lineNumber = 1;
136
+ let selectedStart;
137
+ let selectedEnd;
138
+ let selectedLines = 0;
139
+ let content = "";
140
+ let contentChars = 0;
141
+ let truncated = false;
142
+ let rangeComplete = false;
143
+ let scannedBytes = 0;
144
+ const consumeLine = (line) => {
145
+ if (lineNumber >= wantedStart && lineNumber <= wantedEnd) {
146
+ selectedStart ??= lineNumber;
147
+ selectedEnd = lineNumber;
148
+ if (!truncated) {
149
+ const addition = `${selectedLines > 0 ? "\n" : ""}${line.endsWith("\r") ? line.slice(0, -1) : line}`;
150
+ const bounded = boundedPrefix(addition, maxChars - contentChars);
151
+ content += bounded.text;
152
+ contentChars += bounded.chars;
153
+ truncated = bounded.truncated;
154
+ }
155
+ selectedLines += 1;
156
+ }
157
+ if (lineNumber >= wantedEnd)
158
+ rangeComplete = true;
159
+ lineNumber += 1;
160
+ };
161
+ try {
162
+ for await (const chunk of stream) {
163
+ checkAbort(signal);
164
+ const bytes = chunk;
165
+ const remainingScan = MAX_SCAN_BYTES - scannedBytes;
166
+ const scanned = bytes.length > remainingScan + 1
167
+ ? bytes.subarray(0, remainingScan + 1)
168
+ : bytes;
169
+ scannedBytes += scanned.length;
170
+ try {
171
+ pending += decoder.decode(scanned, { stream: true });
172
+ }
173
+ catch {
174
+ throw new RuntimeError("BINARY_FILE", `${parsed.file} is not valid UTF-8 text`);
175
+ }
176
+ let newline = pending.indexOf("\n");
177
+ while (newline >= 0) {
178
+ consumeLine(pending.slice(0, newline));
179
+ pending = pending.slice(newline + 1);
180
+ if (rangeComplete)
181
+ break;
182
+ newline = pending.indexOf("\n");
183
+ }
184
+ if (rangeComplete)
185
+ break;
186
+ if (scannedBytes > MAX_SCAN_BYTES || scanned.length < bytes.length) {
187
+ throw new RuntimeError("READ_SCAN_LIMIT", `${parsed.file} exceeds the ${MAX_SCAN_BYTES}-byte scan limit`);
188
+ }
189
+ if (newline < 0 && lineNumber < wantedStart)
190
+ pending = "";
191
+ }
192
+ if (!rangeComplete) {
193
+ try {
194
+ pending += decoder.decode();
195
+ }
196
+ catch {
197
+ throw new RuntimeError("BINARY_FILE", `${parsed.file} is not valid UTF-8 text`);
198
+ }
199
+ if (pending.length > 0 || lineNumber === 1)
200
+ consumeLine(pending);
201
+ }
202
+ }
203
+ catch (cause) {
204
+ if (signal?.aborted)
205
+ throw new RuntimeError("CANCELLED", "ast-sgrep read was cancelled");
206
+ throw cause;
207
+ }
208
+ finally {
209
+ stream.destroy();
210
+ }
211
+ checkAbort(signal);
212
+ if (parsed.start >= lineNumber || parsed.end >= lineNumber) {
213
+ throw new RuntimeError("RANGE_OUT_OF_BOUNDS", `${parsed.file} has fewer than ${parsed.end} lines`);
214
+ }
215
+ return {
216
+ file: parsed.file,
217
+ lines: { start: selectedStart ?? wantedStart, end: selectedEnd ?? Math.max(wantedStart, lineNumber - 1) },
218
+ content,
219
+ truncated,
220
+ };
221
+ }
222
+ async function runSearch(runtime, context, command, query, options) {
223
+ const value = await runtime.run([...outputArgs(options), ...command, "--", query, "."], context, options);
224
+ return asSearchResponse(value);
225
+ }
226
+ async function resolveReadableFile(root, ref, parsed) {
227
+ const unresolved = resolve(root, parsed.file);
228
+ if (!inside(root, unresolved))
229
+ throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
230
+ let filePath;
231
+ let expectedStat;
232
+ try {
233
+ filePath = await realpath(unresolved);
234
+ expectedStat = await lstat(filePath);
235
+ }
236
+ catch (cause) {
237
+ throw new RuntimeError("READ_FAILED", `Unable to resolve ${parsed.file}`, {
238
+ ref,
239
+ cause: cause instanceof Error ? cause.message : String(cause),
240
+ });
241
+ }
242
+ if (!inside(root, filePath))
243
+ throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
244
+ if (!expectedStat.isFile())
245
+ throw new RuntimeError("READ_FAILED", `${parsed.file} is not a regular file`);
246
+ return { unresolved, filePath, expectedStat };
247
+ }
248
+ async function openStableHandle(root, ref, fileLabel, unresolved, filePath, expectedStat) {
249
+ let handle;
250
+ try {
251
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
252
+ handle = await open(filePath, constants.O_RDONLY | noFollow);
253
+ }
254
+ catch (cause) {
255
+ throw new RuntimeError("READ_FAILED", `Unable to open ${fileLabel}`, {
256
+ ref,
257
+ cause: cause instanceof Error ? cause.message : String(cause),
258
+ });
259
+ }
260
+ try {
261
+ const [actualStat, openedPath] = await Promise.all([handle.stat(), realpath(unresolved)]);
262
+ if (!inside(root, openedPath) || openedPath !== filePath
263
+ || actualStat.dev !== expectedStat.dev || actualStat.ino !== expectedStat.ino) {
264
+ throw new RuntimeError("PATH_CHANGED", `Ref changed while opening: ${ref}`);
265
+ }
266
+ return handle;
267
+ }
268
+ catch (cause) {
269
+ await handle.close();
270
+ throw cause;
271
+ }
272
+ }
273
+ export class SgrepCodeMode {
274
+ runtime;
275
+ context;
276
+ #api;
277
+ constructor(runtime, context) {
278
+ this.runtime = runtime;
279
+ this.context = context;
280
+ this.#api = Object.freeze({
281
+ keywordSearch: this.keywordSearch.bind(this),
282
+ astSearch: this.astSearch.bind(this),
283
+ semanticSearch: this.semanticSearch.bind(this),
284
+ codeRead: this.codeRead.bind(this),
285
+ find: this.find.bind(this),
286
+ astFind: this.astFind.bind(this),
287
+ semantic: this.semantic.bind(this),
288
+ read: this.read.bind(this),
289
+ });
290
+ }
291
+ async execute(plan) {
292
+ if (typeof plan !== "function")
293
+ throw new RuntimeError("INVALID_PLAN", "Code Mode plan must be a function");
294
+ return await plan(this.#api);
295
+ }
296
+ async keywordSearch(query, options = {}) {
297
+ return runSearch(this.runtime, this.context, ["keyword"], requiredText(query, "query"), options);
298
+ }
299
+ async astSearch(pattern, options = {}) {
300
+ return runSearch(this.runtime, this.context, [], `pattern: ${requiredText(pattern, "pattern")}`, options);
301
+ }
302
+ async semanticSearch(query, options = {}) {
303
+ return runSearch(this.runtime, this.context, ["semantic"], requiredText(query, "query"), options);
304
+ }
305
+ async find(query, options) {
306
+ return this.keywordSearch(query, options);
307
+ }
308
+ async astFind(pattern, options) {
309
+ return this.astSearch(pattern, options);
310
+ }
311
+ async semantic(query, options) {
312
+ return this.semanticSearch(query, options);
313
+ }
314
+ async codeRead(ids, options = {}) {
315
+ const values = Array.isArray(ids) ? ids : [ids];
316
+ if (values.length === 0 || values.length > MAX_READ_REFS) {
317
+ throw new RuntimeError("INVALID_ARGUMENT", `read requires 1 to ${MAX_READ_REFS} refs`);
318
+ }
319
+ const contextLines = boundedInteger(options.contextLines, 0, 0, 100, "contextLines");
320
+ const maxChars = boundedInteger(options.maxChars, DEFAULT_MAX_READ_CHARS, 1, MAX_READ_CHARS, "maxChars");
321
+ const perRefChars = Math.floor(maxChars / values.length);
322
+ const remainder = maxChars % values.length;
323
+ checkAbort(options.signal);
324
+ const root = await realpath(await this.runtime.resolveRoot(this.context));
325
+ const results = [];
326
+ for (const [index, value] of values.entries()) {
327
+ checkAbort(options.signal);
328
+ const ref = refValue(value);
329
+ const parsed = parseRef(ref);
330
+ const { unresolved, filePath, expectedStat } = await resolveReadableFile(root, ref, parsed);
331
+ const handle = await openStableHandle(root, ref, parsed.file, unresolved, filePath, expectedStat);
332
+ try {
333
+ const budget = perRefChars + (index < remainder ? 1 : 0);
334
+ results.push({ ref, ...await readLineWindow(handle, parsed, contextLines, budget, options.signal) });
335
+ }
336
+ finally {
337
+ await handle.close();
338
+ }
339
+ }
340
+ return results;
341
+ }
342
+ async read(ids, options) {
343
+ return await this.codeRead(ids, options);
344
+ }
345
+ }
346
+ export function createSgrepCodeMode(runtime, context) {
347
+ return new SgrepCodeMode(runtime, context);
348
+ }