pi-ast-sgrep 1.4.0 → 2.0.1
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 +42 -12
- package/dist/code-mode.d.ts +18 -10
- package/dist/code-mode.js +126 -42
- package/dist/codemode/connector.d.ts +37 -9
- package/dist/codemode/connector.js +29 -23
- package/dist/codemode/dispatch.d.ts +2 -3
- package/dist/codemode/dispatch.js +109 -131
- package/dist/codemode/index.d.ts +8 -9
- package/dist/codemode/index.js +7 -8
- package/dist/codemode/native.d.ts +6 -9
- package/dist/codemode/native.js +10 -1
- package/dist/codemode/runner.d.ts +37 -0
- package/dist/codemode/runner.js +242 -0
- package/dist/codemode/sandbox-worker.d.ts +1 -0
- package/dist/codemode/sandbox-worker.js +204 -0
- package/dist/codemode/session-pool.d.ts +1 -0
- package/dist/codemode/session-pool.js +94 -29
- package/dist/codemode/types.d.ts +2 -2
- package/dist/codemode/types.js +2 -2
- package/dist/codemode/worker.d.ts +3 -0
- package/dist/codemode/worker.js +99 -42
- package/dist/index.d.ts +6 -1
- package/dist/index.js +192 -106
- package/dist/present.d.ts +70 -0
- package/dist/present.js +260 -0
- package/dist/runtime.d.ts +16 -2
- package/dist/runtime.js +399 -131
- package/package.json +6 -14
- package/dist/codemode/sandbox.d.ts +0 -25
- package/dist/codemode/sandbox.js +0 -192
- package/skills/ast-sgrep/SKILL.md +0 -73
- package/skills/ast-sgrep/references/query-guide.md +0 -22
package/README.md
CHANGED
|
@@ -4,7 +4,23 @@ Native Code Mode, structural, graph, and semantic code search for [Pi](https://g
|
|
|
4
4
|
|
|
5
5
|
[](https://pi.dev/packages/pi-ast-sgrep?name=pi-ast-sgrep)
|
|
6
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 `
|
|
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, joins two indexed channels, and searches local semantic embeddings. The primary `asgrep` tool lets Pi compose several searches in one JavaScript program instead of spending one model round trip per lookup.
|
|
8
|
+
|
|
9
|
+
**v2.0.0** · 13 languages · local-first semantic · critic + two-channel `AND` · **Code Mode** (on by default, no API key)
|
|
10
|
+
|
|
11
|
+
**Upgrading to 2.0:** this is a breaking semver release because the cloud/Ollama embedding backends were removed. Update the Pi package normally; local hashed semantic search remains the default, optional neural embeddings remain in-process, and indexes that still store `embed_backend=cloud|ollama` fail closed until `/asgrep-reindex`. One-shot tools and Code Mode now put bounded hits in `content` so the model sees them, not only display-only `details`.
|
|
12
|
+
|
|
13
|
+
### What's new for Pi in 2.0
|
|
14
|
+
|
|
15
|
+
| Change | What you get |
|
|
16
|
+
|--------|----------------|
|
|
17
|
+
| Local-first embeddings | No `ASGREP_EMBED_API_KEY` / Ollama URL. Hashed semantic is default; optional ONNX stays in-process. |
|
|
18
|
+
| Results on the model path | `asgrep_search` and Code Mode serialize hits into `content`. |
|
|
19
|
+
| Two-channel queries | `asgrep.search({ query: 'callers:process_request AND pattern:fn $NAME($$$)' })` joins by span; `AND NOT` subtracts. Plain English `and` stays hybrid. |
|
|
20
|
+
| Critic + follow-ups | Agent envelopes include `why` (`critic:` notes) and causal `follow_up_queries` from the actual top hit. |
|
|
21
|
+
| Native work off the event loop | Index/search run as N-API worker tasks so Pi JS is not blocked on SQLite. |
|
|
22
|
+
| Auto-registered tools | `asgrep` lands without requiring a skill file. |
|
|
23
|
+
| Schema 12 | Older indexes rebuild through the normal compatibility path; `/asgrep-reindex` is the explicit full rebuild. |
|
|
8
24
|
|
|
9
25
|
## Install
|
|
10
26
|
|
|
@@ -24,11 +40,10 @@ No Rust toolchain or separate MCP server is required. The npm package selects th
|
|
|
24
40
|
|
|
25
41
|
| Resource | Purpose |
|
|
26
42
|
|---|---|
|
|
27
|
-
| `
|
|
43
|
+
| `asgrep` | Primary tool. Run a bounded JavaScript program that composes typed `asgrep.*` calls. Auto-registered with Pi (no skill file). |
|
|
28
44
|
| `asgrep_search` | Run one natural, structural, symbol, graph, semantic, word, literal, or regex lookup. |
|
|
29
45
|
| `asgrep_index` | Create, refresh, or explicitly rebuild the current project index. |
|
|
30
46
|
| `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. |
|
|
32
47
|
|
|
33
48
|
The package also registers `/asgrep-doctor`, `/asgrep-status`, `/asgrep-index`, and `/asgrep-reindex`.
|
|
34
49
|
|
|
@@ -38,7 +53,7 @@ Ask Pi:
|
|
|
38
53
|
|
|
39
54
|
> 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
55
|
|
|
41
|
-
Pi can make one `
|
|
56
|
+
Pi can make one `asgrep` call like this:
|
|
42
57
|
|
|
43
58
|
```json
|
|
44
59
|
{
|
|
@@ -50,7 +65,7 @@ This workflow narrows the first result, runs independent follow-up searches toge
|
|
|
50
65
|
|
|
51
66
|
### Code Mode API
|
|
52
67
|
|
|
53
|
-
The
|
|
68
|
+
The Code Mode program receives these asynchronous methods on `asgrep`:
|
|
54
69
|
|
|
55
70
|
| Method | Use |
|
|
56
71
|
|---|---|
|
|
@@ -67,7 +82,15 @@ The sandbox exposes these asynchronous methods:
|
|
|
67
82
|
|
|
68
83
|
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
84
|
|
|
70
|
-
Code Mode
|
|
85
|
+
Code Mode runs in a disposable worker with a restricted `node:vm` context that exposes only a serialized `asgrep.*` bridge and console. String and WebAssembly code generation are disabled, ambient Node globals such as `process` and `require` are not exposed, and terminating the worker contains synchronous and microtask CPU loops. Node does not consider `vm` an adversarial-code security boundary, however, and the installed Pi package has full OS-user access; do not treat Code Mode as an OS jail. Prefer Code Mode **or** MCP for a client, never both.
|
|
86
|
+
|
|
87
|
+
The bridge rejects oversized call arguments and serialized results, allows at
|
|
88
|
+
most 256 host calls per program, and caps collected console output before it
|
|
89
|
+
reaches the extension host. Raw-memory and WebAssembly globals are unavailable;
|
|
90
|
+
worker heap/stack limits contain the remaining accidental memory growth. Native
|
|
91
|
+
tool values are capped at 1 MiB each and complete batch responses at 4 MiB before
|
|
92
|
+
Node-API converts them into extension-host objects. These bounds do not turn `node:vm` into an OS
|
|
93
|
+
sandbox.
|
|
71
94
|
|
|
72
95
|
## Direct one-shot search
|
|
73
96
|
|
|
@@ -78,13 +101,15 @@ Use `asgrep_search` when one lookup is enough:
|
|
|
78
101
|
{"query":"auth_refresh","mode":"callers","limit":8}
|
|
79
102
|
{"query":"where are credentials renewed?","mode":"semantic","limit":8}
|
|
80
103
|
{"query":"$CLIENT.post($URL)","mode":"pattern","limit":8}
|
|
104
|
+
{"query":"callers:process_request AND pattern:fn $NAME($$$)", "mode":"natural","limit":8}
|
|
105
|
+
{"query":"defs:handle AND NOT callers:test_","mode":"natural","limit":8}
|
|
81
106
|
```
|
|
82
107
|
|
|
83
108
|
Available modes:
|
|
84
109
|
|
|
85
110
|
| Mode | Best for |
|
|
86
111
|
|---|---|
|
|
87
|
-
| `natural` | Intent or mixed code-language queries when exact spelling is unknown. |
|
|
112
|
+
| `natural` | Intent or mixed code-language queries when exact spelling is unknown. Also the mode for two-channel `AND` / `AND NOT` query strings. |
|
|
88
113
|
| `pattern` | Syntax-aware ast-sgrep patterns with metavariables. |
|
|
89
114
|
| `defs`, `callers`, `imports` | Symbol and module navigation. |
|
|
90
115
|
| `chain` | Multi-hop relationship tracing. |
|
|
@@ -97,6 +122,8 @@ Available modes:
|
|
|
97
122
|
|
|
98
123
|
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
124
|
|
|
125
|
+
Native index and search calls run as Promise-returning N-API worker tasks rather than on Node's event-loop thread. Calls for one warm session are serialized before entering libuv so concurrent Pi work does not occupy worker threads waiting on the same SQLite session.
|
|
126
|
+
|
|
100
127
|
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
128
|
|
|
102
129
|
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.
|
|
@@ -105,7 +132,9 @@ Code Mode and `ast-sgrep-mcp` are separate front ends over the same Rust search
|
|
|
105
132
|
|
|
106
133
|
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
134
|
|
|
108
|
-
After a successful Pi `write` or `edit`, the extension marks the affected path dirty and
|
|
135
|
+
After a successful Pi `write` or `edit`, the extension marks the affected path dirty and updates only known changed paths before the next search. It also watches the project for external filesystem changes: known file changes receive the same targeted update, while renames, directory changes, ignore-file edits, watcher errors, and ambiguous events trigger a correctness scan. `.asgrep` writes are excluded so indexing cannot dirty itself. If recursive watching is unavailable, an immediate scan plus the periodic full scan preserve correctness. Concurrent searches for the same root share one in-flight refresh.
|
|
136
|
+
|
|
137
|
+
The periodic interval forces a full incremental reconciliation even when the watcher reports nothing, covering dropped or coalesced filesystem events. Run `/asgrep-index` when you need freshness immediately after a large external operation; use `/asgrep-reindex` only for an incompatible or corrupt index, or when you explicitly need a strict full rebuild.
|
|
109
138
|
|
|
110
139
|
The package never edits `.gitignore`. Add this entry yourself if index data must stay untracked:
|
|
111
140
|
|
|
@@ -120,7 +149,7 @@ The package never edits `.gitignore`. Add this entry yourself if index data must
|
|
|
120
149
|
| `/asgrep-doctor` | Check package versions, native runtime, protocol, index, and project settings. |
|
|
121
150
|
| `/asgrep-status` | Show the current root and index state. |
|
|
122
151
|
| `/asgrep-index` | Create or incrementally refresh the index. |
|
|
123
|
-
| `/asgrep-reindex` |
|
|
152
|
+
| `/asgrep-reindex` | Strictly rebuild the index in one transaction while preserving the prior usable rows on failure. |
|
|
124
153
|
|
|
125
154
|
These commands take no arguments.
|
|
126
155
|
|
|
@@ -166,7 +195,7 @@ The agent chooses the retrieval granularity; these methods never auto-fuse chann
|
|
|
166
195
|
|
|
167
196
|
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
197
|
|
|
169
|
-
|
|
198
|
+
In-process neural embeddings are optional (`--features neural-embed`). They never send source text to a remote embedding API. Hashed local search remains the default.
|
|
170
199
|
|
|
171
200
|
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
201
|
|
|
@@ -200,13 +229,14 @@ pi remove npm:pi-ast-sgrep
|
|
|
200
229
|
pi install npm:pi-ast-sgrep@<previous-version>
|
|
201
230
|
```
|
|
202
231
|
|
|
203
|
-
Then run `/asgrep-doctor`. Compatible updates reuse validated data. Incompatible formats rebuild
|
|
232
|
+
Then run `/asgrep-doctor`. Compatible updates reuse validated data. Incompatible formats rebuild transactionally in place and preserve recoverable prior rows when a rebuild fails.
|
|
204
233
|
|
|
205
234
|
## More documentation
|
|
206
235
|
|
|
207
236
|
- [Complete Pi package guide](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/pi-package.md)
|
|
208
237
|
- [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)
|
|
238
|
+
- [Query grammar](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/QUERY_GRAMMAR.md) (prefixes and two-channel `AND`)
|
|
239
|
+
- [Fusion ranking and critic](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/fusion-ranking.md)
|
|
210
240
|
- [Release provenance](https://github.com/AdityaVG13/ast-sgrep/blob/main/docs/RELEASING.md)
|
|
211
241
|
|
|
212
242
|
MIT
|
package/dist/code-mode.d.ts
CHANGED
|
@@ -2,17 +2,16 @@ import { type AstSgrepRuntime, type MachineEnvelope, type RunOptions, type Runti
|
|
|
2
2
|
export type SgrepKind = "asgrep" | "def" | "caller" | "graph" | "anchor" | "import" | "pattern" | "embed";
|
|
3
3
|
export type SgrepSignal = "exact" | "structural" | "semantic";
|
|
4
4
|
export type SgrepRef = `${string}#L${number}-L${number}`;
|
|
5
|
+
/**
|
|
6
|
+
* Trusted search hit. Location is solely `ref` (parsed once at the CLI/JSON boundary).
|
|
7
|
+
* Wire may still dual-encode file/lines; those are not live fields on this type.
|
|
8
|
+
*/
|
|
5
9
|
export interface SgrepHit {
|
|
6
10
|
kind: SgrepKind;
|
|
7
11
|
signal: SgrepSignal;
|
|
8
12
|
contributors: SgrepKind[];
|
|
9
13
|
score: number;
|
|
10
14
|
margin: number;
|
|
11
|
-
file: string;
|
|
12
|
-
lines: {
|
|
13
|
-
start: number;
|
|
14
|
-
end: number;
|
|
15
|
-
};
|
|
16
15
|
ref: SgrepRef;
|
|
17
16
|
preview: string;
|
|
18
17
|
symbol?: string | null;
|
|
@@ -36,15 +35,18 @@ export interface SgrepReadOptions {
|
|
|
36
35
|
maxChars?: number;
|
|
37
36
|
signal?: AbortSignal;
|
|
38
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Trusted read window. Location is solely `ref` (actual lines returned; may expand the
|
|
40
|
+
* request via contextLines). Derive file/lines with `parseSgrepRef` -- no live twins.
|
|
41
|
+
*/
|
|
39
42
|
export interface SgrepReadResult {
|
|
40
43
|
ref: SgrepRef;
|
|
41
|
-
file: string;
|
|
42
|
-
lines: {
|
|
43
|
-
start: number;
|
|
44
|
-
end: number;
|
|
45
|
-
};
|
|
46
44
|
content: string;
|
|
47
45
|
truncated: boolean;
|
|
46
|
+
/** Present when truncated: 1-indexed line to resume from (on the last shown line). */
|
|
47
|
+
resumeOffset?: number;
|
|
48
|
+
/** Named recovery hint for the model (empty/past-EOF/truncation). */
|
|
49
|
+
note?: string;
|
|
48
50
|
}
|
|
49
51
|
export interface SgrepApi {
|
|
50
52
|
keywordSearch(query: string, options?: SgrepSearchOptions): Promise<SgrepSearchResponse>;
|
|
@@ -62,6 +64,12 @@ export interface SgrepApi {
|
|
|
62
64
|
}
|
|
63
65
|
export type SgrepPlan<T> = (sgrep: Readonly<SgrepApi>) => T | Promise<T>;
|
|
64
66
|
type RuntimeLike = Pick<AstSgrepRuntime, "run" | "resolveRoot">;
|
|
67
|
+
/** Derive file/lines from a branded ref (sole location encoding on SgrepHit). */
|
|
68
|
+
export declare function parseSgrepRef(ref: SgrepRef): {
|
|
69
|
+
file: string;
|
|
70
|
+
start: number;
|
|
71
|
+
end: number;
|
|
72
|
+
};
|
|
65
73
|
export declare class SgrepCodeMode implements SgrepApi {
|
|
66
74
|
#private;
|
|
67
75
|
private readonly runtime;
|
package/dist/code-mode.js
CHANGED
|
@@ -9,6 +9,17 @@ const DEFAULT_MAX_READ_CHARS = 100_000;
|
|
|
9
9
|
const MAX_READ_CHARS = 1_000_000;
|
|
10
10
|
const MAX_READ_REFS = 20;
|
|
11
11
|
const MAX_SCAN_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
const MAX_LINE_CHARS = 2_000;
|
|
13
|
+
const DEVICE_PATHS = new Set([
|
|
14
|
+
"/dev/zero", "/dev/urandom", "/dev/random", "/dev/stdin",
|
|
15
|
+
"/dev/stdout", "/dev/stderr", "/dev/null", "/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
|
|
16
|
+
]);
|
|
17
|
+
function assertSafeReadPath(absolutePath) {
|
|
18
|
+
const normalized = absolutePath.replace(/\\/g, "/");
|
|
19
|
+
if (DEVICE_PATHS.has(normalized) || /^\/proc\/\d+\/fd\//.test(normalized)) {
|
|
20
|
+
throw new RuntimeError("READ_FORBIDDEN_PATH", `${absolutePath} is a device or process fd path and cannot be read`, { path: absolutePath });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
12
23
|
const MAX_LINE_NUMBER = 0xffff_ffff;
|
|
13
24
|
const REF_PATTERN = /^(.+?)#L([1-9]\d*)-L([1-9]\d*)$/;
|
|
14
25
|
const KINDS = new Set(["asgrep", "def", "caller", "graph", "anchor", "import", "pattern", "embed"]);
|
|
@@ -38,6 +49,71 @@ function outputArgs(options) {
|
|
|
38
49
|
String(boundedInteger(options.excerptLines, 0, 0, MAX_EXCERPT_LINES, "excerptLines")),
|
|
39
50
|
];
|
|
40
51
|
}
|
|
52
|
+
function optionalTextField(field) {
|
|
53
|
+
return field === undefined || field === null || typeof field === "string";
|
|
54
|
+
}
|
|
55
|
+
function wireLinesValid(lines) {
|
|
56
|
+
return !!lines && typeof lines === "object"
|
|
57
|
+
&& Number.isSafeInteger(lines.start)
|
|
58
|
+
&& Number.isSafeInteger(lines.end)
|
|
59
|
+
&& Number(lines.start) > 0
|
|
60
|
+
&& Number(lines.end) >= Number(lines.start);
|
|
61
|
+
}
|
|
62
|
+
/** Parse wire location once: prefer branded `ref`; else derive from structured file/lines. */
|
|
63
|
+
function parseWireHitRef(hit) {
|
|
64
|
+
if (typeof hit.ref === "string") {
|
|
65
|
+
parseRef(hit.ref);
|
|
66
|
+
return hit.ref;
|
|
67
|
+
}
|
|
68
|
+
if (typeof hit.file === "string" && hit.file.length > 0 && !isAbsolute(hit.file) && wireLinesValid(hit.lines)) {
|
|
69
|
+
const start = Number(hit.lines.start);
|
|
70
|
+
const end = Number(hit.lines.end);
|
|
71
|
+
if (start > MAX_LINE_NUMBER || end > MAX_LINE_NUMBER) {
|
|
72
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
|
|
73
|
+
}
|
|
74
|
+
const ref = `${hit.file}#L${start}-L${end}`;
|
|
75
|
+
parseRef(ref);
|
|
76
|
+
return ref;
|
|
77
|
+
}
|
|
78
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
|
|
79
|
+
}
|
|
80
|
+
/** Wire hit shape gate: required protocol fields + optional text fields. Domain checks kept intact. */
|
|
81
|
+
function isValidHitShape(hit) {
|
|
82
|
+
return typeof hit.kind === "string" && KINDS.has(hit.kind)
|
|
83
|
+
&& typeof hit.signal === "string" && SIGNALS.has(hit.signal)
|
|
84
|
+
&& Array.isArray(hit.contributors) && hit.contributors.length > 0
|
|
85
|
+
&& hit.contributors.every((kind) => typeof kind === "string" && KINDS.has(kind))
|
|
86
|
+
&& typeof hit.score === "number" && Number.isFinite(hit.score)
|
|
87
|
+
&& typeof hit.margin === "number" && Number.isFinite(hit.margin) && hit.margin >= 0
|
|
88
|
+
&& typeof hit.preview === "string"
|
|
89
|
+
&& optionalTextField(hit.symbol) && optionalTextField(hit.caller) && optionalTextField(hit.callee)
|
|
90
|
+
&& optionalTextField(hit.language) && optionalTextField(hit.excerpt);
|
|
91
|
+
}
|
|
92
|
+
function parseSearchHit(candidate) {
|
|
93
|
+
if (!candidate || typeof candidate !== "object") {
|
|
94
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
|
|
95
|
+
}
|
|
96
|
+
const hit = candidate;
|
|
97
|
+
if (!isValidHitShape(hit)) {
|
|
98
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep returned an invalid search hit");
|
|
99
|
+
}
|
|
100
|
+
const ref = parseWireHitRef(hit);
|
|
101
|
+
const parsed = {
|
|
102
|
+
kind: hit.kind,
|
|
103
|
+
signal: hit.signal,
|
|
104
|
+
contributors: hit.contributors,
|
|
105
|
+
score: hit.score,
|
|
106
|
+
margin: hit.margin,
|
|
107
|
+
ref,
|
|
108
|
+
preview: hit.preview,
|
|
109
|
+
...(hit.symbol === undefined ? {} : { symbol: hit.symbol }),
|
|
110
|
+
...(hit.caller === undefined ? {} : { caller: hit.caller }),
|
|
111
|
+
...(hit.callee === undefined ? {} : { callee: hit.callee }),
|
|
112
|
+
...(hit.language === undefined ? {} : { language: hit.language }),
|
|
113
|
+
...(hit.excerpt === undefined ? {} : { excerpt: hit.excerpt }),
|
|
114
|
+
};
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
41
117
|
function asSearchResponse(value) {
|
|
42
118
|
if (value.ok !== true || !Array.isArray(value.hits)) {
|
|
43
119
|
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response is missing hits");
|
|
@@ -45,48 +121,23 @@ function asSearchResponse(value) {
|
|
|
45
121
|
if (value.query !== undefined && typeof value.query !== "string") {
|
|
46
122
|
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid query");
|
|
47
123
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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");
|
|
124
|
+
// Guard form of compound hit_count validity (same checks, less && nesting).
|
|
125
|
+
if (value.hit_count !== undefined) {
|
|
126
|
+
if (typeof value.hit_count !== "number" || !Number.isSafeInteger(value.hit_count)
|
|
127
|
+
|| value.hit_count < 0 || value.hit_count !== value.hits.length) {
|
|
128
|
+
throw new RuntimeError("PROTOCOL_MISMATCH", "ast-sgrep search response has an invalid hit_count");
|
|
83
129
|
}
|
|
84
130
|
}
|
|
85
|
-
|
|
131
|
+
const hits = value.hits.map(parseSearchHit);
|
|
132
|
+
return { ...value, hits };
|
|
86
133
|
}
|
|
87
134
|
function refValue(value) {
|
|
88
135
|
return typeof value === "string" ? value : value.ref;
|
|
89
136
|
}
|
|
137
|
+
/** Derive file/lines from a branded ref (sole location encoding on SgrepHit). */
|
|
138
|
+
export function parseSgrepRef(ref) {
|
|
139
|
+
return parseRef(ref);
|
|
140
|
+
}
|
|
90
141
|
function parseRef(ref) {
|
|
91
142
|
const match = REF_PATTERN.exec(ref);
|
|
92
143
|
if (!match)
|
|
@@ -119,6 +170,9 @@ function boundedPrefix(value, maxChars) {
|
|
|
119
170
|
}
|
|
120
171
|
return { text: value, chars, truncated: false };
|
|
121
172
|
}
|
|
173
|
+
function formatReadRef(file, start, end) {
|
|
174
|
+
return `${file}#L${start}-L${end}`;
|
|
175
|
+
}
|
|
122
176
|
async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
|
|
123
177
|
const stat = await handle.stat();
|
|
124
178
|
if (!stat.isFile())
|
|
@@ -146,7 +200,9 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
|
|
|
146
200
|
selectedStart ??= lineNumber;
|
|
147
201
|
selectedEnd = lineNumber;
|
|
148
202
|
if (!truncated) {
|
|
149
|
-
const
|
|
203
|
+
const rawLine = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
204
|
+
const clamped = rawLine.length > MAX_LINE_CHARS ? `${rawLine.slice(0, MAX_LINE_CHARS)}…` : rawLine;
|
|
205
|
+
const addition = `${selectedLines > 0 ? "\n" : ""}${clamped}`;
|
|
150
206
|
const bounded = boundedPrefix(addition, maxChars - contentChars);
|
|
151
207
|
content += bounded.text;
|
|
152
208
|
contentChars += bounded.chars;
|
|
@@ -209,14 +265,31 @@ async function readLineWindow(handle, parsed, contextLines, maxChars, signal) {
|
|
|
209
265
|
stream.destroy();
|
|
210
266
|
}
|
|
211
267
|
checkAbort(signal);
|
|
212
|
-
|
|
213
|
-
|
|
268
|
+
const totalLines = Math.max(0, lineNumber - 1);
|
|
269
|
+
if (totalLines === 0) {
|
|
270
|
+
return {
|
|
271
|
+
window: null,
|
|
272
|
+
content: "",
|
|
273
|
+
truncated: false,
|
|
274
|
+
note: `${parsed.file} is empty`,
|
|
275
|
+
};
|
|
214
276
|
}
|
|
277
|
+
if (parsed.start > totalLines || parsed.end > totalLines) {
|
|
278
|
+
const resume = Math.max(1, totalLines);
|
|
279
|
+
throw new RuntimeError("RANGE_OUT_OF_BOUNDS", `Note: offset ${parsed.start} is beyond the end of ${parsed.file} (${totalLines} lines scanned). Retry with a smaller offset (e.g. start=${resume})`, { file: parsed.file, start: parsed.start, end: parsed.end, totalLines, resumeOffset: resume });
|
|
280
|
+
}
|
|
281
|
+
const endLine = selectedEnd ?? Math.max(wantedStart, totalLines);
|
|
282
|
+
const startLine = selectedStart ?? wantedStart;
|
|
215
283
|
return {
|
|
216
|
-
file: parsed.file,
|
|
217
|
-
lines: { start: selectedStart ?? wantedStart, end: selectedEnd ?? Math.max(wantedStart, lineNumber - 1) },
|
|
284
|
+
window: { file: parsed.file, start: startLine, end: endLine },
|
|
218
285
|
content,
|
|
219
286
|
truncated,
|
|
287
|
+
...(truncated
|
|
288
|
+
? {
|
|
289
|
+
resumeOffset: endLine,
|
|
290
|
+
note: `truncated at line ${endLine}; resume with start=${endLine}`,
|
|
291
|
+
}
|
|
292
|
+
: {}),
|
|
220
293
|
};
|
|
221
294
|
}
|
|
222
295
|
async function runSearch(runtime, context, command, query, options) {
|
|
@@ -225,6 +298,7 @@ async function runSearch(runtime, context, command, query, options) {
|
|
|
225
298
|
}
|
|
226
299
|
async function resolveReadableFile(root, ref, parsed) {
|
|
227
300
|
const unresolved = resolve(root, parsed.file);
|
|
301
|
+
assertSafeReadPath(unresolved);
|
|
228
302
|
if (!inside(root, unresolved))
|
|
229
303
|
throw new RuntimeError("PATH_OUTSIDE_ROOT", `Ref escapes the project root: ${ref}`);
|
|
230
304
|
let filePath;
|
|
@@ -331,7 +405,17 @@ export class SgrepCodeMode {
|
|
|
331
405
|
const handle = await openStableHandle(root, ref, parsed.file, unresolved, filePath, expectedStat);
|
|
332
406
|
try {
|
|
333
407
|
const budget = perRefChars + (index < remainder ? 1 : 0);
|
|
334
|
-
|
|
408
|
+
const payload = await readLineWindow(handle, parsed, contextLines, budget, options.signal);
|
|
409
|
+
const windowRef = payload.window
|
|
410
|
+
? formatReadRef(payload.window.file, payload.window.start, payload.window.end)
|
|
411
|
+
: ref;
|
|
412
|
+
results.push({
|
|
413
|
+
ref: windowRef,
|
|
414
|
+
content: payload.content,
|
|
415
|
+
truncated: payload.truncated,
|
|
416
|
+
...(payload.resumeOffset === undefined ? {} : { resumeOffset: payload.resumeOffset }),
|
|
417
|
+
...(payload.note === undefined ? {} : { note: payload.note }),
|
|
418
|
+
});
|
|
335
419
|
}
|
|
336
420
|
finally {
|
|
337
421
|
await handle.close();
|
|
@@ -1,49 +1,77 @@
|
|
|
1
1
|
import type { MachineEnvelope } from "../runtime.js";
|
|
2
2
|
import type { ChainArgs, SearchArgs } from "./types.js";
|
|
3
3
|
import { type BatchCapableHost, type DispatchStats } from "./dispatch.js";
|
|
4
|
+
/**
|
|
5
|
+
* Spawn/CLI transport. Hosts provide argv `run` only — never a typed twin.
|
|
6
|
+
* Typed entry lives solely on {@link DispatchSurface} (dispatcher output).
|
|
7
|
+
*/
|
|
4
8
|
export type ConnectorHost = {
|
|
5
|
-
|
|
6
|
-
call?(tool: string, args: Record<string, unknown>, context: {
|
|
9
|
+
run(args: readonly string[], context: {
|
|
7
10
|
cwd: string;
|
|
8
11
|
}, options?: {
|
|
9
12
|
signal?: AbortSignal;
|
|
10
13
|
}): Promise<MachineEnvelope>;
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Trusted typed dispatch after coalescing. `call` is required; no argv peer
|
|
17
|
+
* that can disagree with tool+args.
|
|
18
|
+
*/
|
|
19
|
+
export type DispatchSurface = {
|
|
20
|
+
call(tool: string, args: Record<string, unknown>, context: {
|
|
13
21
|
cwd: string;
|
|
14
22
|
}, options?: {
|
|
15
23
|
signal?: AbortSignal;
|
|
16
24
|
}): Promise<MachineEnvelope>;
|
|
17
25
|
};
|
|
18
26
|
export type AsgrepConnector = {
|
|
19
|
-
search(input: SearchArgs
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
search(input: SearchArgs, options?: {
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<MachineEnvelope>;
|
|
30
|
+
semantic(input: SearchArgs, options?: {
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}): Promise<MachineEnvelope>;
|
|
33
|
+
chain(input: ChainArgs, options?: {
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}): Promise<MachineEnvelope>;
|
|
22
36
|
defs(input: {
|
|
23
37
|
symbol: string;
|
|
24
38
|
limit?: number;
|
|
25
39
|
excerptLines?: number;
|
|
40
|
+
}, options?: {
|
|
41
|
+
signal?: AbortSignal;
|
|
26
42
|
}): Promise<MachineEnvelope>;
|
|
27
43
|
callers(input: {
|
|
28
44
|
symbol: string;
|
|
29
45
|
limit?: number;
|
|
30
46
|
excerptLines?: number;
|
|
47
|
+
}, options?: {
|
|
48
|
+
signal?: AbortSignal;
|
|
31
49
|
}): Promise<MachineEnvelope>;
|
|
32
50
|
imports(input: {
|
|
33
51
|
module: string;
|
|
34
52
|
limit?: number;
|
|
35
53
|
excerptLines?: number;
|
|
54
|
+
}, options?: {
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}): Promise<MachineEnvelope>;
|
|
57
|
+
indexStatus(options?: {
|
|
58
|
+
signal?: AbortSignal;
|
|
36
59
|
}): Promise<MachineEnvelope>;
|
|
37
|
-
indexStatus(): Promise<MachineEnvelope>;
|
|
38
60
|
indexRepo(input?: {
|
|
39
61
|
force?: boolean;
|
|
62
|
+
}, options?: {
|
|
63
|
+
signal?: AbortSignal;
|
|
40
64
|
}): Promise<MachineEnvelope>;
|
|
41
65
|
/** Progressive discovery (like deferred tools) — list/filter available asgrep tools. */
|
|
42
66
|
catalogSearch(input: {
|
|
43
67
|
query: string;
|
|
68
|
+
}, options?: {
|
|
69
|
+
signal?: AbortSignal;
|
|
44
70
|
}): Promise<MachineEnvelope>;
|
|
45
71
|
catalogDescribe(input: {
|
|
46
72
|
name: string;
|
|
73
|
+
}, options?: {
|
|
74
|
+
signal?: AbortSignal;
|
|
47
75
|
}): Promise<MachineEnvelope>;
|
|
48
76
|
};
|
|
49
77
|
export type ConnectorBundle = {
|
|
@@ -52,7 +80,7 @@ export type ConnectorBundle = {
|
|
|
52
80
|
resetStats: () => void;
|
|
53
81
|
};
|
|
54
82
|
/**
|
|
55
|
-
* Host-side connector: typed methods the
|
|
83
|
+
* Host-side connector: typed methods the Code Mode program calls.
|
|
56
84
|
*
|
|
57
85
|
* Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
|
|
58
86
|
* lookups share sticky serve / one warm batch process when available.
|
|
@@ -11,59 +11,65 @@ function clampExcerpt(excerptLines) {
|
|
|
11
11
|
return Math.min(100, Math.max(0, Math.trunc(excerptLines)));
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Host-side connector: typed methods the
|
|
14
|
+
* Host-side connector: typed methods the Code Mode program calls.
|
|
15
15
|
*
|
|
16
16
|
* Same-tick calls (Promise.all) are coalesced by CodemodeDispatcher so N
|
|
17
17
|
* lookups share sticky serve / one warm batch process when available.
|
|
18
18
|
*/
|
|
19
19
|
export function createAsgrepConnector(host, context, options = {}) {
|
|
20
20
|
const dispatcher = createCodemodeDispatcher(host);
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
21
|
+
const combinedSignals = new WeakMap();
|
|
22
|
+
const callOptions = (signal) => {
|
|
23
|
+
if (!options.signal)
|
|
24
|
+
return signal ? { signal } : {};
|
|
25
|
+
if (!signal || signal === options.signal)
|
|
26
|
+
return { signal: options.signal };
|
|
27
|
+
let combined = combinedSignals.get(signal);
|
|
28
|
+
if (!combined) {
|
|
29
|
+
combined = AbortSignal.any([options.signal, signal]);
|
|
30
|
+
combinedSignals.set(signal, combined);
|
|
25
31
|
}
|
|
26
|
-
|
|
27
|
-
return dispatcher.host.run([], context, runOptions);
|
|
32
|
+
return { signal: combined };
|
|
28
33
|
};
|
|
34
|
+
const call = (tool, args, signal) => dispatcher.host.call(tool, args, context, callOptions(signal));
|
|
29
35
|
// Bound function properties (not methods) so vm call sites cannot lose `this`.
|
|
30
36
|
const asgrep = {
|
|
31
|
-
search: (input) => call("search", {
|
|
37
|
+
search: (input, callOptions) => call("search", {
|
|
32
38
|
query: input.query,
|
|
33
39
|
limit: clampLimit(input.limit),
|
|
34
40
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
35
41
|
format: input.format === "agent" ? "agent" : "capsule",
|
|
36
|
-
}),
|
|
37
|
-
semantic: (input) => call("semantic", {
|
|
42
|
+
}, callOptions?.signal),
|
|
43
|
+
semantic: (input, callOptions) => call("semantic", {
|
|
38
44
|
query: input.query,
|
|
39
45
|
limit: clampLimit(input.limit),
|
|
40
46
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
41
47
|
format: input.format === "agent" ? "agent" : "capsule",
|
|
42
|
-
}),
|
|
43
|
-
chain: (input) => call("chain", {
|
|
48
|
+
}, callOptions?.signal),
|
|
49
|
+
chain: (input, callOptions) => call("chain", {
|
|
44
50
|
query: input.query,
|
|
45
51
|
limit: clampLimit(input.limit),
|
|
46
52
|
top_n: 20,
|
|
47
|
-
}),
|
|
48
|
-
defs: (input) => call("defs", {
|
|
53
|
+
}, callOptions?.signal),
|
|
54
|
+
defs: (input, callOptions) => call("defs", {
|
|
49
55
|
symbol: input.symbol,
|
|
50
56
|
limit: clampLimit(input.limit),
|
|
51
57
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
52
|
-
}),
|
|
53
|
-
callers: (input) => call("callers", {
|
|
58
|
+
}, callOptions?.signal),
|
|
59
|
+
callers: (input, callOptions) => call("callers", {
|
|
54
60
|
symbol: input.symbol,
|
|
55
61
|
limit: clampLimit(input.limit),
|
|
56
62
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
57
|
-
}),
|
|
58
|
-
imports: (input) => call("imports", {
|
|
63
|
+
}, callOptions?.signal),
|
|
64
|
+
imports: (input, callOptions) => call("imports", {
|
|
59
65
|
module: input.module,
|
|
60
66
|
limit: clampLimit(input.limit),
|
|
61
67
|
excerpt_lines: clampExcerpt(input.excerptLines),
|
|
62
|
-
}),
|
|
63
|
-
indexStatus: () => call("index_status", {}),
|
|
64
|
-
indexRepo: (input = {}) => call("index_repo", { force: input.force === true }),
|
|
65
|
-
catalogSearch: (input) => call("catalog_search", { query: input.query }),
|
|
66
|
-
catalogDescribe: (input) => call("catalog_describe", { name: input.name }),
|
|
68
|
+
}, callOptions?.signal),
|
|
69
|
+
indexStatus: (callOptions) => call("index_status", {}, callOptions?.signal),
|
|
70
|
+
indexRepo: (input = {}, callOptions) => call("index_repo", { force: input.force === true }, callOptions?.signal),
|
|
71
|
+
catalogSearch: (input, callOptions) => call("catalog_search", { query: input.query }, callOptions?.signal),
|
|
72
|
+
catalogDescribe: (input, callOptions) => call("catalog_describe", { name: input.name }, callOptions?.signal),
|
|
67
73
|
};
|
|
68
74
|
return {
|
|
69
75
|
asgrep,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* for the whole Code Mode program; batch coalescing kills it per Promise.all wave.
|
|
6
6
|
*/
|
|
7
7
|
import type { MachineEnvelope } from "../runtime.js";
|
|
8
|
-
import type { ConnectorHost } from "./connector.js";
|
|
8
|
+
import type { ConnectorHost, DispatchSurface } from "./connector.js";
|
|
9
9
|
export type CodemodeToolCall = {
|
|
10
10
|
tool: string;
|
|
11
11
|
args: Record<string, unknown>;
|
|
@@ -61,11 +61,10 @@ export type BatchCapableHost = ConnectorHost & {
|
|
|
61
61
|
* one microtask wave. Prefers sticky serve → one-shot batch → overlapped spawn.
|
|
62
62
|
*/
|
|
63
63
|
export declare function createCodemodeDispatcher(host: BatchCapableHost): {
|
|
64
|
-
host:
|
|
64
|
+
host: DispatchSurface;
|
|
65
65
|
stats: () => DispatchStats;
|
|
66
66
|
resetStats: () => void;
|
|
67
67
|
};
|
|
68
|
-
/** Build CLI argv for spawn fallback (typed path preferred). */
|
|
69
68
|
export declare function argvFor(tool: string, args: Record<string, unknown>): string[];
|
|
70
69
|
export declare function asEnvelope(value: unknown, command?: string): MachineEnvelope;
|
|
71
70
|
/** One-shot batch via stdin (no tempfile) when spawn-with-stdin is available. */
|