mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
feat: code-intelligence contract with real GBrain/Graphify/Sourcebot adapters
contract.ts: repo-oriented interface (four required ops, three optional), typed CodeProviderError, egress + capability consent guards. Three real, runtime-drivable adapters: - GbrainProvider: gbrain CLI (reuses lib/gbrain-exec + lib/gbrain-sources), all seven capabilities. - GraphifyProvider: graphify CLI — `graphify <dir>` builds the local graph, `graphify query` searches it, export reads graphify-out/graph.json. Fully local; never auto-installed. - SourcebotProvider: self-hosted server over HTTP — register writes a local git connection to config.json, search is POST /api/search, status is a liveness probe. Loopback base URL = local (no egress); remote = consent. selection.ts persists the chosen provider + per-repo indexing consent under $GSTACK_HOME. picker.ts recommends GBrain first, resolves the selected provider or null (provider-OFF), and probes live availability. Every adapter degrades to PROVIDER_UNAVAILABLE when its tool/server is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b4421a4e69
commit
3eb4a1b23f
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||
*
|
||||
* Graphify is a LOCAL tree-sitter knowledge graph: `graphify <dir>` builds a
|
||||
* `graphify-out/` (graph.json + report) in that dir, `graphify query "<q>"`
|
||||
* queries it, and nothing leaves the machine (no embeddings, no network). So it
|
||||
* needs no egress consent and no MCP — the runtime just shells out to the CLI,
|
||||
* the same shape as the GBrain adapter.
|
||||
*
|
||||
* Never auto-installed: install is `pip install graphifyy && graphify install`,
|
||||
* a user action the picker gates on. 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 } 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
|
||||
|
||||
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<CodeProviderCapability>(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 (local; no egress consent needed). */
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
this.#assertOk(this.#run(["."], repo.path, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||
return this.status({ id: repo.path }, opts);
|
||||
}
|
||||
|
||||
/** Re-parse and merge changes into the existing graph. */
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
this.#assertOk(this.#run([".", "--update"], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||
return this.status(source, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* `graphify query "<q>"` returns a traced answer over the graph. Its stdout is
|
||||
* an answer, not a fixed hit schema (the CLI reference documents the command
|
||||
* but not a machine format), so we map non-empty output lines to hits
|
||||
* tolerantly: a leading path-like token becomes the ref, the line the snippet.
|
||||
* Reconcile against a live graphify if a stricter schema is needed.
|
||||
*/
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
const cwd = opts.source ?? this.#root;
|
||||
const r = this.#run(["query", query], cwd, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
this.#assertOk(r);
|
||||
return parseGraphifyQuery(r.stdout || "", opts.limit ?? 10);
|
||||
}
|
||||
|
||||
async status(source?: SourceRef, _opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
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;
|
||||
try {
|
||||
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
|
||||
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
|
||||
} catch {
|
||||
// graph.json present but unparseable — still ready, just no count.
|
||||
}
|
||||
return { id: dir, state: "ready", itemCount, detail: graphPath };
|
||||
}
|
||||
|
||||
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `graphify query` stdout to hits. Tolerant: each non-empty line becomes a
|
||||
* hit; a leading `path` or `path:line` token becomes the ref, else the whole
|
||||
* line is the snippet. Exported for deterministic unit testing.
|
||||
*/
|
||||
export function parseGraphifyQuery(stdout: string, limit: number): CodeSearchHit[] {
|
||||
const hits: CodeSearchHit[] = [];
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const token = line.split(/\s+/)[0];
|
||||
const looksPath = /[/\\.]/.test(token) && !token.includes(" ");
|
||||
hits.push({ ref: looksPath ? token : "graphify", snippet: line, kind: "graph-node" });
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
Reference in New Issue
Block a user