/** * Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify). * * Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT. * * Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify ` * and `graphify update ` produce the SAME AST graph with NO LLM and NO * network (verified against graphify 0.9.23 — both emit `AST extraction on N * code files`, all node origins `ast`). The LLM backend (openai/gemini) is only * used to RENAME community clusters (`graphify label` / `cluster-only`) and to * ingest non-code docs (`graphify add`); it adds zero nodes/edges, and our parser * discards the `community=` field it touches — so an LLM mode would send code * off-machine for no change in search output, and is intentionally not offered. * * This adapter uses `graphify update ` (writes `/graphify-out/graph.json` * and does clustering in one shot) and stays fully local — nothing leaves the * machine, so `local = true` and no egress consent is needed. * * Query is `graphify query "" --graph /graphify-out/graph.json`; the * `--graph` flag points at the built graph so search never depends on cwd. * * Never auto-installed: install is `pip install graphifyy && graphify install` * (needs Python >= 3.10), a user action the picker surfaces. When the CLI is * absent every op throws PROVIDER_UNAVAILABLE and callers degrade to file-only. * * Path-based, not id-based: for Graphify a source "id" IS the absolute repo path * (that is where `graphify-out/` lives), unlike GBrain's short source ids. */ import { spawnSync } from "child_process"; import { existsSync, readFileSync, statSync } from "fs"; import { join } from "path"; import { assertCapability, assertRequiredCapabilities, CodeProviderError, type CodeProvider, type CodeProviderCapability, type CodeSearchHit, type OpOptions, type RepoRef, type SearchOptions, type SourceRef, type SourceStatus, } from "./contract"; const CAPABILITIES: CodeProviderCapability[] = ["register_source", "refresh", "search", "status", "export"]; const OUT_DIR = "graphify-out"; const GRAPH_JSON = "graph.json"; const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows /** * status() only parses graph.json for a node count when the file is at most * this big. On the 1000+-file repos this feature targets, graph.json can run * to hundreds of MB — JSON.parsing that for a cosmetic count is a heap spike. * Above the threshold the display reports the file size instead. */ const STATUS_PARSE_MAX_BYTES = 5 * 1024 * 1024; export interface GraphifyOptions { /** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */ root?: string; env?: NodeJS.ProcessEnv; } export class GraphifyProvider implements CodeProvider { readonly id = "graphify" as const; readonly label = "Graphify"; readonly capabilities = new Set(CAPABILITIES); /** Fully local — no repo content leaves the machine. */ readonly local = true; readonly #root: string; readonly #env?: NodeJS.ProcessEnv; constructor(opts: GraphifyOptions = {}) { this.#root = opts.root ?? process.cwd(); this.#env = opts.env; assertRequiredCapabilities(this.id, this.capabilities); } has(capability: CodeProviderCapability): boolean { return this.capabilities.has(capability); } #run(args: string[], cwd: string, timeout: number) { return spawnSync("graphify", args, { cwd, encoding: "utf-8", timeout, stdio: ["ignore", "pipe", "pipe"], env: this.#env, shell: NEEDS_SHELL_ON_WINDOWS, }); } #assertOk(r: { status: number | null; stderr?: string; error?: Error & { code?: string }; signal?: NodeJS.Signals | null }): void { if (r.status === 0) return; const stderr = (r.stderr || "").trim(); if (r.error?.code === "ENOENT" || /command not found/.test(stderr)) { throw new CodeProviderError("PROVIDER_UNAVAILABLE", "graphify CLI not on PATH (install: pip install graphifyy && graphify install)", this.id); } if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") { throw new CodeProviderError("PROVIDER_TIMEOUT", "graphify timed out", this.id); } throw new CodeProviderError("PROVIDER_ERROR", stderr || `graphify exited ${r.status}`, this.id); } /** Build the graph over repo.path locally (no LLM, no egress consent needed). */ async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise { this.#assertOk(this.#run(["update", repo.path], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS)); return this.status({ id: repo.path }, opts); } /** Re-parse and rebuild the graph (same local `graphify update` path). */ async refresh(source: SourceRef, opts: OpOptions = {}): Promise { this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS)); return this.status(source, opts); } /** * `graphify query "" --graph ` traces the graph and prints * `NODE ...` / `EDGE ...` lines (plus a `Traversal:` header). We pass `--graph` * explicitly so the query reads the indexed repo's graph regardless of cwd. */ async search(query: string, opts: SearchOptions = {}): Promise { if (!query.trim()) return []; const root = opts.source ?? this.#root; const graphPath = join(root, OUT_DIR, GRAPH_JSON); const r = this.#run(["query", query, "--graph", graphPath], root, opts.timeout ?? DEFAULT_TIMEOUT_MS); this.#assertOk(r); return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10); } async status(source?: SourceRef, _opts: OpOptions = {}): Promise { const dir = source?.id ?? this.#root; const graphPath = join(dir, OUT_DIR, GRAPH_JSON); if (!existsSync(graphPath)) return { id: dir, state: "absent" }; let itemCount: number | undefined; let detail = graphPath; try { // stat first: parse the whole graph only when it's small (the node count // is display-only, never worth a hundreds-of-MB JSON.parse heap spike). const size = statSync(graphPath).size; if (size <= STATUS_PARSE_MAX_BYTES) { const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] }; if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length; } else { detail = `${graphPath} (${(size / (1024 * 1024)).toFixed(1)} MB graph; node count skipped)`; } } catch { // graph.json present but unstatable/unparseable — still ready, just no count. } return { id: dir, state: "ready", itemCount, detail }; } async export(source: SourceRef, _opts: OpOptions = {}): Promise { assertCapability(this, "export"); const graphPath = join(source.id, OUT_DIR, GRAPH_JSON); if (!existsSync(graphPath)) { throw new CodeProviderError("SOURCE_NOT_REGISTERED", `no graph at ${graphPath}; index it first`, this.id); } return readFileSync(graphPath, "utf-8"); } } /** * Parse real `graphify query` output into hits. The format (graphify 0.9.23): * Traversal: BFS depth=2 | Start: ['query()'] | ... | 4 nodes found * NODE query() [src=db.py loc=L4 community=login] * EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8 * The file lives mid-line (`src= loc=L` on NODE, `at=:L` on * EDGE), so the ref is `:L`. The `Traversal:` header and any other line * are skipped. Exported for deterministic unit testing against the real format. */ export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] { const hits: CodeSearchHit[] = []; for (const raw of stdout.split("\n")) { const line = raw.trim(); let ref: string | undefined; const node = line.match(/^NODE\b.*?\[src=(\S+)\s+loc=(L\d+)/); const edge = line.match(/^EDGE\b.*?\bat=(\S+?):(L\d+)\b/); if (node) ref = `${node[1]}:${node[2]}`; else if (edge) ref = `${edge[1]}:${edge[2]}`; else continue; // skip the Traversal header and anything non-NODE/EDGE hits.push({ ref, snippet: line, kind: "graph-node" }); if (hits.length >= limit) break; } return hits; } /** Whether the `graphify` CLI is installed (for the picker's availability probe). */ export function graphifyInstalled(env?: NodeJS.ProcessEnv): boolean { const r = spawnSync("graphify", ["--version"], { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "ignore", "ignore"], env, shell: NEEDS_SHELL_ON_WINDOWS, }); return r.status === 0; }