pi-graphnav 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +130 -2
- package/extensions/command/parse.ts +75 -0
- package/extensions/core/config.ts +135 -0
- package/extensions/core/gate.ts +418 -0
- package/extensions/core/mcp.ts +191 -0
- package/extensions/core/project.ts +172 -0
- package/extensions/core/resilience.ts +91 -0
- package/extensions/core/tools.ts +142 -0
- package/extensions/graphnav.ts +414 -0
- package/package.json +51 -4
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CodeGraph-first gate: spots text searches (`rg`, `grep`, `git grep`, `ag`, `ack`, pi's
|
|
3
|
+
* `grep` tool) whose pattern is really a symbol lookup, and decides whether to let them run.
|
|
4
|
+
*
|
|
5
|
+
* Pure: the extension feeds it commands and remembers what CodeGraph was already asked.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One text search found in a command. `pattern` is undefined for a listing (`rg --files`). */
|
|
9
|
+
export interface Search {
|
|
10
|
+
tool: string;
|
|
11
|
+
pattern?: string;
|
|
12
|
+
/** File and directory operands. */
|
|
13
|
+
paths: string[];
|
|
14
|
+
/** `--type`/`--glob`/`--include` filters, as written. */
|
|
15
|
+
filters: string[];
|
|
16
|
+
/** A file-name lookup (`fd Foo`, `find -name '*Foo*'`): only ever nudged, never blocked. */
|
|
17
|
+
files?: true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const OPERATORS = new Set(["|", "||", "&", "&&", ";", "(", ")", "\n", "|&"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Split a shell command into simple commands (arrays of words). Handles quotes, backslash
|
|
24
|
+
* escapes and the usual operators; anything stranger (subshell syntax, heredocs) degrades to
|
|
25
|
+
* extra words, which only makes the gate more permissive.
|
|
26
|
+
*/
|
|
27
|
+
export function splitCommands(command: string): string[][] {
|
|
28
|
+
return splitPipeline(command).map((c) => c.words);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Like `splitCommands`, also telling which commands read a pipe (`a | b`: b is piped). */
|
|
32
|
+
export function splitPipeline(command: string): { words: string[]; piped: boolean }[] {
|
|
33
|
+
const out: { words: string[]; piped: boolean }[] = [];
|
|
34
|
+
let cur: string[] = [];
|
|
35
|
+
let word = "";
|
|
36
|
+
let inWord = false;
|
|
37
|
+
let piped = false;
|
|
38
|
+
const endWord = () => {
|
|
39
|
+
if (inWord) cur.push(word);
|
|
40
|
+
word = "";
|
|
41
|
+
inWord = false;
|
|
42
|
+
};
|
|
43
|
+
const endCommand = (nextPiped: boolean) => {
|
|
44
|
+
endWord();
|
|
45
|
+
if (cur.length > 0) out.push({ words: cur, piped });
|
|
46
|
+
cur = [];
|
|
47
|
+
piped = nextPiped;
|
|
48
|
+
};
|
|
49
|
+
for (let i = 0; i < command.length; i++) {
|
|
50
|
+
const c = command[i]!;
|
|
51
|
+
if (c === "'") {
|
|
52
|
+
const end = command.indexOf("'", i + 1);
|
|
53
|
+
const stop = end === -1 ? command.length : end;
|
|
54
|
+
word += command.slice(i + 1, stop);
|
|
55
|
+
inWord = true;
|
|
56
|
+
i = stop;
|
|
57
|
+
} else if (c === '"') {
|
|
58
|
+
inWord = true;
|
|
59
|
+
for (i++; i < command.length && command[i] !== '"'; i++) {
|
|
60
|
+
if (command[i] === "\\" && i + 1 < command.length && '"\\$`'.includes(command[i + 1]!)) i++;
|
|
61
|
+
word += command[i];
|
|
62
|
+
}
|
|
63
|
+
} else if (c === "\\" && i + 1 < command.length) {
|
|
64
|
+
if (command[i + 1] !== "\n") word += command[i + 1];
|
|
65
|
+
inWord = true;
|
|
66
|
+
i++;
|
|
67
|
+
} else if (c === " " || c === "\t") {
|
|
68
|
+
endWord();
|
|
69
|
+
} else if (OPERATORS.has(c)) {
|
|
70
|
+
const double = command[i + 1] === c;
|
|
71
|
+
endCommand(c === "|" && !double);
|
|
72
|
+
if (double || (c === "|" && command[i + 1] === "&")) i++;
|
|
73
|
+
} else {
|
|
74
|
+
word += c;
|
|
75
|
+
inWord = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
endCommand(false);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Flags that take a value, per tool. Long flags may also be written `--flag=value`. */
|
|
83
|
+
const VALUE_FLAGS: Record<string, Set<string>> = {
|
|
84
|
+
rg: new Set(
|
|
85
|
+
"-A -B -C -m -M -j -d -E -g -t -T -r -f -e --after-context --before-context --context --max-count --max-columns --threads --max-depth --encoding --glob --iglob --type --type-not --type-add --type-clear --replace --file --regexp --color --colors --sort --sortr --pre --pre-glob --ignore-file --max-filesize --path-separator --field-match-separator --field-context-separator --context-separator --engine --dfa-size-limit --regex-size-limit --hostname-bin --hyperlink-format".split(" "),
|
|
86
|
+
),
|
|
87
|
+
grep: new Set("-A -B -C -m -e -f -d -D --after-context --before-context --context --max-count --regexp --file --include --exclude --exclude-dir --exclude-from --directories --devices --color --colour --label --binary-files".split(" ")),
|
|
88
|
+
ag: new Set("-A -B -C -m -G -g --after --before --context --max-count --file-search-regex --ignore --ignore-dir --depth --pager".split(" ")),
|
|
89
|
+
ack: new Set("-A -B -C -m -g --after-context --before-context --context --max-count --type --ignore-dir --ignore-file --output --match".split(" ")),
|
|
90
|
+
};
|
|
91
|
+
VALUE_FLAGS.egrep = VALUE_FLAGS.grep!;
|
|
92
|
+
VALUE_FLAGS.fgrep = VALUE_FLAGS.grep!;
|
|
93
|
+
VALUE_FLAGS["git grep"] = new Set("-A -B -C -m -e -f --after-context --before-context --context --max-count --max-depth --threads".split(" "));
|
|
94
|
+
|
|
95
|
+
const PATTERN_FLAGS = new Set(["-e", "--regexp"]);
|
|
96
|
+
const FILTER_FLAGS = new Set(["-g", "-t", "-T", "--glob", "--iglob", "--type", "--type-not", "--include", "-G", "--file-search-regex"]);
|
|
97
|
+
const WRAPPERS = new Set(["sudo", "command", "exec", "time", "nice", "nohup", "env", "xargs", "timeout"]);
|
|
98
|
+
/** Wrapper flags that take a separate value (`timeout -s KILL 5 rg …`, `nice -n 5 rg …`). */
|
|
99
|
+
const WRAPPER_VALUE_FLAGS: Record<string, Set<string>> = {
|
|
100
|
+
timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
|
|
101
|
+
nice: new Set(["-n", "--adjustment"]),
|
|
102
|
+
sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]),
|
|
103
|
+
xargs: new Set(["-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a"]),
|
|
104
|
+
env: new Set(["-u", "--unset", "-C", "--chdir"]),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The program of a simple command, skipping `VAR=x` assignments and wrappers like `xargs -0`. */
|
|
108
|
+
function programIndex(words: string[]): number {
|
|
109
|
+
let i = 0;
|
|
110
|
+
for (;;) {
|
|
111
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++;
|
|
112
|
+
const w = words[i];
|
|
113
|
+
if (w === undefined || !WRAPPERS.has(baseName(w))) return i;
|
|
114
|
+
const wrapper = baseName(w);
|
|
115
|
+
i++;
|
|
116
|
+
while (i < words.length && words[i]!.startsWith("-")) i += WRAPPER_VALUE_FLAGS[wrapper]?.has(words[i]!) ? 2 : 1;
|
|
117
|
+
if (wrapper === "timeout" && /^\d/.test(words[i] ?? "")) i++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const baseName = (w: string) => w.slice(w.lastIndexOf("/") + 1);
|
|
122
|
+
|
|
123
|
+
/** Every text search over files in a shell command (a filter reading a pipe searches no code). */
|
|
124
|
+
export function findSearches(command: string): Search[] {
|
|
125
|
+
const out: Search[] = [];
|
|
126
|
+
for (const { words, piped } of splitPipeline(command)) {
|
|
127
|
+
const at = programIndex(words);
|
|
128
|
+
const prog = baseName(words[at] ?? "");
|
|
129
|
+
let tool: string;
|
|
130
|
+
let args: string[];
|
|
131
|
+
if (prog === "git") {
|
|
132
|
+
let j = at + 1;
|
|
133
|
+
while (j < words.length && words[j]!.startsWith("-")) j += words[j] === "-C" || words[j] === "-c" ? 2 : 1;
|
|
134
|
+
if (words[j] !== "grep") continue;
|
|
135
|
+
tool = "git grep";
|
|
136
|
+
args = words.slice(j + 1);
|
|
137
|
+
} else if (prog in VALUE_FLAGS && prog !== "git grep") {
|
|
138
|
+
tool = prog;
|
|
139
|
+
args = words.slice(at + 1);
|
|
140
|
+
} else continue;
|
|
141
|
+
const search = parseArgs(tool, args);
|
|
142
|
+
// `… | rg x` filters the pipe; `… | xargs rg x` searches the files the pipe names.
|
|
143
|
+
if (piped && search.paths.length === 0 && !words.slice(0, at).some((w) => baseName(w) === "xargs")) continue;
|
|
144
|
+
out.push(search);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseArgs(tool: string, args: string[]): Search {
|
|
150
|
+
const valueFlags = VALUE_FLAGS[tool]!;
|
|
151
|
+
const positional: string[] = [];
|
|
152
|
+
const filters: string[] = [];
|
|
153
|
+
let pattern: string | undefined;
|
|
154
|
+
let listing = false;
|
|
155
|
+
let endOfFlags = false;
|
|
156
|
+
for (let i = 0; i < args.length; i++) {
|
|
157
|
+
const a = args[i]!;
|
|
158
|
+
if (endOfFlags || !a.startsWith("-") || a === "-") {
|
|
159
|
+
positional.push(a);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (a === "--") {
|
|
163
|
+
endOfFlags = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const eq = a.startsWith("--") ? a.indexOf("=") : -1;
|
|
167
|
+
const flag = eq === -1 ? a : a.slice(0, eq);
|
|
168
|
+
// Short flag cluster with an attached value (`-tmd`, `-e foo` as `-efoo`), or a cluster of booleans (`-rn`).
|
|
169
|
+
const short = !a.startsWith("--") && a.length > 2 ? `-${a[1]}` : undefined;
|
|
170
|
+
let value: string | undefined;
|
|
171
|
+
let name = flag;
|
|
172
|
+
if (eq !== -1) value = a.slice(eq + 1);
|
|
173
|
+
else if (valueFlags.has(flag)) value = args[++i];
|
|
174
|
+
else if (short && valueFlags.has(short)) {
|
|
175
|
+
name = short;
|
|
176
|
+
value = a.slice(2);
|
|
177
|
+
}
|
|
178
|
+
if (tool === "rg" && (name === "--files" || name === "--type-list")) listing = true;
|
|
179
|
+
if (tool === "ag" && name === "-g") listing = true;
|
|
180
|
+
if (value === undefined) continue;
|
|
181
|
+
if (PATTERN_FLAGS.has(name)) pattern = pattern === undefined ? value : `${pattern}|${value}`;
|
|
182
|
+
else if (FILTER_FLAGS.has(name) || (tool === "ack" && name === "--type")) filters.push(value);
|
|
183
|
+
else if (name === "-f" || name === "--file") listing = true; // patterns come from a file: unknowable
|
|
184
|
+
}
|
|
185
|
+
if (listing && pattern === undefined) return { tool, paths: positional, filters };
|
|
186
|
+
if (pattern === undefined) pattern = positional.shift();
|
|
187
|
+
return { tool, pattern, paths: positional, filters };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Extensions CodeGraph indexes as code. Searches limited to anything else are left alone. */
|
|
191
|
+
export const CODE_EXTENSIONS = new Set(
|
|
192
|
+
"ts tsx js jsx mjs cjs mts cts kt kts java scala groovy rs go py pyi rb php cs fs swift m mm c cc cpp cxx h hh hpp hxx dart lua ex exs erl hs ml vue svelte zig nim sol".split(" "),
|
|
193
|
+
);
|
|
194
|
+
const CODE_TYPES = new Set(
|
|
195
|
+
"ts typescript js javascript kotlin kt java scala rust rs go py python ruby rb php cs csharp swift objc objcpp c cpp dart lua elixir erlang haskell ocaml vue svelte zig".split(" "),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const extOf = (p: string): string | undefined => {
|
|
199
|
+
const base = baseName(p);
|
|
200
|
+
const dot = base.lastIndexOf(".");
|
|
201
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : undefined;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Is the search restricted to non-code files (docs, config, logs)? */
|
|
205
|
+
export function targetsOnlyNonCode(s: Search): boolean {
|
|
206
|
+
if (s.filters.length > 0) {
|
|
207
|
+
const nonCode = s.filters.every((f) => {
|
|
208
|
+
const neg = f.startsWith("!");
|
|
209
|
+
if (neg) return false;
|
|
210
|
+
const bare = f.replace(/^\{([^,{}]*)\}$/, "$1");
|
|
211
|
+
const braces = /\.\{([A-Za-z0-9,]+)\}$/.exec(bare)?.[1];
|
|
212
|
+
const exts = braces
|
|
213
|
+
? braces.split(",").map((e) => e.toLowerCase())
|
|
214
|
+
: [/\.([A-Za-z0-9]+)$/.exec(bare)?.[1]?.toLowerCase() ?? (bare.includes("*") || bare.includes(".") ? undefined : bare.toLowerCase())];
|
|
215
|
+
return exts.every((ext) => ext !== undefined && ext !== "" && !CODE_EXTENSIONS.has(ext) && !CODE_TYPES.has(ext));
|
|
216
|
+
});
|
|
217
|
+
if (nonCode) return true;
|
|
218
|
+
}
|
|
219
|
+
const files = s.paths.filter((p) => extOf(p) !== undefined);
|
|
220
|
+
return s.paths.length > 0 && files.length === s.paths.length && files.every((p) => !CODE_EXTENSIONS.has(extOf(p)!));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const DECL_KEYWORDS =
|
|
224
|
+
"fn|pub fn|pub\\(crate\\) fn|async fn|def|class|struct|enum|trait|impl|interface|type|typealias|func|function|export function|export const|export class|export interface|export type|const|let|var|val|fun|suspend fun|object|data class|sealed class|protocol|extension|module|mod|use|import|from|new";
|
|
225
|
+
const DECL = new RegExp(`^(?:${DECL_KEYWORDS})(?:\\s+|\\\\s[+*])`);
|
|
226
|
+
const KEYWORDS = new Set(DECL_KEYWORDS.split("|").filter((k) => !k.includes(" ") && !k.includes("\\")));
|
|
227
|
+
const QUALIFIED = /^[A-Za-z_$][\w$]*(?:(?:::|\.|#|->)[A-Za-z_$][\w$]*)*$/;
|
|
228
|
+
const NOT_SYMBOLS = new Set(["todo", "fixme", "hack", "xxx", "note", "bug", "deprecated", "error", "warn", "warning", "info", "debug"]);
|
|
229
|
+
|
|
230
|
+
/** Strip regex syntax that does not change what is being looked for. */
|
|
231
|
+
function normalizeAlternative(raw: string): string {
|
|
232
|
+
let s = raw.trim();
|
|
233
|
+
s = s.replace(/\\[bB<>]/g, "").replace(/\[\[:<:\]\]|\[\[:>:\]\]/g, "");
|
|
234
|
+
s = s.replace(/^\^/, "").replace(/\$$/, "");
|
|
235
|
+
for (let prev = ""; prev !== s; ) {
|
|
236
|
+
prev = s;
|
|
237
|
+
s = s.replace(/^\((?:\?:)?/, "").replace(/\)$/, "");
|
|
238
|
+
}
|
|
239
|
+
s = s.replace(DECL, "");
|
|
240
|
+
s = s.replace(/(?:\\?\(|\\?<|\s*\\s\*\\?\(|\s*\\s\*)$/, "");
|
|
241
|
+
s = s.replace(/^(?:\.\*|\\s\*|\\s\+)+|(?:\.\*|\\s\*|\\s\+)+$/g, "");
|
|
242
|
+
return s.trim();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The identifiers a pattern looks for, when it is a symbol lookup (`fooBar`, `\bFoo\b`,
|
|
247
|
+
* `fn parse_args`, `Foo::new`, `foo(`, `a|b`). Undefined for a text search (a sentence, a
|
|
248
|
+
* string literal, a path, an error message, `TODO`).
|
|
249
|
+
*/
|
|
250
|
+
export function symbolsIn(pattern: string | undefined): string[] | undefined {
|
|
251
|
+
if (pattern === undefined || pattern.length > 200) return undefined;
|
|
252
|
+
const alternatives = pattern.split(/\\?\|/);
|
|
253
|
+
const out: string[] = [];
|
|
254
|
+
for (const alt of alternatives) {
|
|
255
|
+
const s = normalizeAlternative(alt);
|
|
256
|
+
if (s.length < 3 || !QUALIFIED.test(s) || KEYWORDS.has(s)) return undefined;
|
|
257
|
+
const parts = s.split(/::|\.|#|->/);
|
|
258
|
+
if (parts.some((p) => NOT_SYMBOLS.has(p.toLowerCase()))) return undefined;
|
|
259
|
+
if (/^[A-Z0-9_]+$/.test(s) && s.length <= 4) return undefined;
|
|
260
|
+
out.push(s);
|
|
261
|
+
}
|
|
262
|
+
return out.length > 0 ? out : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Words a CodeGraph call looked up, for remembering what is already answered. */
|
|
266
|
+
export function lookupWords(params: Record<string, unknown>): string[] {
|
|
267
|
+
const out: string[] = [];
|
|
268
|
+
for (const key of ["query", "symbol"]) {
|
|
269
|
+
const v = params[key];
|
|
270
|
+
if (typeof v === "string") for (const w of v.split(/[^\w$:.#>-]+/)) if (w.length >= 3) out.push(...w.split(/::|\.|#|->/).filter((x) => x.length >= 3), w);
|
|
271
|
+
}
|
|
272
|
+
return out.map((w) => w.toLowerCase());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type Mode = "enforce" | "nudge" | "off";
|
|
276
|
+
|
|
277
|
+
export type Decision =
|
|
278
|
+
| { action: "allow"; override?: true }
|
|
279
|
+
| { action: "block"; reason: string }
|
|
280
|
+
| { action: "nudge"; hint: string };
|
|
281
|
+
|
|
282
|
+
export interface GateMemory {
|
|
283
|
+
/** Lower-cased words already looked up with CodeGraph this session. */
|
|
284
|
+
lookedUp: Set<string>;
|
|
285
|
+
/** Commands blocked once: running the same command again is the agent's explicit override. */
|
|
286
|
+
blocked: Set<string>;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set() });
|
|
290
|
+
|
|
291
|
+
const known = (symbol: string, mem: GateMemory) => {
|
|
292
|
+
const s = symbol.toLowerCase();
|
|
293
|
+
if (mem.lookedUp.has(s)) return true;
|
|
294
|
+
const last = s.split(/::|\.|#|->/).pop()!;
|
|
295
|
+
return mem.lookedUp.has(last);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export function suggestion(symbols: string[]): string {
|
|
299
|
+
const q = symbols.join(" ");
|
|
300
|
+
const one = symbols[0]!;
|
|
301
|
+
return [
|
|
302
|
+
`codegraph_search({ query: "${one}" }) for the definition`,
|
|
303
|
+
`codegraph_callers({ symbol: "${one}" }) for usages`,
|
|
304
|
+
`codegraph_explore({ query: "${q}" }) for the code around it`,
|
|
305
|
+
].join(", ");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What to do with a call to a text-search tool. `key` identifies the exact call (for the
|
|
310
|
+
* re-run override); `searches` are what it searches for.
|
|
311
|
+
*/
|
|
312
|
+
export function decide(mode: Mode, searches: Search[], key: string, mem: GateMemory): Decision {
|
|
313
|
+
if (mode === "off") return { action: "allow" };
|
|
314
|
+
const symbols = new Set<string>();
|
|
315
|
+
const names = new Set<string>();
|
|
316
|
+
for (const s of searches) {
|
|
317
|
+
if (targetsOnlyNonCode(s)) continue;
|
|
318
|
+
for (const sym of (s.files ? fileSymbolIn(s.pattern) : symbolsIn(s.pattern)) ?? []) if (!known(sym, mem)) (s.files ? names : symbols).add(sym);
|
|
319
|
+
}
|
|
320
|
+
if (symbols.size === 0 && names.size === 0) return { action: "allow" };
|
|
321
|
+
if (symbols.size === 0) {
|
|
322
|
+
const [one] = [...names];
|
|
323
|
+
return {
|
|
324
|
+
action: "nudge",
|
|
325
|
+
hint: `[codegraph] "${one}" looks like a symbol name: codegraph_search({ query: "${one}" }) finds where it is defined (file and line), codegraph_files lists the indexed files.`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const list = [...symbols];
|
|
329
|
+
const hint = `"${list.join('", "')}" ${list.length === 1 ? "looks like a symbol" : "look like symbols"}: CodeGraph answers this structurally. Use ${suggestion(list)}.`;
|
|
330
|
+
if (mode === "nudge") return { action: "nudge", hint: `[codegraph] ${hint}` };
|
|
331
|
+
if (mem.blocked.has(key)) return { action: "allow", override: true };
|
|
332
|
+
mem.blocked.add(key);
|
|
333
|
+
return {
|
|
334
|
+
action: "block",
|
|
335
|
+
reason: `CodeGraph first: ${hint} If CodeGraph cannot answer it (string literals, comments, generated or non-indexed files), run the exact same command again and it will be allowed.`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Source files: reading many of them before any CodeGraph call earns a hint. */
|
|
340
|
+
export const isSourceFile = (path: string): boolean => {
|
|
341
|
+
const ext = extOf(path);
|
|
342
|
+
return ext !== undefined && CODE_EXTENSIONS.has(ext);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const FD_VALUE_FLAGS = new Set(
|
|
346
|
+
"-e -t -E -d -x -X -S -c -j -g --extension --type --exclude --max-depth --min-depth --exact-depth --exec --exec-batch --size --changed-within --changed-before --owner --color --threads --max-results --base-directory --search-path --format --path-separator --batch-size --ignore-file".split(" "),
|
|
347
|
+
);
|
|
348
|
+
const FIND_NAME_FLAGS = new Set(["-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename"]);
|
|
349
|
+
|
|
350
|
+
/** File-name lookups in a shell command: `fd <pattern>`, `find … -name <glob>`. */
|
|
351
|
+
export function findFileSearches(command: string): Search[] {
|
|
352
|
+
const out: Search[] = [];
|
|
353
|
+
for (const { words } of splitPipeline(command)) {
|
|
354
|
+
const at = programIndex(words);
|
|
355
|
+
const prog = baseName(words[at] ?? "");
|
|
356
|
+
const args = words.slice(at + 1);
|
|
357
|
+
if (prog === "fd" || prog === "fdfind") {
|
|
358
|
+
const positional: string[] = [];
|
|
359
|
+
for (let i = 0; i < args.length; i++) {
|
|
360
|
+
const a = args[i]!;
|
|
361
|
+
if (!a.startsWith("-")) positional.push(a);
|
|
362
|
+
else if (FD_VALUE_FLAGS.has(a)) i++;
|
|
363
|
+
}
|
|
364
|
+
if (positional.length > 0) out.push({ tool: prog, pattern: positional[0], paths: positional.slice(1), filters: [], files: true });
|
|
365
|
+
} else if (prog === "find") {
|
|
366
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
367
|
+
if (FIND_NAME_FLAGS.has(args[i]!)) out.push({ tool: "find", pattern: args[i + 1], paths: [], filters: [], files: true });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The symbol a file-name pattern looks for (`*ParseConfig*`, `user_service.rs`,
|
|
376
|
+
* `^AuthController`), stricter than `symbolsIn`: a plain word like `config` names files, not
|
|
377
|
+
* symbols, so it needs a camelCase/PascalCase hump or snake_case (`Readme`, `Makefile` are file names).
|
|
378
|
+
*/
|
|
379
|
+
export function fileSymbolIn(pattern: string | undefined): string[] | undefined {
|
|
380
|
+
if (pattern === undefined) return undefined;
|
|
381
|
+
const base = baseName(pattern)
|
|
382
|
+
.replace(/^\^?\.\*|\.\*\$?$/g, "")
|
|
383
|
+
.replace(/^[*^]+|[*$]+$/g, "")
|
|
384
|
+
.replace(/\\?\.[A-Za-z0-9]{1,5}$/, "")
|
|
385
|
+
.replace(/^[*^]+|[*$]+$/g, "");
|
|
386
|
+
if (base.length < 4 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(base) || !/[a-z][A-Z]|[a-z0-9]_[a-z]/.test(base)) return undefined;
|
|
387
|
+
if (NOT_SYMBOLS.has(base.toLowerCase())) return undefined;
|
|
388
|
+
return [base];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const unquote = (quote: string, body: string): string => {
|
|
392
|
+
if (quote === '"') {
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(`"${body}"`);
|
|
395
|
+
} catch {
|
|
396
|
+
// not JSON-compatible: fall through to the plain unescape
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return body.replace(/\\(.)/g, "$1");
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const STRING = String.raw`(["'\`])((?:\\.|(?!\1)[^\\])*)\1`;
|
|
403
|
+
const CODEMODE_BASH = new RegExp(String.raw`tools\.bash\(\s*\{[^}]*?\bcommand\s*:\s*` + STRING, "g");
|
|
404
|
+
const CODEMODE_GREP = new RegExp(String.raw`tools\.grep\(\s*\{[^}]*?\bpattern\s*:\s*` + STRING, "g");
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Searches a codemode script makes through `tools.bash({ command: "…" })` and
|
|
408
|
+
* `tools.grep({ pattern: "…" })` with literal strings (a computed command is not knowable).
|
|
409
|
+
*/
|
|
410
|
+
export function codemodeSearches(code: string): Search[] {
|
|
411
|
+
const out: Search[] = [];
|
|
412
|
+
for (const m of code.matchAll(CODEMODE_BASH)) {
|
|
413
|
+
const command = unquote(m[1]!, m[2]!);
|
|
414
|
+
out.push(...findSearches(command), ...findFileSearches(command));
|
|
415
|
+
}
|
|
416
|
+
for (const m of code.matchAll(CODEMODE_GREP)) out.push({ tool: "grep", pattern: unquote(m[1]!, m[2]!), paths: [], filters: [] });
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A long-lived `codegraph serve --mcp` process per project, spoken to over stdio (JSON-RPC 2.0,
|
|
3
|
+
* one message per line).
|
|
4
|
+
*
|
|
5
|
+
* Long-lived on purpose: the server's file watcher keeps the index in sync while pi runs,
|
|
6
|
+
* and no call pays for a process start. A call that times out kills the process; the next
|
|
7
|
+
* call starts a fresh one.
|
|
8
|
+
*/
|
|
9
|
+
import { type ChildProcessWithoutNullStreams, spawn as nodeSpawn } from "node:child_process";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
/** The part of a child process the client uses (so tests can pass a fake). */
|
|
13
|
+
export interface ServerProcess {
|
|
14
|
+
stdin: { write(chunk: string): unknown };
|
|
15
|
+
stdout: { on(event: "data", fn: (chunk: Buffer | string) => void): unknown };
|
|
16
|
+
stderr: { on(event: "data", fn: (chunk: Buffer | string) => void): unknown };
|
|
17
|
+
on(event: "exit", fn: (code: number | null) => void): unknown;
|
|
18
|
+
on(event: "error", fn: (err: Error) => void): unknown;
|
|
19
|
+
kill(): unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type Spawner = (args: string[], cwd: string) => ServerProcess;
|
|
23
|
+
|
|
24
|
+
export const defaultSpawner: Spawner = (args, cwd) =>
|
|
25
|
+
nodeSpawn("codegraph", args, {
|
|
26
|
+
cwd,
|
|
27
|
+
env: process.env,
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
// npm installs a .cmd shim on Windows, which only a shell resolves.
|
|
31
|
+
shell: process.platform === "win32",
|
|
32
|
+
}) as ChildProcessWithoutNullStreams;
|
|
33
|
+
|
|
34
|
+
export interface McpResult {
|
|
35
|
+
content?: { type: string; text?: string }[];
|
|
36
|
+
isError?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const MAX_STDERR = 2000;
|
|
40
|
+
|
|
41
|
+
/** Remove ANSI colours and anything that looks like a secret from server diagnostics. */
|
|
42
|
+
export function sanitize(text: string): string {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
45
|
+
.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|APIKEY)[A-Z0-9_]*=)\S+/gi, "$1[redacted]")
|
|
46
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
47
|
+
.trim()
|
|
48
|
+
.slice(-MAX_STDERR);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The server process exited under a call (a crash, not a slow or failed query). */
|
|
52
|
+
export class ServerExitError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "ServerExitError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Pending {
|
|
60
|
+
resolve: (v: any) => void;
|
|
61
|
+
reject: (e: Error) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class CodeGraphServer {
|
|
65
|
+
private proc?: ServerProcess;
|
|
66
|
+
private ready?: Promise<void>;
|
|
67
|
+
private pending = new Map<number, Pending>();
|
|
68
|
+
private nextId = 1;
|
|
69
|
+
private buffer = "";
|
|
70
|
+
private stderr = "";
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
readonly root: string,
|
|
74
|
+
private readonly opts: { watch: boolean; timeoutMs: number; spawn?: Spawner },
|
|
75
|
+
) {}
|
|
76
|
+
|
|
77
|
+
get running(): boolean {
|
|
78
|
+
return this.proc !== undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private start(): Promise<void> {
|
|
82
|
+
const args = ["serve", "--mcp", "--path", this.root, ...(this.opts.watch ? [] : ["--no-watch"])];
|
|
83
|
+
const proc = (this.opts.spawn ?? defaultSpawner)(args, this.root);
|
|
84
|
+
this.proc = proc;
|
|
85
|
+
this.buffer = "";
|
|
86
|
+
this.stderr = "";
|
|
87
|
+
proc.stdout.on("data", (chunk) => this.onData(String(chunk)));
|
|
88
|
+
proc.stderr.on("data", (chunk) => {
|
|
89
|
+
this.stderr = (this.stderr + String(chunk)).slice(-MAX_STDERR);
|
|
90
|
+
});
|
|
91
|
+
proc.on("error", (err) => this.fail(proc, err));
|
|
92
|
+
proc.on("exit", (code) => this.fail(proc, new ServerExitError(sanitize(this.stderr) || `codegraph serve exited with code ${code}`)));
|
|
93
|
+
const rootUri = pathToFileURL(this.root).href;
|
|
94
|
+
return this.request("initialize", {
|
|
95
|
+
protocolVersion: "2024-11-05",
|
|
96
|
+
rootUri,
|
|
97
|
+
workspaceFolders: [{ uri: rootUri, name: this.root.split(/[\\/]/).pop() || this.root }],
|
|
98
|
+
capabilities: {},
|
|
99
|
+
clientInfo: { name: "pi-graphnav", version: "1" },
|
|
100
|
+
}).then(() => {
|
|
101
|
+
this.send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The process died or could not start: fail what waits on it and forget it. */
|
|
106
|
+
private fail(proc: ServerProcess, err: Error) {
|
|
107
|
+
if (this.proc !== proc) return;
|
|
108
|
+
this.proc = undefined;
|
|
109
|
+
this.ready = undefined;
|
|
110
|
+
for (const p of this.pending.values()) p.reject(err);
|
|
111
|
+
this.pending.clear();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private onData(chunk: string) {
|
|
115
|
+
this.buffer += chunk;
|
|
116
|
+
let nl: number;
|
|
117
|
+
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
118
|
+
const line = this.buffer.slice(0, nl).trim();
|
|
119
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
let msg: any;
|
|
122
|
+
try {
|
|
123
|
+
msg = JSON.parse(line);
|
|
124
|
+
} catch {
|
|
125
|
+
continue; // a log line on stdout
|
|
126
|
+
}
|
|
127
|
+
const p = typeof msg?.id === "number" ? this.pending.get(msg.id) : undefined;
|
|
128
|
+
if (!p) continue;
|
|
129
|
+
this.pending.delete(msg.id);
|
|
130
|
+
if (msg.error) p.reject(new Error(String(msg.error.message ?? JSON.stringify(msg.error))));
|
|
131
|
+
else p.resolve(msg.result);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private send(payload: unknown) {
|
|
136
|
+
this.proc?.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private request(method: string, params: unknown): Promise<any> {
|
|
140
|
+
const id = this.nextId++;
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
this.pending.set(id, { resolve, reject });
|
|
143
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Call a CodeGraph tool. Times out after `timeoutMs` (the process is then restarted on the next call). */
|
|
148
|
+
async call(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<McpResult> {
|
|
149
|
+
if (signal?.aborted) throw new Error("Aborted");
|
|
150
|
+
this.ready ??= this.start().catch((e: unknown) => {
|
|
151
|
+
this.close();
|
|
152
|
+
throw e;
|
|
153
|
+
});
|
|
154
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
155
|
+
let onAbort: (() => void) | undefined;
|
|
156
|
+
let id: number | undefined;
|
|
157
|
+
const guard = new Promise<never>((_, reject) => {
|
|
158
|
+
timer = setTimeout(() => {
|
|
159
|
+
this.close();
|
|
160
|
+
reject(new Error(`CodeGraph did not answer within ${Math.round(this.opts.timeoutMs / 1000)}s. If this repeats, run "codegraph unlock" in ${this.root}.`));
|
|
161
|
+
}, this.opts.timeoutMs);
|
|
162
|
+
onAbort = () => reject(new Error("Aborted"));
|
|
163
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
const answer = this.ready.then(() => {
|
|
167
|
+
if (signal?.aborted) throw new Error("Aborted");
|
|
168
|
+
id = this.nextId;
|
|
169
|
+
return this.request("tools/call", { name, arguments: args });
|
|
170
|
+
});
|
|
171
|
+
return await Promise.race([answer, guard]);
|
|
172
|
+
} finally {
|
|
173
|
+
// An abandoned request (timeout, abort) must not wait in `pending` for an answer nobody reads.
|
|
174
|
+
if (id !== undefined) this.pending.delete(id);
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Stop the process. Idempotent. */
|
|
181
|
+
close() {
|
|
182
|
+
const proc = this.proc;
|
|
183
|
+
if (!proc) return;
|
|
184
|
+
this.fail(proc, new Error("CodeGraph server closed"));
|
|
185
|
+
try {
|
|
186
|
+
proc.kill();
|
|
187
|
+
} catch {
|
|
188
|
+
// already gone
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|