pi-graphnav 0.2.0 → 0.2.2

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
@@ -33,6 +33,13 @@ A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
33
33
  phrases, string literals or in docs and config (`*.md`, `--type yaml`, …) are never touched,
34
34
  nor symbols already looked up with CodeGraph. Searches a codemode script makes with literal
35
35
  `tools.bash({ command })` / `tools.grep({ pattern })` are gated the same way.
36
+ A pattern only counts as a symbol when it has code shape (`parseConfig`, `ParseConfig`,
37
+ `start_scan`, `Foo::new`) or symbol syntax (`fn summary`, `summary(`); plain words like
38
+ `summary`, `cache` or `Success` are text. Before blocking, the gate asks the index (one
39
+ `codegraph_search`, 2 s limit, fails open) and lets the search through when the index does
40
+ not define the name. When it does, the block message already says where (`path:line
41
+ (kind)`), so one blocked call still answers the question. A search inside named files
42
+ (`rg start_scan src/app.rs`) is only nudged: the agent has already found the code.
36
43
  * **Nudges file-name lookups.** `fd UserService`, `find . -name '*ParseConfig*'` and pi's `find`
37
44
  tool with a camelCase or snake_case name run, with a hint to `codegraph_search` (never blocked).
38
45
  * **Hints after blind reading.** Reading several source files before any CodeGraph call adds
@@ -228,7 +228,7 @@ const QUALIFIED = /^[A-Za-z_$][\w$]*(?:(?:::|\.|#|->)[A-Za-z_$][\w$]*)*$/;
228
228
  const NOT_SYMBOLS = new Set(["todo", "fixme", "hack", "xxx", "note", "bug", "deprecated", "error", "warn", "warning", "info", "debug"]);
229
229
 
230
230
  /** Strip regex syntax that does not change what is being looked for. */
231
- function normalizeAlternative(raw: string): string {
231
+ function normalizeAlternative(raw: string): { s: string; declared: boolean } {
232
232
  let s = raw.trim();
233
233
  s = s.replace(/\\[bB<>]/g, "").replace(/\[\[:<:\]\]|\[\[:>:\]\]/g, "");
234
234
  s = s.replace(/^\^/, "").replace(/\$$/, "");
@@ -236,10 +236,23 @@ function normalizeAlternative(raw: string): string {
236
236
  prev = s;
237
237
  s = s.replace(/^\((?:\?:)?/, "").replace(/\)$/, "");
238
238
  }
239
+ const bare = s;
239
240
  s = s.replace(DECL, "");
241
+ const declared = s !== bare;
240
242
  s = s.replace(/(?:\\?\(|\\?<|\s*\\s\*\\?\(|\s*\\s\*)$/, "");
243
+ const called = s !== bare && !declared && /\(\s*$|\\\(/.test(bare);
241
244
  s = s.replace(/^(?:\.\*|\\s\*|\\s\+)+|(?:\.\*|\\s\*|\\s\+)+$/g, "");
242
- return s.trim();
245
+ return { s: s.trim(), declared: declared || called };
246
+ }
247
+
248
+ /**
249
+ * Does a bare identifier look like code rather than a word? A plain `summary`, `cache`,
250
+ * `Success` or `ERROR` is as likely prose or a log string as a symbol, so it only counts when
251
+ * the search says "symbol": a declaration keyword (`fn summary`) or call syntax (`summary(`).
252
+ * Otherwise it needs shape: camelCase/PascalCase humps, an inner underscore, or a qualifier.
253
+ */
254
+ export function isSymbolShaped(s: string): boolean {
255
+ return /::|\.|#|->/.test(s) || /[a-z0-9][A-Z]/.test(s) || /^[A-Z][a-z0-9]+[A-Z]/.test(s) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(s);
243
256
  }
244
257
 
245
258
  /**
@@ -252,11 +265,13 @@ export function symbolsIn(pattern: string | undefined): string[] | undefined {
252
265
  const alternatives = pattern.split(/\\?\|/);
253
266
  const out: string[] = [];
254
267
  for (const alt of alternatives) {
255
- const s = normalizeAlternative(alt);
268
+ const { s, declared } = normalizeAlternative(alt);
256
269
  if (s.length < 3 || !QUALIFIED.test(s) || KEYWORDS.has(s)) return undefined;
270
+ if (!declared && !isSymbolShaped(s)) return undefined;
257
271
  const parts = s.split(/::|\.|#|->/);
258
272
  if (parts.some((p) => NOT_SYMBOLS.has(p.toLowerCase()))) return undefined;
259
273
  if (/^[A-Z0-9_]+$/.test(s) && s.length <= 4) return undefined;
274
+ if (/^_+$|^[a-z]+_$/.test(s)) return undefined;
260
275
  out.push(s);
261
276
  }
262
277
  return out.length > 0 ? out : undefined;
@@ -284,21 +299,64 @@ export interface GateMemory {
284
299
  lookedUp: Set<string>;
285
300
  /** Commands blocked once: running the same command again is the agent's explicit override. */
286
301
  blocked: Set<string>;
302
+ /** Lower-cased words the index was asked about and does not define: text, not symbols. */
303
+ unindexed: Set<string>;
304
+ /** Lower-cased leaf → where the index defines it (`path:line (kind)`), from the gate's probe. */
305
+ defined: Map<string, string[]>;
287
306
  }
288
307
 
289
- export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set() });
308
+ export const newMemory = (): GateMemory => ({ lookedUp: new Set(), blocked: new Set(), unindexed: new Set(), defined: new Map() });
309
+
310
+ /** Last segment of a qualified name, lower-cased: what the index is keyed by. */
311
+ export const leafOf = (symbol: string): string => symbol.toLowerCase().split(/::|\.|#|->/).pop()!;
290
312
 
291
313
  const known = (symbol: string, mem: GateMemory) => {
292
314
  const s = symbol.toLowerCase();
315
+ if (mem.unindexed.has(leafOf(s))) return true;
293
316
  if (mem.lookedUp.has(s)) return true;
294
317
  const last = s.split(/::|\.|#|->/).pop()!;
295
318
  return mem.lookedUp.has(last);
296
319
  };
297
320
 
321
+ /**
322
+ * Does a `codegraph_search` answer define `symbol`? Reads the `**name** (kind)` headings and
323
+ * compares the last path segment, case-insensitively.
324
+ */
325
+ export function indexDefines(searchText: string, symbol: string): boolean {
326
+ return definitionsIn(searchText, symbol).length > 0;
327
+ }
328
+
329
+ /** Where a `codegraph_search` answer defines `symbol`: `path:line (kind)`, at most `max`. */
330
+ export function definitionsIn(searchText: string, symbol: string, max = 5): string[] {
331
+ const leaf = leafOf(symbol);
332
+ const out: string[] = [];
333
+ for (const m of searchText.matchAll(/^\*\*(.+?)\*\* \(([^)\n]*)\)[ \t]*\n([^\n`*]*)/gm)) {
334
+ if (m[1]!.toLowerCase() !== leaf) continue;
335
+ const at = m[3]!.trim();
336
+ out.push(at ? `${at} (${m[2]})` : `(${m[2]})`);
337
+ if (out.length >= max) break;
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /** Does every search name only files (not directories), so it reads code already pinned down? */
343
+ const onlyNamedFiles = (s: Search): boolean => s.paths.length > 0 && s.paths.every((p) => extOf(p) !== undefined && !/[*?{[]/.test(p));
344
+
345
+ /** Symbols a search set would be gated on (not yet looked up, not known to be text). */
346
+ export function candidateSymbols(searches: Search[], mem: GateMemory): string[] {
347
+ const out = new Set<string>();
348
+ for (const s of searches) {
349
+ if (s.files || targetsOnlyNonCode(s)) continue;
350
+ for (const sym of symbolsIn(s.pattern) ?? []) if (!known(sym, mem)) out.add(sym);
351
+ }
352
+ return [...out];
353
+ }
354
+
298
355
  export function suggestion(symbols: string[]): string {
299
356
  const q = symbols.join(" ");
300
357
  const one = symbols[0]!;
301
358
  return [
359
+ `codegraph_node({ symbol: "${one}", includeCode: true }) for its source`,
302
360
  `codegraph_search({ query: "${one}" }) for the definition`,
303
361
  `codegraph_callers({ symbol: "${one}" }) for usages`,
304
362
  `codegraph_explore({ query: "${q}" }) for the code around it`,
@@ -313,9 +371,14 @@ export function decide(mode: Mode, searches: Search[], key: string, mem: GateMem
313
371
  if (mode === "off") return { action: "allow" };
314
372
  const symbols = new Set<string>();
315
373
  const names = new Set<string>();
374
+ let scoped = true;
316
375
  for (const s of searches) {
317
376
  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);
377
+ for (const sym of (s.files ? fileSymbolIn(s.pattern) : symbolsIn(s.pattern)) ?? []) {
378
+ if (known(sym, mem)) continue;
379
+ (s.files ? names : symbols).add(sym);
380
+ if (!s.files && !onlyNamedFiles(s)) scoped = false;
381
+ }
319
382
  }
320
383
  if (symbols.size === 0 && names.size === 0) return { action: "allow" };
321
384
  if (symbols.size === 0) {
@@ -326,8 +389,14 @@ export function decide(mode: Mode, searches: Search[], key: string, mem: GateMem
326
389
  };
327
390
  }
328
391
  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}` };
392
+ const where = list.flatMap((sym) => {
393
+ const at = mem.defined.get(leafOf(sym));
394
+ return at && at.length > 0 ? [`${sym} is defined at ${at.join(", ")}`] : [];
395
+ });
396
+ const found = where.length > 0 ? ` ${where.join("; ")}.` : "";
397
+ const hint = `"${list.join('", "')}" ${list.length === 1 ? "looks like a symbol" : "look like symbols"}: CodeGraph answers this structurally.${found} Use ${suggestion(list)}.`;
398
+ // A search inside named files reads code the agent already pinned down: hint, do not block.
399
+ if (mode === "nudge" || scoped) return { action: "nudge", hint: `[codegraph] ${hint}` };
331
400
  if (mem.blocked.has(key)) return { action: "allow", override: true };
332
401
  mem.blocked.add(key);
333
402
  return {
@@ -114,7 +114,7 @@ export const isCodeGraphTool = (name: string): boolean => TOOL_NAMES.includes(na
114
114
 
115
115
  /** Guidelines added to the system prompt while the tools are active (static: they never break the prompt cache). */
116
116
  export const GUIDELINES = [
117
- "Understand code through CodeGraph first: codegraph_explore for how something works or where to change it, codegraph_search to find a definition, codegraph_callers/codegraph_callees for relations, codegraph_impact before changing a signature, codegraph_files for structure.",
117
+ "Understand code through CodeGraph first: codegraph_explore for how something works or where to change it, codegraph_search to find a definition, codegraph_node with includeCode for a symbol's body (instead of rg -A), codegraph_callers/codegraph_callees for relations, codegraph_impact before changing a signature, codegraph_files for structure.",
118
118
  "Do not grep (rg, grep, git grep) for symbol names: such searches are redirected to CodeGraph. Text search is for string literals, comments, config and docs, or after CodeGraph came up empty.",
119
119
  "Read a file once CodeGraph has told you where to look, with offset/limit around the reported lines.",
120
120
  ];
@@ -17,10 +17,13 @@ import { completions, parseCommand } from "./command/parse.ts";
17
17
  import { type Config, DEFAULT_CONFIG, type ProjectOverrides, readConfig, readProjectConfig, saveConfig } from "./core/config.ts";
18
18
  import {
19
19
  codemodeSearches,
20
+ candidateSymbols,
20
21
  decide,
21
22
  findFileSearches,
22
23
  findSearches,
23
24
  type GateMemory,
25
+ definitionsIn,
26
+ leafOf,
24
27
  isSourceFile,
25
28
  lookupWords,
26
29
  newMemory,
@@ -131,6 +134,15 @@ export interface Deps {
131
134
  breaker?: { threshold: number; resetMs: number };
132
135
  }
133
136
 
137
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T> =>
138
+ new Promise((resolve, reject) => {
139
+ const t = setTimeout(() => reject(new Error("timeout")), ms);
140
+ p.then(
141
+ (v) => (clearTimeout(t), resolve(v)),
142
+ (e) => (clearTimeout(t), reject(e)),
143
+ );
144
+ });
145
+
134
146
  export function createExtension(deps: Deps = {}) {
135
147
  const run = deps.run ?? defaultRunner;
136
148
  return function (pi: ExtensionAPI) {
@@ -198,6 +210,9 @@ export function createExtension(deps: Deps = {}) {
198
210
  index = status;
199
211
  lastError = error;
200
212
  state = error ? "error" : status.initialized ? "ready" : "missing";
213
+ // the index changed: earlier probe answers may be stale
214
+ mem.unindexed.clear();
215
+ mem.defined.clear();
201
216
  })
202
217
  .finally(() => {
203
218
  job = undefined;
@@ -300,6 +315,24 @@ export function createExtension(deps: Deps = {}) {
300
315
  if (!config.enabled || config.mode === "off" || state !== "ready" || unavailable()) return;
301
316
  const searches = searchesOf(event.toolName, event.input ?? {});
302
317
  if (searches.length === 0) return;
318
+ // Gate only what the index defines: a word it has never heard of is text, not a symbol.
319
+ const root = project?.root;
320
+ if (root) {
321
+ const unknown = candidateSymbols(searches, mem).filter((sym) => !mem.defined.has(leafOf(sym)));
322
+ await Promise.all(
323
+ unknown.map(async (sym) => {
324
+ try {
325
+ const res = await withTimeout(server(root).call("codegraph_search", { query: leafOf(sym), limit: 25, projectPath: root }), 2000);
326
+ if (res.isError) return;
327
+ const at = definitionsIn(resultText(res), sym);
328
+ if (at.length === 0) mem.unindexed.add(leafOf(sym));
329
+ else mem.defined.set(leafOf(sym), at);
330
+ } catch {
331
+ // index unreachable or slow: fail open to the usual gate rather than guess
332
+ }
333
+ }),
334
+ );
335
+ }
303
336
  const d = decide(config.mode, searches, `${event.toolName}\u0000${JSON.stringify(event.input)}`, mem);
304
337
  if (d.action === "block") {
305
338
  counts.blocked++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-graphnav",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CodeGraph-first code navigation for pi: persistent index, auto init/sync, and a gate that steers structural searches to codegraph_* tools.",
5
5
  "keywords": [
6
6
  "pi-package",