mirror of https://github.com/garrytan/gstack.git
feat(code-intelligence): provider contract Phase 1 — GBrain, Sourcebot, Graphify behind one ask-once offer
Open a large repo (1,000+ tracked files) and gstack can offer code intelligence ONCE, with the trade-offs stated: GBrain (semantic memory + code, sends content to YOUR gbrain DB, per-repo consent), Sourcebot (self-hosted whole-repo search, local on localhost), Graphify (local tree-sitter graph, nothing leaves the machine, user-installed), or No indexing — a decline persists machine-wide so no skill ever asks again. Small repos never see the question; grep stays the always-working default and provider-OFF degrades silently (PROVIDER_UNAVAILABLE -> file-only). Ported: lib/code-intelligence/ (contract + 3 verified adapters + picker + selection + suggest, MIT headers), the gstack-code-intelligence CLI (suggest/select/consent/index/search/status), 31 offline tests (fake CLI shims + injected fetch), and the provider-contract design doc. Verified live on this repo: suggest fires at 1,233 files with real availability detail per provider. Hardened per review: the per-remote trust store is the SINGLE consent authority — a gstack-gbrain-repo-policy deny tier vetoes any recorded code-intelligence consent (fail-closed on an unreadable store, pinned by three tests); both send-capable adapters are registered as fail-closed MODULE_SINKS in the egress tripwire so a refactor can't drop their receipts; and local-compute vs remote-send consents are never bundled. setup-gbrain gains the provider-choice Step 0. The fork's Phases 2-4 glue-collapse is explicitly NOT ported. Ported from time-attack/gstack (GStack 2); consent unification ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
53deeeb116
commit
3aba218303
|
|
@ -0,0 +1,206 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* gstack-code-intelligence — pick a code-intelligence provider and use it to
|
||||
* index and search this repo. OPTIONAL: with nothing selected, gstack works
|
||||
* fine and callers use grep / the file-only decision store.
|
||||
*
|
||||
* Usage:
|
||||
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
|
||||
* gstack-code-intelligence options # list providers (GBrain first) + availability
|
||||
* gstack-code-intelligence status # current selection + availability
|
||||
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
|
||||
* gstack-code-intelligence consent [repo-path] # allow indexing this repo (per-repo)
|
||||
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
|
||||
* gstack-code-intelligence search <query...> # search via the selected provider
|
||||
*
|
||||
* Non-local providers (GBrain, or a Sourcebot on a remote host) refuse to index
|
||||
* until you consent for that repo. Graphify and a localhost Sourcebot are local:
|
||||
* nothing leaves the machine, so no consent is needed. Graphify is never
|
||||
* auto-installed.
|
||||
*/
|
||||
|
||||
import { basename, resolve } from "path";
|
||||
import {
|
||||
CodeProviderError,
|
||||
RECOMMENDED_ORDER,
|
||||
detectAvailable,
|
||||
hasConsent,
|
||||
providerById,
|
||||
readSelection,
|
||||
resolveSelectedProvider,
|
||||
setConsent,
|
||||
setProvider,
|
||||
setRoot,
|
||||
shouldOfferIndexing,
|
||||
type CodeProviderId,
|
||||
} from "../lib/code-intelligence";
|
||||
|
||||
const PROVIDER_IDS = new Set<CodeProviderId>(["gbrain", "sourcebot", "graphify"]);
|
||||
const LABEL: Record<CodeProviderId, string> = { gbrain: "GBrain", sourcebot: "Sourcebot", graphify: "Graphify" };
|
||||
const NOTE: Record<CodeProviderId, string> = {
|
||||
gbrain: "recommended; federated memory + code (sends content to your GBrain DB)",
|
||||
sourcebot: "self-hosted whole-repo regex search (local when on localhost)",
|
||||
graphify: "local tree-sitter code graph, nothing leaves the machine (install it yourself)",
|
||||
};
|
||||
|
||||
function out(s: string): void {
|
||||
process.stdout.write(`${s}\n`);
|
||||
}
|
||||
function fail(s: string): never {
|
||||
process.stderr.write(`gstack-code-intelligence: ${s}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdOptions(): Promise<void> {
|
||||
out("Code-intelligence providers (indexing is optional; GBrain recommended):\n");
|
||||
const avail = await detectAvailable();
|
||||
const byId = new Map(avail.map((a) => [a.id, a]));
|
||||
for (const id of RECOMMENDED_ORDER) {
|
||||
const a = byId.get(id);
|
||||
const mark = a?.available ? "available" : "not available";
|
||||
out(` ${id === "gbrain" ? "*" : " "} ${LABEL[id].padEnd(10)} [${mark}] — ${NOTE[id]}`);
|
||||
if (a?.detail) out(` ${a.detail}`);
|
||||
}
|
||||
out("\nSelect one with: gstack-code-intelligence select <provider>");
|
||||
}
|
||||
|
||||
async function cmdStatus(): Promise<void> {
|
||||
const sel = readSelection();
|
||||
out(`selected: ${sel.provider ?? "none (grep / file-only fallback)"}`);
|
||||
const avail = await detectAvailable();
|
||||
for (const a of avail) out(` ${LABEL[a.id]}: ${a.available ? "available" : "unavailable"} (${a.detail})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-time session-start offer gate. Prints (or emits as JSON) whether an
|
||||
* agent should ask the user about indexing this repo, and when it should, the
|
||||
* provider options with their reasons so the question is self-contained.
|
||||
*/
|
||||
async function cmdSuggest(rest: string[]): Promise<void> {
|
||||
const json = rest.includes("--json");
|
||||
const pathArg = rest.find((a) => !a.startsWith("--"));
|
||||
const repoPath = resolve(pathArg ?? process.cwd());
|
||||
const suggestion = shouldOfferIndexing(repoPath);
|
||||
if (!suggestion.offer) {
|
||||
if (json) {
|
||||
out(JSON.stringify({ ...suggestion, repoPath }));
|
||||
} else {
|
||||
out(`no offer (${suggestion.reason}${suggestion.fileCount != null ? `, ${suggestion.fileCount} tracked files` : ""})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const avail = await detectAvailable();
|
||||
if (json) {
|
||||
out(JSON.stringify({
|
||||
...suggestion,
|
||||
repoPath,
|
||||
options: avail.map((a) => ({
|
||||
id: a.id,
|
||||
label: LABEL[a.id],
|
||||
reason: NOTE[a.id],
|
||||
local: providerById(a.id).local,
|
||||
available: a.available,
|
||||
detail: a.detail,
|
||||
})),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
out(`offer indexing: ${suggestion.fileCount} tracked files (threshold ${suggestion.threshold}) and no prior decision`);
|
||||
await cmdOptions();
|
||||
}
|
||||
|
||||
function cmdSelect(arg: string | undefined): void {
|
||||
if (arg === "none") {
|
||||
setProvider(null);
|
||||
out("code-intelligence declined; gstack uses grep / file-only fallback and will not ask again");
|
||||
return;
|
||||
}
|
||||
if (!arg || !PROVIDER_IDS.has(arg as CodeProviderId)) {
|
||||
fail("Usage: select <gbrain|sourcebot|graphify|none>");
|
||||
}
|
||||
const id = arg as CodeProviderId;
|
||||
setProvider(id);
|
||||
out(`selected ${LABEL[id]}.`);
|
||||
const provider = providerById(id);
|
||||
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
|
||||
}
|
||||
|
||||
function cmdConsent(pathArg: string | undefined): void {
|
||||
const repoPath = resolve(pathArg ?? process.cwd());
|
||||
setConsent(repoPath, true);
|
||||
out(`indexing consent recorded for ${repoPath}`);
|
||||
}
|
||||
|
||||
async function cmdIndex(pathArg: string | undefined): Promise<void> {
|
||||
const provider = resolveSelectedProvider();
|
||||
if (!provider) fail("no provider selected; run `select <provider>` first");
|
||||
const repoPath = resolve(pathArg ?? process.cwd());
|
||||
const consented = hasConsent(repoPath);
|
||||
if (!provider!.local && !consented) {
|
||||
fail(`${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath}\` first.`);
|
||||
}
|
||||
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short id.
|
||||
const sourceId = provider!.id === "graphify" ? repoPath : basename(repoPath);
|
||||
const repo = { id: sourceId, path: repoPath };
|
||||
try {
|
||||
const registered = await provider!.registerSource(repo, { consented });
|
||||
out(`registered ${repo.id} with ${provider!.label} (${registered.state})`);
|
||||
const refreshed = await provider!.refresh({ id: registered.id }, { consented });
|
||||
// Remember which repo this provider indexed so `search` reads the same graph.
|
||||
setRoot(provider!.id, repoPath);
|
||||
out(`indexed: ${refreshed.state}${refreshed.itemCount != null ? ` (${refreshed.itemCount} items)` : ""}`);
|
||||
} catch (err) {
|
||||
handleProviderError(err, provider!.label);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSearch(terms: string[]): Promise<void> {
|
||||
const query = terms.join(" ").trim();
|
||||
if (!query) fail("Usage: search <query...>");
|
||||
const provider = resolveSelectedProvider();
|
||||
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
|
||||
try {
|
||||
const hits = await provider!.search(query, { limit: 10 });
|
||||
if (!hits.length) {
|
||||
out("(no results)");
|
||||
return;
|
||||
}
|
||||
for (const h of hits) out(`${h.score != null ? `[${h.score.toFixed(2)}] ` : ""}${h.ref}${h.snippet ? ` — ${h.snippet}` : ""}`);
|
||||
} catch (err) {
|
||||
handleProviderError(err, provider!.label);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderError(err: unknown, label: string): never {
|
||||
if (err instanceof CodeProviderError) {
|
||||
if (err.code === "PROVIDER_UNAVAILABLE") {
|
||||
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
|
||||
}
|
||||
fail(`${label} ${err.code}: ${err.message}`);
|
||||
}
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [action, ...rest] = process.argv.slice(2);
|
||||
switch (action) {
|
||||
case "suggest":
|
||||
return cmdSuggest(rest);
|
||||
case "options":
|
||||
return cmdOptions();
|
||||
case "status":
|
||||
return cmdStatus();
|
||||
case "select":
|
||||
return cmdSelect(rest[0]);
|
||||
case "consent":
|
||||
return cmdConsent(rest[0]);
|
||||
case "index":
|
||||
return cmdIndex(rest[0]);
|
||||
case "search":
|
||||
return cmdSearch(rest);
|
||||
default:
|
||||
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] | index [path] | search <query...>");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Code-Intelligence Provider Contract
|
||||
|
||||
Status: design + first implementation slice
|
||||
Owner: maintainer-directed internal work
|
||||
Related: `runtime/context.js` (Context.dev provider pattern),
|
||||
`scripts/gstack2/browser-provider-contract.ts` (the existing provider-contract idiom),
|
||||
`lib/gstack-decision-semantic.ts` (degrade-to-null reliability contract)
|
||||
|
||||
## Problem
|
||||
|
||||
gstack carries ~17k LOC of home-grown code-intelligence glue: transcript
|
||||
ingestion (`bin/gstack-memory-ingest.ts`, ~1.9k), a unified sync verb
|
||||
(`bin/gstack-gbrain-sync.ts`, ~1.6k), context loading
|
||||
(`bin/gstack-brain-context-load.ts`), a three-tier planning cache
|
||||
(`bin/gstack-brain-cache`), source reconciliation, engine-status classification,
|
||||
destructive-op guards, plus ~15 `bin/gstack-gbrain-*` and `bin/gstack-brain-*`
|
||||
entrypoints and ~40 tests. All of it is bespoke wiring around one external tool
|
||||
(GBrain) reached by direct CLI shell-out.
|
||||
|
||||
We do not want to keep maintaining a home-grown indexer. We want gstack to
|
||||
define a **small optional contract** that external providers implement, so the
|
||||
indexing/search/graph work lives in the provider, not in gstack.
|
||||
|
||||
Hard requirement, non-negotiable: **gstack must remain fully functional with the
|
||||
provider OFF.** File-only paths (the decision store, Context Recovery, grep) stay
|
||||
reliable and never depend on a provider being present. This is the existing
|
||||
decision-store philosophy (`lib/gstack-decision.ts` has zero gbrain imports;
|
||||
`lib/gstack-decision-semantic.ts` degrades to `null`). The contract is an
|
||||
enhancement, never a dependency.
|
||||
|
||||
## Design decision: repo-oriented, not document-store
|
||||
|
||||
Settled (do not relitigate). The contract is **repo-oriented**:
|
||||
|
||||
```
|
||||
register_source(repo) — required
|
||||
refresh(source) — required
|
||||
search(query) — required
|
||||
status(source) — required
|
||||
```
|
||||
|
||||
`add` / `delete` / `export` are **optional capabilities** a provider MAY
|
||||
advertise. GBrain advertises them (its native primitive is document-by-slug:
|
||||
put/delete/get/export); code-search and code-graph tools decline them.
|
||||
|
||||
A document-store contract (add / delete-by-id / export as *required* ops) was
|
||||
rejected: it misrepresents code-search and code-graph tools. Sourcebot indexes a
|
||||
whole repo and exposes search; it has no concept of "delete document id X".
|
||||
Forcing every provider to implement a document CRUD surface would either exclude
|
||||
the exact tools we most want (whole-repo indexers, graph tools) or force them to
|
||||
stub required ops with lies. Repo-in / query-out is the honest common
|
||||
denominator. GBrain's document axis survives as an *optional* capability, not as
|
||||
the contract's shape.
|
||||
|
||||
## The contract
|
||||
|
||||
TypeScript in `lib/code-intelligence/contract.ts`. Shape (abridged):
|
||||
|
||||
```ts
|
||||
type CodeProviderCapability =
|
||||
| "register_source" | "refresh" | "search" | "status" // required
|
||||
| "add" | "delete" | "export"; // optional
|
||||
|
||||
interface CodeProvider {
|
||||
readonly id: "gbrain" | "sourcebot" | "graphify";
|
||||
readonly label: string;
|
||||
readonly capabilities: ReadonlySet<CodeProviderCapability>;
|
||||
readonly local: boolean; // true = no repo content leaves the machine
|
||||
|
||||
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
|
||||
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
|
||||
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
|
||||
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
|
||||
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
Every provider MUST implement the four required methods and MUST advertise
|
||||
exactly the capabilities it backs (`assertRequiredCapabilities` enforces the
|
||||
required four at construction; a test pins it). Optional methods are present iff
|
||||
the matching capability is advertised. Calling an unadvertised optional op throws
|
||||
`CAPABILITY_UNSUPPORTED` — never a silent no-op.
|
||||
|
||||
### Typed failures
|
||||
|
||||
Mirrors `runtime/context.js`'s `ContextError` discipline (a closed code set,
|
||||
constructor throws on an unknown code):
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `PROVIDER_UNAVAILABLE` | CLI/MCP transport absent — degrade to file-only |
|
||||
| `PROVIDER_NOT_CONSENTED` | repo indexing not consented and content would leave the machine |
|
||||
| `CAPABILITY_UNSUPPORTED` | provider declines this op |
|
||||
| `SOURCE_NOT_REGISTERED` | op needs a source that isn't registered |
|
||||
| `PROVIDER_TIMEOUT` | provider exceeded the op timeout |
|
||||
| `PROVIDER_ERROR` | provider ran and failed |
|
||||
|
||||
`PROVIDER_UNAVAILABLE` is the load-bearing one: callers catch it (or use the
|
||||
picker's null resolution) and fall back to grep / file-only. It is never fatal.
|
||||
|
||||
### Consent
|
||||
|
||||
Two orthogonal consent axes, both explicit, neither auto-granted:
|
||||
|
||||
1. **Network / content-egress consent (repo-scoped).** Before any repo content
|
||||
leaves the machine, indexing must be consented *per repo*. The contract
|
||||
enforces this in `registerSource`/`refresh`/`add`: when
|
||||
`provider.local === false` and `opts.consented !== true`, it throws
|
||||
`PROVIDER_NOT_CONSENTED`. Local providers (Graphify) skip this axis — nothing
|
||||
leaves the machine.
|
||||
2. **Install consent (Graphify only).** Graphify is never auto-installed. The
|
||||
`options`/`status` display marks it available only when its CLI is present,
|
||||
and nothing in gstack runs a Graphify installer. Install is a user action
|
||||
(`pip install graphifyy && graphify install`).
|
||||
|
||||
This matches the Context.dev model: selection persists without granting egress
|
||||
consent; egress requires a separate explicit step.
|
||||
|
||||
## Per-provider capability matrix
|
||||
|
||||
| Op | GBrain (recommend first) | Sourcebot | Graphify |
|
||||
|----|--------------------------|-----------|----------|
|
||||
| `register_source` | ✓ `sources add --federated` | ✓ local `git` connection in config.json | ✓ `graphify update <dir>` (local, no LLM) |
|
||||
| `refresh` | ✓ `sync` + `sync --strategy code --full` | ✓ auto (config change + reindexIntervalMs) | ✓ `graphify update <dir>` |
|
||||
| `search` | ✓ `gbrain search` (federated corpora) | ✓ `POST /api/search` (keyless w/ anonymous access; Bearer key optional) | ✓ `graphify query "<q>" --graph <graph.json>` |
|
||||
| `status` | ✓ `sources list` + page_count | ~ partial (server liveness) | ~ partial (graph.json present + node count) |
|
||||
| `add` | ✓ `put <slug>` | ✗ declines | ✗ declines |
|
||||
| `delete` | ✓ `delete <slug>` | ✗ declines | ✗ declines |
|
||||
| `export` | ✓ `export` | ✗ declines | ✓ read `graphify-out/graph.json` |
|
||||
| `local` (no egress) | no (federated DB) | loopback → **yes**; remote host → no | **yes** (local only) |
|
||||
|
||||
All three are driven directly from the runtime — no MCP client:
|
||||
|
||||
- **GBrain** (`garrytan/gbrain`, the gstack-ecosystem tool): full contract fit,
|
||||
driven via the existing `gbrain` CLI chokepoint (`lib/gbrain-exec.ts`). Native
|
||||
primitive is document-by-slug (put/delete/get/export) PLUS a repo axis
|
||||
(`sources add`/`sync`). Advertises all seven capabilities. **Recommended
|
||||
first.**
|
||||
- **Sourcebot** (`github.com/sourcebot-dev/sourcebot`, YC Fall 2025): self-hosted
|
||||
whole-repo regex search, deployed via Docker Compose (bundled server + Postgres
|
||||
+ Redis; no supported non-Docker path). `register_source` adds a local `{ "type":
|
||||
"git", "url": "file:///path" }` connection to the server's `config.json` (it
|
||||
re-indexes on config change; a local repo needs a `remote.origin.url` or it is
|
||||
skipped); `search` is `POST {baseUrl}/api/search`; `status` probes that endpoint.
|
||||
Declines `add`/`delete`/`export`. It is a **local** tool — indexed code stays on
|
||||
your machine — and an **API key is optional**: a local instance with anonymous
|
||||
access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` keyless. The
|
||||
adapter sends `Authorization: Bearer <SOURCEBOT_API_KEY>` only when a key is set.
|
||||
A loopback `baseUrl` keeps content on the machine (local=true); a remote one
|
||||
requires egress consent.
|
||||
- **Graphify** (`github.com/Graphify-Labs/graphify`, YC-backed): local
|
||||
tree-sitter code graph via the `graphify` CLI. The adapter uses **`graphify
|
||||
update <dir>`** — the local, no-LLM build (writes `graphify-out/graph.json`);
|
||||
it deliberately avoids the bare `graphify <dir>` build, which runs an LLM
|
||||
extraction backend needing an API key + network. `graphify query "<q>" --graph
|
||||
<graph.json>` searches it (its `NODE ...`/`EDGE ...` output carries the file at
|
||||
`src=`/`at=`); `export` reads the graph JSON. Fully local — nothing leaves the
|
||||
machine. Optional, **install only with explicit user action** (`pip install
|
||||
graphifyy && graphify install`, needs Python >= 3.10); never auto-installed.
|
||||
|
||||
**No local-index option is offered** (deliberately excluded — a naive local
|
||||
index degrades result quality; we route to a real provider or to file-only grep,
|
||||
not to a half-baked in-house index).
|
||||
|
||||
### Integration surfaces (no MCP needed)
|
||||
|
||||
Each provider exposes a runtime-drivable surface, so gstack drives them with a
|
||||
CLI shell-out or plain HTTP — it never speaks MCP:
|
||||
|
||||
- **GBrain / Graphify: CLI.** Shell out (`spawnSync`), same shape and the same
|
||||
ENOENT→`PROVIDER_UNAVAILABLE` degrade as the existing gbrain glue.
|
||||
- **Sourcebot: HTTP + a config-file edit.** `POST /api/search` for queries and a
|
||||
JSON edit of the server's `config.json` to register a repo. `fetch` is
|
||||
injectable so tests run against a stub, no live server.
|
||||
|
||||
Sourcebot and Graphify also ship MCP servers for in-agent use; the contract does
|
||||
not depend on them, because their CLI/HTTP surfaces are enough to index and
|
||||
search from the runtime.
|
||||
|
||||
## Picker: recommend GBrain first
|
||||
|
||||
`lib/code-intelligence/picker.ts` + `selection.ts`. The user picks a provider
|
||||
with `gstack-code-intelligence select <provider>`, persisted to
|
||||
`$GSTACK_HOME/code-intelligence.json`. `resolveSelectedProvider()` constructs the
|
||||
selected provider, or returns `null` when nothing is selected — the provider-OFF
|
||||
path, where callers degrade to grep / the file-only decision store. Availability
|
||||
is proven at call time: a selected provider whose CLI/server is absent throws
|
||||
`PROVIDER_UNAVAILABLE`, which callers catch and degrade on.
|
||||
|
||||
`RECOMMENDED_ORDER` is the static **GBrain → Sourcebot → Graphify** fact — GBrain
|
||||
is always recommended first. `detectAvailable()` probes each provider for the
|
||||
`options`/`status` display (GBrain via the real `localEngineStatus()`; Graphify
|
||||
via its CLI/graph presence; Sourcebot via an HTTP liveness probe). The picker
|
||||
never silently prefers a non-recommended tool.
|
||||
|
||||
## How this replaces the current GBrain glue
|
||||
|
||||
The contract is the seam; the bespoke glue collapses onto it. Mapping:
|
||||
|
||||
| Today (bespoke) | Under the contract |
|
||||
|-----------------|--------------------|
|
||||
| `bin/gstack-gbrain-sync.ts` (`sync`/`reindex-code`/`sources`) | `provider.registerSource` / `provider.refresh` |
|
||||
| `lib/gstack-decision-semantic.ts` `semanticRecall` | `provider.search` (scoped) → same degrade-to-null |
|
||||
| `bin/gstack-brain-context-load.ts` (`query`/`list_pages`) | `provider.search` / `provider.status` |
|
||||
| `bin/gstack-memory-ingest.ts` (`import`, put) | `provider.add` (optional cap; GBrain-only) |
|
||||
| `lib/gbrain-sources.ts` (`ensureSourceRegistered`, `probeSource`) | GBrain adapter internals |
|
||||
| `lib/gbrain-local-status.ts` | GBrain adapter availability probe (kept, reused) |
|
||||
| `bin/gstack-gbrain-detect` / `-install` / `-source-wireup` / `-repo-policy` | provider setup + picker + consent (thinner) |
|
||||
|
||||
The point is not to delete 17k LOC in one commit — it is to make every consumer
|
||||
call the contract, then retire the bespoke paths provider-by-provider behind it.
|
||||
Consumers that only need "search my code, or degrade" stop importing gbrain
|
||||
specifics entirely.
|
||||
|
||||
## Rollout
|
||||
|
||||
Phased, each phase independently revertable. Skill-template edits are deferred to
|
||||
a later phase precisely so the first slices do not trigger the
|
||||
`gen:gstack2` / parity re-baseline cycle.
|
||||
|
||||
- **Phase 1 (this slice): the contract, three real adapters, and a usable CLI.**
|
||||
`contract.ts` + fully-drivable GBrain (CLI), Graphify (CLI), and Sourcebot
|
||||
(HTTP + config) adapters + the selection store + the `gstack-code-intelligence`
|
||||
CLI (`options`/`status`/`select`/`consent`/`index`/`search`) + tests. A user
|
||||
can select a provider and index/search their repo today. No skill-template or
|
||||
generated-file changes yet, so no `gen:gstack2` / parity re-baseline.
|
||||
- **Phase 2: route internal consumers through the contract.** Point
|
||||
`gstack-decision-semantic` and `gstack-brain-context-load` at
|
||||
`resolveSelectedProvider()`, preserving degrade-to-null exactly. Behavior-neutral
|
||||
for the file-only paths.
|
||||
- **Phase 3: surface selection in the skills.** Offer the picker at the moments a
|
||||
skill would benefit from indexed search, mirroring the `context` command's
|
||||
just-in-time consent prompt. Regenerate skills (`bun run gen:gstack2`), re-run
|
||||
`bun run test:gstack2`, re-baseline parity intentionally.
|
||||
- **Phase 4: retire bespoke glue.** Once every consumer is on the contract,
|
||||
delete the sync/ingest/cache entrypoints and their tests provider-by-provider.
|
||||
|
||||
## Verified against real environments
|
||||
|
||||
All three adapters were driven against the real tools in isolated environments
|
||||
(parallel agents, one worktree each), and all three now index + search a real repo
|
||||
end-to-end. Two rounds ran, because the first round's fixes included a mistake that
|
||||
only real execution caught — recorded here honestly.
|
||||
|
||||
- **GBrain — real Postgres+pgvector (Docker), gbrain 0.42.56 — PROVEN.** The
|
||||
default pglite/WASM engine is broken on macOS (upstream garrytan/gbrain#223), so
|
||||
the working recipe points gbrain at a real Postgres via `DATABASE_URL`. Real
|
||||
end-to-end search returned the actual code definition
|
||||
(`[0.88] src-checksum-ts … export statement computeChecksum`). Real execution
|
||||
caught a **regression I had introduced**: I removed `--strategy code` from
|
||||
`refresh` based on a `--help` misread, which silently stopped code from ever
|
||||
being indexed (only docs were). Restored to the verified two-pass
|
||||
(`sync`, then `sync --strategy code --full`); `--federated` registration is
|
||||
load-bearing for global search. Also fixed earlier: engine-down now degrades to
|
||||
`PROVIDER_UNAVAILABLE` (one-line message) instead of `PROVIDER_ERROR` + a WASM
|
||||
stack dump.
|
||||
- **Sourcebot — live v6.5.0 (Docker) — PROVEN keyless.** Endpoint, body, and
|
||||
response parsing were correct against the real server. Correcting an earlier
|
||||
wrong conclusion: Sourcebot does **not** require an API key for local use —
|
||||
enabling anonymous access (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves
|
||||
`/api/search` keyless, verified with a real hit through the CLI with no key set.
|
||||
The key stays optional; the only fix was messaging (point users to anonymous
|
||||
access first, key as fallback) plus a note that a local repo needs a
|
||||
`remote.origin.url` to be indexed. It is a local tool (code stays on the
|
||||
machine; a boot telemetry ping unless `SOURCEBOT_TELEMETRY_DISABLED=true`).
|
||||
- **Graphify — real install, graphify 0.9.23 — PROVEN.** Correcting an earlier
|
||||
wrong claim of mine: for **code**, `graphify <dir>` and `graphify update <dir>`
|
||||
produce the identical AST graph with **no LLM call**; the LLM only renames
|
||||
community clusters and ingests non-code docs, adding zero nodes/edges, and our
|
||||
parser discards the field it touches. So there is deliberately no LLM mode, and
|
||||
`local=true` is correct. The adapter uses `graphify update`; real `index`+search
|
||||
returned correct `file:line` refs. Also fixed: `search` now reads the indexed
|
||||
repo's graph (persisted root), and `options` reports an installed provider as
|
||||
available.
|
||||
|
||||
The larger lesson, kept on the record: a `--help` reading or a single agent's
|
||||
conclusion is not proof — running the real tool is. It reversed two of my
|
||||
first-round calls (the gbrain flag removal and the graphify LLM claim).
|
||||
|
||||
## What this does NOT change
|
||||
|
||||
Per the GStack 2 canonical contract and CLAUDE.md boundaries: no cloud browsers,
|
||||
no alternate iOS drivers, no local image models, no provider marketplaces, no
|
||||
workflow engines, **no new state database**. Context.dev remains the only
|
||||
newly-authorized external service for web context; this contract governs code
|
||||
intelligence, a separate axis. The existing decision store and Context Recovery
|
||||
stay file-only and provider-independent.
|
||||
|
||||
## Testing
|
||||
|
||||
`test/code-intelligence.test.ts` (19 tests, no live tools): capability-matrix
|
||||
invariants (all providers advertise the four required; only GBrain advertises the
|
||||
document ops; `local` flags, including loopback-vs-remote Sourcebot); the result
|
||||
parsers; the selection store + per-repo consent + provider-OFF (`null`); consent
|
||||
gating (GBrain non-local without consent throws `PROVIDER_NOT_CONSENTED`; local
|
||||
Graphify is exempt); the GBrain adapter against a fake `gbrain` shim; the Graphify
|
||||
adapter against a fake `graphify` shim (index builds a graph, search returns hits,
|
||||
status counts nodes); the Sourcebot adapter against an injected `fetch` + a temp
|
||||
`config.json` (register writes a local git connection, search maps `files[]` to
|
||||
hits); and every adapter degrading to `PROVIDER_UNAVAILABLE` when its tool/server
|
||||
is absent. The `gstack-code-intelligence` CLI was smoke-tested end-to-end:
|
||||
select → consent gate → local Graphify index (5-node graph) → search.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* code-intelligence/contract — the OPTIONAL, repo-oriented provider contract.
|
||||
*
|
||||
* gstack does not maintain a home-grown indexer. It defines this small contract
|
||||
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
|
||||
* contract is OPTIONAL: when no provider is available/consented,
|
||||
* `resolveCodeProvider()` returns null and callers degrade to grep / the
|
||||
* file-only decision store. Never a dependency, always an enhancement — the same
|
||||
* reliability contract as lib/gstack-decision-semantic.ts.
|
||||
*
|
||||
* Repo-oriented, not document-store (settled): register_source / refresh /
|
||||
* search / status are required; add / delete / export are optional capabilities
|
||||
* a provider MAY advertise. A document-CRUD-required contract would misrepresent
|
||||
* whole-repo code-search and code-graph tools. See
|
||||
* docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
|
||||
*/
|
||||
|
||||
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
|
||||
|
||||
export type CodeProviderCapability =
|
||||
| "register_source"
|
||||
| "refresh"
|
||||
| "search"
|
||||
| "status"
|
||||
| "add"
|
||||
| "delete"
|
||||
| "export";
|
||||
|
||||
export const REQUIRED_CAPABILITIES: readonly CodeProviderCapability[] = [
|
||||
"register_source",
|
||||
"refresh",
|
||||
"search",
|
||||
"status",
|
||||
] as const;
|
||||
|
||||
export const OPTIONAL_CAPABILITIES: readonly CodeProviderCapability[] = [
|
||||
"add",
|
||||
"delete",
|
||||
"export",
|
||||
] as const;
|
||||
|
||||
export interface RepoRef {
|
||||
/** Source id the provider registers this repo under. */
|
||||
id: string;
|
||||
/** Local worktree path. */
|
||||
path: string;
|
||||
/** Remote URL, when the provider clones/manages it. */
|
||||
remoteUrl?: string;
|
||||
}
|
||||
|
||||
export interface SourceRef {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SourceStatus {
|
||||
id: string;
|
||||
state: "registered" | "indexing" | "ready" | "absent" | "unknown";
|
||||
/** Pages / files / graph nodes, when the provider reports a count. */
|
||||
itemCount?: number;
|
||||
detail?: string;
|
||||
/** True when the provider only implements a partial status probe. */
|
||||
partial?: boolean;
|
||||
}
|
||||
|
||||
export interface CodeSearchHit {
|
||||
/** Slug, file path, or symbol id — whatever the provider keys results on. */
|
||||
ref: string;
|
||||
score?: number;
|
||||
snippet?: string;
|
||||
kind?: "document" | "file" | "symbol" | "graph-node";
|
||||
}
|
||||
|
||||
export interface OpOptions {
|
||||
/**
|
||||
* Env override for spawned processes. Production callers leave this unset;
|
||||
* tests inject a synthetic env (fake CLI on PATH). Matches the existing
|
||||
* gbrain helpers.
|
||||
*/
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Timeout in ms for the underlying op. */
|
||||
timeout?: number;
|
||||
/**
|
||||
* Explicit per-repo consent that repo content may leave the machine. Required
|
||||
* for non-local providers on register_source / refresh / add.
|
||||
*/
|
||||
consented?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions extends OpOptions {
|
||||
/** Restrict to a registered source. */
|
||||
source?: string;
|
||||
limit?: number;
|
||||
minScore?: number;
|
||||
}
|
||||
|
||||
export interface CodeProvider {
|
||||
readonly id: CodeProviderId;
|
||||
readonly label: string;
|
||||
readonly capabilities: ReadonlySet<CodeProviderCapability>;
|
||||
/** True when no repo content leaves the machine (Graphify). */
|
||||
readonly local: boolean;
|
||||
|
||||
has(capability: CodeProviderCapability): boolean;
|
||||
|
||||
registerSource(repo: RepoRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
refresh(source: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
search(query: string, opts?: SearchOptions): Promise<CodeSearchHit[]>;
|
||||
status(source?: SourceRef, opts?: OpOptions): Promise<SourceStatus>;
|
||||
|
||||
add?(doc: { slug: string; body: string }, opts?: OpOptions): Promise<SourceStatus>;
|
||||
delete?(slug: string, opts?: OpOptions): Promise<SourceStatus>;
|
||||
export?(source: SourceRef, opts?: OpOptions): Promise<string>;
|
||||
}
|
||||
|
||||
export const CODE_PROVIDER_FAILURES = Object.freeze([
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
"PROVIDER_NOT_CONSENTED",
|
||||
"CAPABILITY_UNSUPPORTED",
|
||||
"SOURCE_NOT_REGISTERED",
|
||||
"PROVIDER_TIMEOUT",
|
||||
"PROVIDER_ERROR",
|
||||
] as const);
|
||||
|
||||
export type CodeProviderFailure = (typeof CODE_PROVIDER_FAILURES)[number];
|
||||
|
||||
const FAILURE_SET = new Set<string>(CODE_PROVIDER_FAILURES);
|
||||
|
||||
/**
|
||||
* Typed provider failure. Mirrors runtime/context.js ContextError discipline:
|
||||
* the code set is closed and the constructor throws on an unknown code, so a
|
||||
* typo can never mint an untyped failure.
|
||||
*/
|
||||
export class CodeProviderError extends Error {
|
||||
readonly code: CodeProviderFailure;
|
||||
readonly providerId?: CodeProviderId;
|
||||
|
||||
constructor(code: CodeProviderFailure, message: string, providerId?: CodeProviderId) {
|
||||
if (!FAILURE_SET.has(code)) throw new TypeError(`Unknown code-provider failure code: ${code}`);
|
||||
super(message);
|
||||
this.name = "CodeProviderError";
|
||||
this.code = code;
|
||||
this.providerId = providerId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce that a provider advertises every required capability. Called by each
|
||||
* adapter constructor so an incomplete provider fails fast, not at first search.
|
||||
*/
|
||||
export function assertRequiredCapabilities(
|
||||
id: CodeProviderId,
|
||||
capabilities: ReadonlySet<CodeProviderCapability>,
|
||||
): void {
|
||||
const missing = REQUIRED_CAPABILITIES.filter((cap) => !capabilities.has(cap));
|
||||
if (missing.length) {
|
||||
throw new TypeError(`Code provider ${id} is missing required capabilities: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for optional ops: throw CAPABILITY_UNSUPPORTED (never a silent no-op)
|
||||
* when a provider is asked for a capability it does not advertise.
|
||||
*/
|
||||
export function assertCapability(provider: CodeProvider, capability: CodeProviderCapability): void {
|
||||
if (!provider.has(capability)) {
|
||||
throw new CodeProviderError(
|
||||
"CAPABILITY_UNSUPPORTED",
|
||||
`${provider.label} does not support "${capability}"`,
|
||||
provider.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo-scoped egress consent gate. Non-local providers must not move repo
|
||||
* content off the machine without explicit per-repo consent. Local providers
|
||||
* (nothing leaves the machine) are exempt.
|
||||
*/
|
||||
export function assertEgressConsent(provider: CodeProvider, opts?: OpOptions): void {
|
||||
if (provider.local) return;
|
||||
if (opts?.consented === true) return;
|
||||
throw new CodeProviderError(
|
||||
"PROVIDER_NOT_CONSENTED",
|
||||
`${provider.label} would send repo content off this machine; per-repo indexing consent is required`,
|
||||
provider.id,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* GBrain adapter — full contract fit over the existing gbrain CLI chokepoint.
|
||||
*
|
||||
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
|
||||
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
|
||||
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
|
||||
* Windows-shim guarantees carry over unchanged. GBrain's native primitive is
|
||||
* document-by-slug (put/delete/get/export) PLUS a repo axis (sources add/sync),
|
||||
* so it advertises all seven capabilities.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
|
||||
import { spawnGbrain, buildGbrainEnv, NEEDS_SHELL_ON_WINDOWS } from "../gbrain-exec";
|
||||
import { ensureSourceRegistered, probeSource, sourcePageCount } from "../gbrain-sources";
|
||||
import {
|
||||
assertCapability,
|
||||
assertEgressConsent,
|
||||
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",
|
||||
"add",
|
||||
"delete",
|
||||
"export",
|
||||
];
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
|
||||
* gbrain's search prints text, not JSON (verified in
|
||||
* lib/gstack-decision-semantic.ts). Exported for deterministic unit testing.
|
||||
*/
|
||||
export function parseGbrainSearch(stdout: string, minScore: number, limit: number): CodeSearchHit[] {
|
||||
const hits: CodeSearchHit[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^\[([\d.]+)\]\s+(\S+)\s+--\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
const score = parseFloat(m[1]);
|
||||
if (!Number.isFinite(score) || score < minScore) continue;
|
||||
hits.push({ ref: m[2], score, snippet: m[3].trim(), kind: "document" });
|
||||
}
|
||||
return hits.slice(0, limit);
|
||||
}
|
||||
|
||||
export class GbrainProvider implements CodeProvider {
|
||||
readonly id = "gbrain" as const;
|
||||
readonly label = "GBrain";
|
||||
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||
/** GBrain federates into a (possibly remote) DB, so content can leave the machine. */
|
||||
readonly local = false;
|
||||
|
||||
constructor() {
|
||||
assertRequiredCapabilities(this.id, this.capabilities);
|
||||
}
|
||||
|
||||
has(capability: CodeProviderCapability): boolean {
|
||||
return this.capabilities.has(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-closed egress receipt for the write ops (register/refresh/add). The
|
||||
* gbrain subprocess owns the wire bytes, so the receipt records destination
|
||||
* + payload class; sha256 is only known for `add` (the exact document body).
|
||||
*/
|
||||
#receipt(payloadClass: string, opts: OpOptions, body?: string): void {
|
||||
writeReceipt({
|
||||
env: opts.env,
|
||||
sink: "gbrain",
|
||||
host: "gbrain-db (user-configured DATABASE_URL)",
|
||||
payloadClass,
|
||||
bytes: body == null ? 0 : Buffer.byteLength(body),
|
||||
sha256: body == null ? null : sha256Hex(body),
|
||||
consent: "code-intelligence provider=gbrain + per-repo consented=true",
|
||||
});
|
||||
}
|
||||
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertEgressConsent(this, opts);
|
||||
this.#receipt("repo-source-registration (sent by gbrain subprocess)", opts);
|
||||
try {
|
||||
const result = await ensureSourceRegistered(repo.id, repo.path, {
|
||||
federated: true,
|
||||
env: opts.env,
|
||||
});
|
||||
return {
|
||||
id: repo.id,
|
||||
state: result.state.status === "match" ? "registered" : "unknown",
|
||||
detail: result.changed ? "registered" : "already registered",
|
||||
};
|
||||
} catch (err) {
|
||||
throw this.#wrap(err);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertEgressConsent(this, opts);
|
||||
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
|
||||
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
|
||||
// 1. default sync (markdown strategy) — indexes docs.
|
||||
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
|
||||
// is never indexed (the whole point of a code provider); `code-def` stays
|
||||
// "not_built" and search only finds incidental doc mentions. `--full`
|
||||
// forces it past the per-source checkpoint the markdown pass advanced.
|
||||
this.#assertOk(spawnGbrain(["sync", "--source", source.id], { baseEnv: opts.env, timeout }));
|
||||
this.#assertOk(spawnGbrain(["sync", "--source", source.id, "--strategy", "code", "--full"], { baseEnv: opts.env, timeout }));
|
||||
return this.status(source, opts);
|
||||
}
|
||||
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
// `gbrain search` is global and has no `--source` flag; `--limit` is real
|
||||
// (verified against gbrain 0.42.x --help).
|
||||
const args = ["search", query];
|
||||
if (opts.limit) args.push("--limit", String(opts.limit));
|
||||
const r = spawnGbrain(args, { baseEnv: opts.env, timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS });
|
||||
this.#assertOk(r);
|
||||
return parseGbrainSearch(r.stdout || "", opts.minScore ?? 0.1, opts.limit ?? 10);
|
||||
}
|
||||
|
||||
async status(source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
if (!source) {
|
||||
// No source given: liveness probe. `sources list` reachable = ready.
|
||||
this.#assertOk(spawnGbrain(["sources", "list", "--json"], {
|
||||
baseEnv: opts.env,
|
||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
}));
|
||||
return { id: "*", state: "ready" };
|
||||
}
|
||||
try {
|
||||
const probed = probeSource(source.id, opts.env);
|
||||
if (probed.status === "absent") return { id: source.id, state: "absent" };
|
||||
const count = sourcePageCount(source.id, opts.env);
|
||||
return {
|
||||
id: source.id,
|
||||
state: "ready",
|
||||
itemCount: count ?? undefined,
|
||||
detail: probed.registered_path,
|
||||
};
|
||||
} catch (err) {
|
||||
throw this.#wrap(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Document ops (add/delete/export) are GBrain-only and secondary; they match
|
||||
// gbrain's documented CLI surface (`put <slug>` reads stdin; `delete <slug>`;
|
||||
// `export`) but could not be exercised against a live engine on the test host
|
||||
// (pglite WASM broken, garrytan/gbrain#223), so treat them as best-effort.
|
||||
async add(doc: { slug: string; body: string }, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertCapability(this, "add");
|
||||
assertEgressConsent(this, opts);
|
||||
this.#receipt("document-body (sent by gbrain subprocess)", opts, doc.body);
|
||||
// `gbrain put <slug>` reads the document body from stdin.
|
||||
this.#assertOk(this.#runInput(["put", doc.slug], doc.body, opts));
|
||||
return { id: doc.slug, state: "ready" };
|
||||
}
|
||||
|
||||
async delete(slug: string, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertCapability(this, "delete");
|
||||
// stdin closed ("") so any confirmation prompt gets EOF rather than hanging.
|
||||
this.#assertOk(this.#runInput(["delete", slug], "", opts));
|
||||
return { id: slug, state: "absent" };
|
||||
}
|
||||
|
||||
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
||||
assertCapability(this, "export");
|
||||
// `gbrain export` is brain-wide (no per-source flag); returns whatever it prints.
|
||||
const r = spawnGbrain(["export"], {
|
||||
baseEnv: opts.env,
|
||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
});
|
||||
this.#assertOk(r);
|
||||
return r.stdout || "";
|
||||
}
|
||||
|
||||
/** spawn gbrain with `input` on stdin, seeded env, Windows-shim aware. */
|
||||
#runInput(args: string[], input: string, opts: OpOptions) {
|
||||
return spawnSync("gbrain", args, {
|
||||
input,
|
||||
encoding: "utf-8",
|
||||
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
env: buildGbrainEnv({ baseEnv: opts.env }),
|
||||
shell: NEEDS_SHELL_ON_WINDOWS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a typed failure unless the spawn succeeded. Distinguishes a missing
|
||||
* CLI (ENOENT → PROVIDER_UNAVAILABLE, the degrade signal) from a timeout
|
||||
* (ETIMEDOUT/SIGTERM, status=null) and a real non-zero exit.
|
||||
*/
|
||||
#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", "gbrain CLI not on PATH", this.id);
|
||||
}
|
||||
if (r.error?.code === "ETIMEDOUT" || r.signal === "SIGTERM") {
|
||||
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
|
||||
}
|
||||
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
|
||||
// (caller falls back to file-only), not a hard PROVIDER_ERROR with a raw dump.
|
||||
// Covers the real case where gbrain's pglite engine fails to init its WASM
|
||||
// runtime (garrytan/gbrain#223) as well as unreachable/unconfigured databases.
|
||||
if (/PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i.test(stderr)) {
|
||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
|
||||
}
|
||||
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
|
||||
}
|
||||
|
||||
#wrap(err: unknown): CodeProviderError {
|
||||
if (err instanceof CodeProviderError) return err;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/
|
||||
// config problems, degrade to UNAVAILABLE so callers fall back to file-only.
|
||||
if (/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json/i.test(message)) {
|
||||
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
|
||||
}
|
||||
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** First non-empty line, so a multi-line WASM/stack dump never reaches the user. */
|
||||
function firstLine(text: string): string {
|
||||
return (text || "").split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Graphify adapter — real CLI integration (github.com/Graphify-Labs/graphify).
|
||||
*
|
||||
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
|
||||
* and `graphify update <dir>` 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 <dir>` (writes `<dir>/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 "<q>" --graph <dir>/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 } 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 locally (no LLM, no egress consent needed). */
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
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<SourceStatus> {
|
||||
this.#assertOk(this.#run(["update", source.id], source.id, opts.timeout ?? DEFAULT_TIMEOUT_MS));
|
||||
return this.status(source, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* `graphify query "<q>" --graph <graph.json>` 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<CodeSearchHit[]> {
|
||||
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<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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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=<file> loc=L<n>` on NODE, `at=<file>:L<n>` on
|
||||
* EDGE), so the ref is `<file>:L<n>`. 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* code-intelligence — the OPTIONAL, repo-oriented provider contract.
|
||||
* See docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
|
||||
*/
|
||||
|
||||
export * from "./contract";
|
||||
export { GbrainProvider, parseGbrainSearch } from "./gbrain-adapter";
|
||||
export { GraphifyProvider, parseGraphifyQuery, type GraphifyOptions } from "./graphify-adapter";
|
||||
export { SourcebotProvider, parseSourcebotSearch, type SourcebotOptions } from "./sourcebot-adapter";
|
||||
export {
|
||||
readSelection,
|
||||
setProvider,
|
||||
setConsent,
|
||||
hasConsent,
|
||||
setRoot,
|
||||
getRoot,
|
||||
type Selection,
|
||||
} from "./selection";
|
||||
export {
|
||||
LARGE_REPO_FILE_THRESHOLD,
|
||||
shouldOfferIndexing,
|
||||
trackedFileCount,
|
||||
type Suggestion,
|
||||
type SuggestReason,
|
||||
} from "./suggest";
|
||||
export {
|
||||
RECOMMENDED_ORDER,
|
||||
providerById,
|
||||
resolveSelectedProvider,
|
||||
detectAvailable,
|
||||
type PickerOptions,
|
||||
type Availability,
|
||||
} from "./picker";
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Picker — constructs the code-intelligence provider the user selected, and
|
||||
* offers the recommendation order (GBrain first) for the selection UX.
|
||||
*
|
||||
* `resolveSelectedProvider()` reads the persisted selection and constructs that
|
||||
* provider, or returns null when nothing is selected — the provider-OFF path,
|
||||
* where callers degrade to grep / the file-only decision store. Availability is
|
||||
* proven at call time: a selected provider whose tool/server is absent throws
|
||||
* PROVIDER_UNAVAILABLE from its ops, which callers catch and degrade on. The
|
||||
* `detectAvailable()` probe drives the `options`/`status` display.
|
||||
*
|
||||
* GBrain is recommended first. Graphify is NEVER auto-installed — it appears in
|
||||
* the options only once its CLI is present (a user install).
|
||||
*/
|
||||
|
||||
import { localEngineStatus } from "../gbrain-local-status";
|
||||
import { GbrainProvider } from "./gbrain-adapter";
|
||||
import { GraphifyProvider, graphifyInstalled, type GraphifyOptions } from "./graphify-adapter";
|
||||
import { SourcebotProvider, type SourcebotOptions } from "./sourcebot-adapter";
|
||||
import { readSelection, getRoot } from "./selection";
|
||||
import type { CodeProvider, CodeProviderId } from "./contract";
|
||||
|
||||
/** Recommendation order — GBrain first. */
|
||||
export const RECOMMENDED_ORDER: readonly CodeProviderId[] = ["gbrain", "sourcebot", "graphify"];
|
||||
|
||||
export interface PickerOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
graphify?: GraphifyOptions;
|
||||
sourcebot?: SourcebotOptions;
|
||||
}
|
||||
|
||||
/** Construct a provider by id (no availability check — ops degrade at call time). */
|
||||
export function providerById(id: CodeProviderId, opts: PickerOptions = {}): CodeProvider {
|
||||
switch (id) {
|
||||
case "gbrain":
|
||||
return new GbrainProvider();
|
||||
case "graphify": {
|
||||
// Default the graph root to the repo Graphify last indexed, so `search`
|
||||
// reads the same graph `index` built (not whatever cwd happens to be).
|
||||
const root = opts.graphify?.root ?? getRoot("graphify", opts.env);
|
||||
return new GraphifyProvider({ env: opts.env, ...opts.graphify, ...(root ? { root } : {}) });
|
||||
}
|
||||
case "sourcebot":
|
||||
return new SourcebotProvider({ env: opts.env, ...opts.sourcebot });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider the user selected, constructed, or null when none is selected.
|
||||
* Null is the provider-OFF path: callers MUST degrade to grep / file-only.
|
||||
*/
|
||||
export function resolveSelectedProvider(opts: PickerOptions = {}): CodeProvider | null {
|
||||
const { provider } = readSelection(opts.env);
|
||||
return provider ? providerById(provider, opts) : null;
|
||||
}
|
||||
|
||||
export interface Availability {
|
||||
id: CodeProviderId;
|
||||
available: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe which providers are usable right now, in recommendation order. Used by
|
||||
* the `options`/`status` display. GBrain via the real localEngineStatus();
|
||||
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
|
||||
*/
|
||||
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
|
||||
const gbrainStatus = localEngineStatus({ env: opts.env });
|
||||
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
|
||||
|
||||
// Available = the CLI is installed and selectable (NOT "a graph already exists
|
||||
// here"). A freshly installed Graphify with no graph yet is still available.
|
||||
const graphifyOk = graphifyInstalled(opts.env);
|
||||
let graphifyDetail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
|
||||
if (graphifyOk) {
|
||||
try {
|
||||
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
|
||||
graphifyDetail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
|
||||
} catch {
|
||||
graphifyDetail = "installed";
|
||||
}
|
||||
}
|
||||
|
||||
let sourcebotOk = false;
|
||||
let sourcebotDetail = "server unreachable";
|
||||
try {
|
||||
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
|
||||
sourcebotOk = s.state === "ready";
|
||||
sourcebotDetail = s.detail ?? "";
|
||||
} catch {
|
||||
sourcebotOk = false;
|
||||
}
|
||||
|
||||
return [
|
||||
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
|
||||
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
|
||||
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* selection — persists the user's chosen code-intelligence provider and their
|
||||
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
|
||||
* (default `~/.gstack/`), the same home the rest of gstack uses.
|
||||
*
|
||||
* Consent is per-repo (keyed by absolute repo path), because indexing consent
|
||||
* is "may THIS repo's content be indexed by the selected provider" — a decision
|
||||
* a user makes per project, not once for the machine. No selection at all is the
|
||||
* provider-OFF default: callers degrade to grep / the file-only decision store.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { execFileSync, spawnSync } from "child_process";
|
||||
import type { CodeProviderId } from "./contract";
|
||||
|
||||
export interface Selection {
|
||||
provider: CodeProviderId | null;
|
||||
/** Absolute repo path → consented. */
|
||||
consents: Record<string, boolean>;
|
||||
/** Provider id → the absolute repo path it last indexed (so search finds it). */
|
||||
roots: Record<string, string>;
|
||||
/** User explicitly chose no indexing — never offer again. */
|
||||
declined: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: Selection = { provider: null, consents: {}, roots: {}, declined: false };
|
||||
|
||||
function storePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
|
||||
return join(home, "code-intelligence.json");
|
||||
}
|
||||
|
||||
export function readSelection(env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const p = storePath(env);
|
||||
if (!existsSync(p)) return { ...EMPTY };
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(p, "utf-8")) as Partial<Selection>;
|
||||
return {
|
||||
provider: raw.provider ?? null,
|
||||
consents: raw.consents && typeof raw.consents === "object" ? raw.consents : {},
|
||||
roots: raw.roots && typeof raw.roots === "object" ? raw.roots : {},
|
||||
declined: raw.declined === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...EMPTY };
|
||||
}
|
||||
}
|
||||
|
||||
function write(selection: Selection, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const p = storePath(env);
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
const tmp = `${p}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(selection, null, 2), "utf-8");
|
||||
renameSync(tmp, p);
|
||||
}
|
||||
|
||||
export function setProvider(provider: CodeProviderId | null, env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
// Choosing a provider clears a prior decline; clearing to null records one,
|
||||
// so the session-start offer is never repeated after an explicit "none".
|
||||
const next = { ...readSelection(env), provider, declined: provider === null };
|
||||
write(next, env);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Record per-repo indexing consent (repo path resolved to absolute). */
|
||||
export function setConsent(repoPath: string, consented: boolean, env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const current = readSelection(env);
|
||||
const next: Selection = { ...current, consents: { ...current.consents, [resolve(repoPath)]: consented } };
|
||||
write(next, env);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-remote trust store (gstack-gbrain-repo-policy) is the SINGLE
|
||||
* authority for consent-to-send: a `deny` tier vetoes any recorded
|
||||
* code-intelligence consent, so two stores can never disagree about whether
|
||||
* code may leave this repo (R1, fork port wave 2 review). Mirrors the
|
||||
* gbrain-sync chokepoint's polarity: no policy store → no veto (nothing was
|
||||
* ever set); unreadable store → veto (fail-closed — a policy the user set
|
||||
* must not be bypassed by a broken store).
|
||||
*/
|
||||
function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const home = env.GSTACK_HOME || join(homedir(), ".gstack");
|
||||
if (!existsSync(join(home, "gbrain-repo-policy.json"))) return false;
|
||||
let url = "";
|
||||
try {
|
||||
url = execFileSync("git", ["-C", resolve(repoPath), "remote", "get-url", "origin"], {
|
||||
encoding: "utf-8", timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return false; // no remote → policy (keyed by remote) has nothing set for this repo
|
||||
}
|
||||
if (!url) return false;
|
||||
const res = spawnSync(join(import.meta.dir, "..", "..", "bin", "gstack-gbrain-repo-policy"), ["get", url], {
|
||||
encoding: "utf-8", timeout: 10_000, env: { ...env } as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (res.error || res.status !== 0) return true; // fail-closed
|
||||
const tier = (res.stdout || "").trim();
|
||||
return tier === "deny";
|
||||
}
|
||||
|
||||
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (readSelection(env).consents[resolve(repoPath)] !== true) return false;
|
||||
return !repoPolicyVeto(repoPath, env);
|
||||
}
|
||||
|
||||
/** Record the repo path a provider last indexed, so search reads the same graph. */
|
||||
export function setRoot(provider: CodeProviderId, repoPath: string, env: NodeJS.ProcessEnv = process.env): Selection {
|
||||
const current = readSelection(env);
|
||||
const next: Selection = { ...current, roots: { ...current.roots, [provider]: resolve(repoPath) } };
|
||||
write(next, env);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getRoot(provider: CodeProviderId, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
return readSelection(env).roots[provider];
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* Sourcebot adapter — real HTTP + config integration
|
||||
* (github.com/sourcebot-dev/sourcebot, YC F2025).
|
||||
*
|
||||
* Sourcebot is a self-hosted server that indexes repos declared in its
|
||||
* config.json and serves regex code search over `POST /api/search` (zoekt). So
|
||||
* the runtime drives it with plain HTTP + a config-file edit — no MCP:
|
||||
* - register_source: add `{ "type": "git", "url": "file:///abs/path" }` to the
|
||||
* server's config.json (it re-indexes automatically on config change).
|
||||
* - refresh: Sourcebot re-indexes on config change and on reindexIntervalMs;
|
||||
* there is no per-source trigger endpoint, so refresh reports current status.
|
||||
* - search: POST /api/search with a regex query, map files[] to hits.
|
||||
* - status: liveness GET against the base URL.
|
||||
* Declines the document ops (add/delete/export) — it is a whole-repo search index.
|
||||
*
|
||||
* Egress: a loopback base URL means the index runs on this machine, so no repo
|
||||
* content leaves it (local=true). A non-loopback base URL means content reaches
|
||||
* another host, so egress consent is required (local=false).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
||||
import { sha256Hex, writeReceipt } from "../egress-receipt.js";
|
||||
import {
|
||||
assertCapability,
|
||||
assertEgressConsent,
|
||||
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"];
|
||||
const DEFAULT_URL = "http://localhost:3000";
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
type FetchLike = typeof globalThis.fetch;
|
||||
|
||||
export interface SourcebotOptions {
|
||||
/** Base URL of the Sourcebot server. Defaults to SOURCEBOT_URL or http://localhost:3000. */
|
||||
baseUrl?: string;
|
||||
/** Path to the server's config.json (for register_source). Defaults to SOURCEBOT_CONFIG. */
|
||||
configPath?: string;
|
||||
/**
|
||||
* OPTIONAL API key for the Sourcebot REST API. Defaults to SOURCEBOT_API_KEY.
|
||||
* A local instance with anonymous access enabled
|
||||
* (`FORCE_ENABLE_ANONYMOUS_ACCESS=true`) serves `/api/search` with NO key —
|
||||
* verified keyless against a real Sourcebot v6.5.0. Only set a key when your
|
||||
* instance is login-gated; it is sent as `Authorization: Bearer <key>`.
|
||||
*/
|
||||
apiKey?: string;
|
||||
/** Injectable fetch for tests. */
|
||||
fetch?: FetchLike;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function isLoopback(url: string): boolean {
|
||||
try {
|
||||
const h = new URL(url).hostname.toLowerCase();
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class SourcebotProvider implements CodeProvider {
|
||||
readonly id = "sourcebot" as const;
|
||||
readonly label = "Sourcebot";
|
||||
readonly capabilities = new Set<CodeProviderCapability>(CAPABILITIES);
|
||||
readonly local: boolean;
|
||||
readonly #baseUrl: string;
|
||||
readonly #configPath?: string;
|
||||
readonly #apiKey?: string;
|
||||
readonly #fetch: FetchLike;
|
||||
|
||||
constructor(opts: SourcebotOptions = {}) {
|
||||
const env = opts.env ?? process.env;
|
||||
this.#baseUrl = (opts.baseUrl ?? env.SOURCEBOT_URL ?? DEFAULT_URL).replace(/\/$/, "");
|
||||
this.#configPath = opts.configPath ?? env.SOURCEBOT_CONFIG;
|
||||
this.#apiKey = opts.apiKey ?? env.SOURCEBOT_API_KEY;
|
||||
this.#fetch = opts.fetch ?? globalThis.fetch;
|
||||
this.local = isLoopback(this.#baseUrl);
|
||||
assertRequiredCapabilities(this.id, this.capabilities);
|
||||
}
|
||||
|
||||
#authHeaders(): Record<string, string> {
|
||||
return this.#apiKey ? { Authorization: `Bearer ${this.#apiKey}` } : {};
|
||||
}
|
||||
|
||||
has(capability: CodeProviderCapability): boolean {
|
||||
return this.capabilities.has(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the repo as a local `git` connection in Sourcebot's config.json.
|
||||
* NOTE: Sourcebot silently SKIPS a local repo that has no `remote.origin.url`
|
||||
* (logs "Skipping <path> - remote.origin.url not found"); a freshly `git init`'d
|
||||
* repo must set an origin before it will index.
|
||||
*/
|
||||
async registerSource(repo: RepoRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
|
||||
if (!this.#configPath) {
|
||||
throw new CodeProviderError(
|
||||
"PROVIDER_UNAVAILABLE",
|
||||
"set SOURCEBOT_CONFIG to the server's config.json path to register sources",
|
||||
this.id,
|
||||
);
|
||||
}
|
||||
let config: { connections?: Record<string, unknown> };
|
||||
try {
|
||||
config = existsSync(this.#configPath)
|
||||
? (JSON.parse(readFileSync(this.#configPath, "utf-8")) as typeof config)
|
||||
: {};
|
||||
} catch (err) {
|
||||
throw new CodeProviderError("PROVIDER_ERROR", `unreadable Sourcebot config: ${(err as Error).message}`, this.id);
|
||||
}
|
||||
config.connections = config.connections ?? {};
|
||||
config.connections[repo.id] = { type: "git", url: `file://${repo.path}` };
|
||||
// Atomic write so a running Sourcebot never reads a half-written config.
|
||||
const tmp = `${this.#configPath}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, JSON.stringify(config, null, 2), "utf-8");
|
||||
renameSync(tmp, this.#configPath);
|
||||
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
|
||||
}
|
||||
|
||||
/** Sourcebot re-indexes automatically; report current liveness. */
|
||||
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
const live = await this.status(source, opts);
|
||||
return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" };
|
||||
}
|
||||
|
||||
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
const body = {
|
||||
query: opts.source ? `repo:${opts.source} ${query}` : query,
|
||||
matches: opts.limit ?? 20,
|
||||
isRegexEnabled: true,
|
||||
isCaseSensitivityEnabled: false,
|
||||
};
|
||||
const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS);
|
||||
return parseSourcebotSearch(payload, opts.limit ?? 20);
|
||||
}
|
||||
|
||||
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
|
||||
// `redirect: manual` so an auth-gated server (307 -> /login) reads as
|
||||
// not-usable instead of following to a 200 and falsely reporting "ready".
|
||||
try {
|
||||
const res = await this.#fetchWithTimeout(
|
||||
`${this.#baseUrl}/api/search`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json", ...this.#authHeaders() }, body: JSON.stringify({ query: "sourcebot", matches: 1, isRegexEnabled: false }), redirect: "manual" },
|
||||
opts.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { id: "*", state: "unknown", partial: true, detail: "reachable but login-gated (enable anonymous access with FORCE_ENABLE_ANONYMOUS_ACCESS=true for local use, or set SOURCEBOT_API_KEY)" };
|
||||
}
|
||||
return { id: "*", state: res.ok ? "ready" : "unknown", partial: true, detail: `HTTP ${res.status}` };
|
||||
} catch {
|
||||
return { id: "*", state: "unknown", partial: true, detail: `unreachable at ${this.#baseUrl}` };
|
||||
}
|
||||
}
|
||||
|
||||
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}, timeout);
|
||||
} catch (err) {
|
||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id);
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot is login-gated (HTTP ${res.status}); enable anonymous access (FORCE_ENABLE_ANONYMOUS_ACCESS=true) for local use, or set SOURCEBOT_API_KEY`, this.id);
|
||||
}
|
||||
if (!res.ok) throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot ${path} returned HTTP ${res.status}`, this.id);
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
throw new CodeProviderError("PROVIDER_ERROR", `Sourcebot returned non-JSON: ${(err as Error).message}`, this.id);
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
|
||||
// Every Sourcebot HTTP call routes through here. A loopback server keeps
|
||||
// content on this machine (no egress, no receipt); a non-loopback server
|
||||
// is an off-machine send and gets a fail-closed receipt BEFORE the fetch.
|
||||
if (!this.local) {
|
||||
const body = typeof init.body === "string" ? init.body : "";
|
||||
writeReceipt({
|
||||
sink: "sourcebot",
|
||||
host: new URL(url).host,
|
||||
payloadClass: "code-search-request",
|
||||
bytes: Buffer.byteLength(body),
|
||||
sha256: sha256Hex(body),
|
||||
consent: "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true",
|
||||
});
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
try {
|
||||
return await this.#fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === "AbortError") throw new CodeProviderError("PROVIDER_TIMEOUT", "Sourcebot request timed out", this.id);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SourcebotMatchRange { start?: { lineNumber?: number } }
|
||||
interface SourcebotChunk { content?: string; matchRanges?: SourcebotMatchRange[] }
|
||||
interface SourcebotFile { fileName?: { text?: string }; repository?: string; chunks?: SourcebotChunk[] }
|
||||
|
||||
/**
|
||||
* Map a `POST /api/search` response `{ files: [...] }` to hits: one hit per file,
|
||||
* ref = file path, snippet = first matching chunk, kind = "file". Tolerant of a
|
||||
* missing/garbage payload (returns []). Exported for deterministic testing.
|
||||
*/
|
||||
export function parseSourcebotSearch(payload: unknown, limit: number): CodeSearchHit[] {
|
||||
const files = (payload as { files?: unknown })?.files;
|
||||
if (!Array.isArray(files)) return [];
|
||||
const hits: CodeSearchHit[] = [];
|
||||
for (const f of files as SourcebotFile[]) {
|
||||
const ref = f?.fileName?.text;
|
||||
if (typeof ref !== "string") continue;
|
||||
const chunk = f.chunks?.[0];
|
||||
const line = chunk?.matchRanges?.[0]?.start?.lineNumber;
|
||||
hits.push({
|
||||
ref: typeof line === "number" ? `${ref}:${line}` : ref,
|
||||
snippet: typeof chunk?.content === "string" ? chunk.content.trim() : undefined,
|
||||
kind: "file",
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* suggest — should the session-start indexing offer be made for this repo?
|
||||
*
|
||||
* The offer fires at most once per machine: never when a provider is already
|
||||
* selected, never after an explicit decline (`select none`), and never for
|
||||
* small repos where grep is already fast. Detection is cheap and local
|
||||
* (`git ls-files` count); a non-repo directory never triggers the offer.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import { resolve } from "path";
|
||||
import { readSelection } from "./selection";
|
||||
|
||||
// ponytail: single tracked-file-count knob for "large"; add a LOC signal if it misfires
|
||||
/** Tracked-file count at which indexing starts paying for itself. */
|
||||
export const LARGE_REPO_FILE_THRESHOLD = 1000;
|
||||
|
||||
export type SuggestReason =
|
||||
| "provider-selected"
|
||||
| "declined"
|
||||
| "not-a-repo"
|
||||
| "small-repo"
|
||||
| "large-repo";
|
||||
|
||||
export interface Suggestion {
|
||||
offer: boolean;
|
||||
reason: SuggestReason;
|
||||
fileCount: number | null;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
/** Count of git-tracked files, or null when the path is not a git repo. */
|
||||
export function trackedFileCount(repoPath: string): number | null {
|
||||
const result = spawnSync("git", ["-C", resolve(repoPath), "ls-files"], {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || typeof result.stdout !== "string") return null;
|
||||
const out = result.stdout.trim();
|
||||
return out ? out.split("\n").length : 0;
|
||||
}
|
||||
|
||||
export function shouldOfferIndexing(
|
||||
repoPath: string,
|
||||
opts: { env?: NodeJS.ProcessEnv; threshold?: number } = {},
|
||||
): Suggestion {
|
||||
const threshold = opts.threshold ?? LARGE_REPO_FILE_THRESHOLD;
|
||||
const selection = readSelection(opts.env);
|
||||
if (selection.provider) return { offer: false, reason: "provider-selected", fileCount: null, threshold };
|
||||
if (selection.declined) return { offer: false, reason: "declined", fileCount: null, threshold };
|
||||
const fileCount = trackedFileCount(repoPath);
|
||||
if (fileCount === null) return { offer: false, reason: "not-a-repo", fileCount, threshold };
|
||||
if (fileCount < threshold) return { offer: false, reason: "small-repo", fileCount, threshold };
|
||||
return { offer: true, reason: "large-repo", fileCount, threshold };
|
||||
}
|
||||
|
|
@ -915,6 +915,38 @@ Step 1.5 — fall through to Step 2 (where `no-cli` triggers Step 3 install and
|
|||
|
||||
---
|
||||
|
||||
## Step 1.7: Code-intelligence provider choice (Step 0 of indexing)
|
||||
|
||||
gbrain is one of THREE code-intelligence providers gstack can use; the offer
|
||||
gate decides whether this repo is even worth indexing:
|
||||
|
||||
```bash
|
||||
bun ~/.claude/skills/gstack/bin/gstack-code-intelligence suggest --json
|
||||
```
|
||||
|
||||
- `"offer": false` with reason `small-repo` → grep is already fast here; say
|
||||
so in one line and continue with this skill only if the user asked for
|
||||
gbrain by name.
|
||||
- `"offer": false` with reason `provider-selected` or `declined` → the
|
||||
machine-wide question was already answered; apply it silently and continue.
|
||||
- `"offer": true` → present the returned options ONCE via AskUserQuestion:
|
||||
**GBrain** (recommended — semantic memory + code, sends repo content to
|
||||
YOUR gbrain DB, per-repo consent), **Sourcebot** (self-hosted whole-repo
|
||||
search, local when on localhost), **Graphify** (local tree-sitter graph,
|
||||
nothing leaves the machine, user installs it), or **No indexing**. Record
|
||||
the choice: `gstack-code-intelligence select <provider|none>` — `none`
|
||||
persists the decline so NO skill ever asks again, on any repo
|
||||
(re-enable: `gstack-code-intelligence select <provider>`). Local-compute
|
||||
and remote-send providers are separate consents — never bundle them.
|
||||
- Per-repo send consent (GBrain/Sourcebot) is recorded with
|
||||
`gstack-code-intelligence consent <repo> yes|no` and is ALWAYS vetoed by a
|
||||
`deny` tier in gstack-gbrain-repo-policy — the trust store is the single
|
||||
authority for whether code leaves a repo.
|
||||
|
||||
If the user picked GBrain (or asked for this skill directly), continue below.
|
||||
If they picked Sourcebot/Graphify, run `gstack-code-intelligence index <repo>`
|
||||
and stop — the rest of this skill is gbrain-specific.
|
||||
|
||||
## Step 2: Pick a path (AskUserQuestion)
|
||||
|
||||
Only fire this if Step 1 shows no existing working config AND no shortcut
|
||||
|
|
|
|||
|
|
@ -161,6 +161,38 @@ Step 1.5 — fall through to Step 2 (where `no-cli` triggers Step 3 install and
|
|||
|
||||
---
|
||||
|
||||
## Step 1.7: Code-intelligence provider choice (Step 0 of indexing)
|
||||
|
||||
gbrain is one of THREE code-intelligence providers gstack can use; the offer
|
||||
gate decides whether this repo is even worth indexing:
|
||||
|
||||
```bash
|
||||
bun ~/.claude/skills/gstack/bin/gstack-code-intelligence suggest --json
|
||||
```
|
||||
|
||||
- `"offer": false` with reason `small-repo` → grep is already fast here; say
|
||||
so in one line and continue with this skill only if the user asked for
|
||||
gbrain by name.
|
||||
- `"offer": false` with reason `provider-selected` or `declined` → the
|
||||
machine-wide question was already answered; apply it silently and continue.
|
||||
- `"offer": true` → present the returned options ONCE via AskUserQuestion:
|
||||
**GBrain** (recommended — semantic memory + code, sends repo content to
|
||||
YOUR gbrain DB, per-repo consent), **Sourcebot** (self-hosted whole-repo
|
||||
search, local when on localhost), **Graphify** (local tree-sitter graph,
|
||||
nothing leaves the machine, user installs it), or **No indexing**. Record
|
||||
the choice: `gstack-code-intelligence select <provider|none>` — `none`
|
||||
persists the decline so NO skill ever asks again, on any repo
|
||||
(re-enable: `gstack-code-intelligence select <provider>`). Local-compute
|
||||
and remote-send providers are separate consents — never bundle them.
|
||||
- Per-repo send consent (GBrain/Sourcebot) is recorded with
|
||||
`gstack-code-intelligence consent <repo> yes|no` and is ALWAYS vetoed by a
|
||||
`deny` tier in gstack-gbrain-repo-policy — the trust store is the single
|
||||
authority for whether code leaves a repo.
|
||||
|
||||
If the user picked GBrain (or asked for this skill directly), continue below.
|
||||
If they picked Sourcebot/Graphify, run `gstack-code-intelligence index <repo>`
|
||||
and stop — the rest of this skill is gbrain-specific.
|
||||
|
||||
## Step 2: Pick a path (AskUserQuestion)
|
||||
|
||||
Only fire this if Step 1 shows no existing working config AND no shortcut
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
/**
|
||||
* Tests for lib/code-intelligence — the OPTIONAL, repo-oriented provider contract
|
||||
* with three REAL adapters (GBrain CLI, Graphify CLI, Sourcebot HTTP) and the
|
||||
* selection store the `gstack-code-intelligence` CLI drives.
|
||||
*
|
||||
* The Graphify and Sourcebot expectations here are pinned to the REAL formats
|
||||
* captured from live tools (graphify 0.9.23 NODE/EDGE query output; Sourcebot v5
|
||||
* `/api/search` response + Bearer auth), not invented shapes.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import {
|
||||
REQUIRED_CAPABILITIES,
|
||||
GbrainProvider,
|
||||
GraphifyProvider,
|
||||
SourcebotProvider,
|
||||
parseGbrainSearch,
|
||||
parseGraphifyQuery,
|
||||
parseSourcebotSearch,
|
||||
readSelection,
|
||||
setProvider,
|
||||
setConsent,
|
||||
hasConsent,
|
||||
setRoot,
|
||||
getRoot,
|
||||
resolveSelectedProvider,
|
||||
RECOMMENDED_ORDER,
|
||||
shouldOfferIndexing,
|
||||
trackedFileCount,
|
||||
} from "../lib/code-intelligence";
|
||||
|
||||
describe("capability matrix", () => {
|
||||
test("every provider advertises the four required capabilities", () => {
|
||||
for (const p of [new GbrainProvider(), new SourcebotProvider(), new GraphifyProvider()]) {
|
||||
for (const cap of REQUIRED_CAPABILITIES) expect(p.has(cap)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("only GBrain advertises the document ops; local flags are right", () => {
|
||||
const g = new GbrainProvider();
|
||||
expect(g.local).toBe(false);
|
||||
for (const cap of ["add", "delete", "export"] as const) expect(g.has(cap)).toBe(true);
|
||||
|
||||
const s = new SourcebotProvider({ baseUrl: "http://localhost:3000" });
|
||||
expect(s.local).toBe(true); // loopback → content stays on machine
|
||||
expect(s.has("add")).toBe(false);
|
||||
expect(new SourcebotProvider({ baseUrl: "https://sb.example.com" }).local).toBe(false);
|
||||
|
||||
const gf = new GraphifyProvider();
|
||||
expect(gf.local).toBe(true);
|
||||
expect(gf.has("export")).toBe(true);
|
||||
expect(gf.has("add")).toBe(false);
|
||||
});
|
||||
|
||||
test("RECOMMENDED_ORDER puts GBrain first", () => {
|
||||
expect([...RECOMMENDED_ORDER]).toEqual(["gbrain", "sourcebot", "graphify"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsers (pinned to real tool output)", () => {
|
||||
test("parseGbrainSearch (text surface)", () => {
|
||||
const hits = parseGbrainSearch("[0.91] slug/a -- one\nbanner\n[0.05] slug/b -- low", 0.1, 10);
|
||||
expect(hits).toEqual([{ ref: "slug/a", score: 0.91, snippet: "one", kind: "document" }]);
|
||||
});
|
||||
|
||||
test("parseGraphifyQuery reads file:line from real NODE/EDGE lines", () => {
|
||||
// Verbatim shape from graphify 0.9.23 `query ... --graph`.
|
||||
const real = [
|
||||
"Traversal: BFS depth=2 | Start: ['query()'] | Context: call (heuristic) | 4 nodes found",
|
||||
"",
|
||||
"NODE query() [src=db.py loc=L4 community=login]",
|
||||
"EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8",
|
||||
].join("\n");
|
||||
const hits = parseGraphifyQuery(real, 10);
|
||||
expect(hits.map((h) => h.ref)).toEqual(["db.py:L4", "auth.py:L8"]); // NOT "graphify"
|
||||
expect(hits.every((h) => h.kind === "graph-node")).toBe(true);
|
||||
// The Traversal header must NOT become a bogus hit.
|
||||
expect(hits.some((h) => h.snippet?.startsWith("Traversal:"))).toBe(false);
|
||||
});
|
||||
|
||||
test("parseSourcebotSearch maps files to file:line hits (real v5 shape)", () => {
|
||||
const real = {
|
||||
files: [
|
||||
{
|
||||
fileName: { text: "src/checksum.ts", matchRanges: [] },
|
||||
repository: "github.com/example/sb-sample",
|
||||
chunks: [{ content: "export function computeChecksum(data: string): number {", matchRanges: [{ start: { byteOffset: 58, column: 17, lineNumber: 2 } }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(parseSourcebotSearch(real, 10)).toEqual([
|
||||
{ ref: "src/checksum.ts:2", snippet: "export function computeChecksum(data: string): number {", kind: "file" },
|
||||
]);
|
||||
expect(parseSourcebotSearch("nope", 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection store + provider-OFF", () => {
|
||||
let home: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-"));
|
||||
env = { ...process.env, GSTACK_HOME: home };
|
||||
});
|
||||
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
test("no selection = provider-OFF (null)", () => {
|
||||
expect(readSelection(env).provider).toBeNull();
|
||||
expect(resolveSelectedProvider({ env })).toBeNull();
|
||||
});
|
||||
|
||||
test("select persists and resolves the provider", () => {
|
||||
setProvider("graphify", env);
|
||||
expect(readSelection(env).provider).toBe("graphify");
|
||||
expect(resolveSelectedProvider({ env })?.id).toBe("graphify");
|
||||
});
|
||||
|
||||
test("consent is per-repo", () => {
|
||||
const repo = path.join(home, "repoA");
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
setConsent(repo, true, env);
|
||||
expect(hasConsent(repo, env)).toBe(true);
|
||||
expect(hasConsent(path.join(home, "repoB"), env)).toBe(false);
|
||||
});
|
||||
|
||||
test("indexed root persists per provider (so search reads the same graph)", () => {
|
||||
expect(getRoot("graphify", env)).toBeUndefined();
|
||||
setRoot("graphify", "/tmp/some/repo", env);
|
||||
expect(getRoot("graphify", env)).toBe(path.resolve("/tmp/some/repo"));
|
||||
});
|
||||
|
||||
test("select none records the decline; selecting a provider clears it", () => {
|
||||
setProvider(null, env);
|
||||
expect(readSelection(env).declined).toBe(true);
|
||||
setProvider("graphify", env);
|
||||
expect(readSelection(env).declined).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session-start indexing offer (suggest)", () => {
|
||||
let home: string;
|
||||
let repo: string;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-home-"));
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-repo-"));
|
||||
env = { ...process.env, GSTACK_HOME: home };
|
||||
Bun.spawnSync(["git", "init", "-q", repo]);
|
||||
for (const name of ["a.ts", "b.ts", "c.ts"]) fs.writeFileSync(path.join(repo, name), "x\n");
|
||||
Bun.spawnSync(["git", "-C", repo, "add", "-A"]);
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("offers exactly once: large repo with no prior decision", () => {
|
||||
const s = shouldOfferIndexing(repo, { env, threshold: 3 });
|
||||
expect(s).toEqual({ offer: true, reason: "large-repo", fileCount: 3, threshold: 3 });
|
||||
});
|
||||
|
||||
test("small repo → no offer", () => {
|
||||
expect(shouldOfferIndexing(repo, { env, threshold: 4 }).reason).toBe("small-repo");
|
||||
});
|
||||
|
||||
test("not a git repo → no offer", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-plain-"));
|
||||
try {
|
||||
expect(shouldOfferIndexing(dir, { env, threshold: 0 }).reason).toBe("not-a-repo");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider already selected → no offer", () => {
|
||||
setProvider("graphify", env);
|
||||
expect(shouldOfferIndexing(repo, { env, threshold: 3 }).reason).toBe("provider-selected");
|
||||
});
|
||||
|
||||
test("explicit decline → never asked again", () => {
|
||||
setProvider(null, env);
|
||||
expect(shouldOfferIndexing(repo, { env, threshold: 3 }).reason).toBe("declined");
|
||||
});
|
||||
|
||||
test("trackedFileCount counts git-tracked files only", () => {
|
||||
fs.writeFileSync(path.join(repo, "untracked.ts"), "x\n");
|
||||
expect(trackedFileCount(repo)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("egress consent gate", () => {
|
||||
test("GBrain (non-local) registerSource without consent → PROVIDER_NOT_CONSENTED", async () => {
|
||||
await expect(new GbrainProvider().registerSource({ id: "code", path: "/repo" })).rejects.toMatchObject({
|
||||
code: "PROVIDER_NOT_CONSENTED",
|
||||
});
|
||||
});
|
||||
|
||||
test("Graphify (local) is exempt from the egress gate", async () => {
|
||||
await expect(
|
||||
new GraphifyProvider({ env: { PATH: "/nonexistent" } }).registerSource({ id: "r", path: os.tmpdir() }),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Graphify adapter (fake graphify shim, real NODE/EDGE format)", () => {
|
||||
let binDir: string;
|
||||
let repo: string;
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { PATH: `${binDir}:${process.env.PATH}` };
|
||||
}
|
||||
beforeEach(() => {
|
||||
binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-bin-"));
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gf-repo-"));
|
||||
// Shim emulates real graphify 0.9.23: `update <path>` writes graph.json (no LLM);
|
||||
// `query <q> --graph <path>` prints NODE/EDGE lines.
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, "graphify"),
|
||||
`#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
--version) echo "graphify 0.9.23"; exit 0;;
|
||||
update) mkdir -p "$2/graphify-out"; echo '{"nodes":[1,2,3,4,5],"edges":[]}' > "$2/graphify-out/graph.json"; echo "Rebuilt: 5 nodes, 8 edges"; exit 0;;
|
||||
query)
|
||||
echo "Traversal: BFS depth=2 | Start: ['query()'] | 4 nodes found"
|
||||
echo ""
|
||||
echo "NODE query() [src=db.py loc=L4 community=login]"
|
||||
echo "EDGE query() --calls [EXTRACTED context=call]--> login() at=auth.py:L8"
|
||||
exit 0;;
|
||||
esac
|
||||
exit 1
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("index builds a graph (via `graphify update`) and status counts nodes", async () => {
|
||||
const gf = new GraphifyProvider({ root: repo, env: env() });
|
||||
const reg = await gf.registerSource({ id: repo, path: repo });
|
||||
expect(reg.state).toBe("ready");
|
||||
expect(reg.itemCount).toBe(5);
|
||||
expect(fs.existsSync(path.join(repo, "graphify-out", "graph.json"))).toBe(true);
|
||||
});
|
||||
|
||||
test("search reads file:line refs from real query output", async () => {
|
||||
const gf = new GraphifyProvider({ root: repo, env: env() });
|
||||
await gf.registerSource({ id: repo, path: repo });
|
||||
const hits = await gf.search("what calls db", { source: repo });
|
||||
expect(hits.map((h) => h.ref)).toEqual(["db.py:L4", "auth.py:L8"]);
|
||||
});
|
||||
|
||||
test("missing graphify CLI degrades to PROVIDER_UNAVAILABLE", async () => {
|
||||
await expect(
|
||||
new GraphifyProvider({ root: repo, env: { PATH: os.tmpdir() } }).search("q"),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sourcebot adapter (injected fetch, real v5 auth + shape)", () => {
|
||||
test("registerSource writes a local git connection to config.json", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-sb-"));
|
||||
const configPath = path.join(dir, "config.json");
|
||||
await new SourcebotProvider({ baseUrl: "http://localhost:3000", configPath }).registerSource({ id: "myrepo", path: "/abs/repo" });
|
||||
const written = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
expect(written.connections.myrepo).toEqual({ type: "git", url: "file:///abs/repo" });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("search sends Bearer auth and maps the real v5 response to hits", async () => {
|
||||
const seen: Array<{ url: string; auth: string | null }> = [];
|
||||
const fetchStub = (async (url: string, init: RequestInit) => {
|
||||
seen.push({ url: String(url), auth: (init.headers as Record<string, string>)?.Authorization ?? null });
|
||||
return new Response(
|
||||
JSON.stringify({ files: [{ fileName: { text: "a.ts" }, chunks: [{ content: "x", matchRanges: [{ start: { lineNumber: 3 } }] }] }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", apiKey: "sbk_test", fetch: fetchStub });
|
||||
const hits = await sb.search("foo");
|
||||
expect(seen[0].url).toBe("http://localhost:3000/api/search");
|
||||
expect(seen[0].auth).toBe("Bearer sbk_test");
|
||||
expect(hits).toEqual([{ ref: "a.ts:3", snippet: "x", kind: "file" }]);
|
||||
});
|
||||
|
||||
test("401 (no API key) degrades to PROVIDER_UNAVAILABLE, not PROVIDER_ERROR", async () => {
|
||||
const fetchStub = (async () =>
|
||||
new Response(JSON.stringify({ errorCode: "NOT_AUTHENTICATED" }), { status: 401 })) as unknown as typeof fetch;
|
||||
await expect(
|
||||
new SourcebotProvider({ baseUrl: "http://localhost:3000", fetch: fetchStub }).search("q"),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
test("unreachable server degrades to PROVIDER_UNAVAILABLE", async () => {
|
||||
const fetchStub = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
await expect(
|
||||
new SourcebotProvider({ baseUrl: "http://localhost:3999", fetch: fetchStub }).search("q"),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
test("registerSource without SOURCEBOT_CONFIG → PROVIDER_UNAVAILABLE", async () => {
|
||||
await expect(
|
||||
new SourcebotProvider({ baseUrl: "http://localhost:3000", env: {} }).registerSource({ id: "r", path: "/x" }),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GBrain adapter (fake gbrain shim)", () => {
|
||||
let binDir: string;
|
||||
let homeDir: string;
|
||||
function env(): NodeJS.ProcessEnv {
|
||||
return { PATH: `${binDir}:${process.env.PATH}`, HOME: homeDir };
|
||||
}
|
||||
function writeShim(body: string): void {
|
||||
fs.writeFileSync(path.join(binDir, "gbrain"), body, { mode: 0o755 });
|
||||
}
|
||||
beforeEach(() => {
|
||||
binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-bin-"));
|
||||
homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-home-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("search parses hits (and sends no --source: gbrain search is global)", async () => {
|
||||
writeShim(`#!/usr/bin/env bash
|
||||
if [ "$1" = "search" ]; then
|
||||
if printf '%s ' "$@" | grep -q -- "--source"; then echo "[0.0] ERR -- adapter sent phantom --source"; exit 0; fi
|
||||
echo "[0.88] src/x.ts -- match"; exit 0
|
||||
fi
|
||||
exit 1
|
||||
`);
|
||||
const hits = await new GbrainProvider().search("where", { env: env() });
|
||||
expect(hits).toEqual([{ ref: "src/x.ts", score: 0.88, snippet: "match", kind: "document" }]);
|
||||
});
|
||||
|
||||
test("refresh runs the code-indexing pass (`sync --strategy code --full`)", async () => {
|
||||
// Real gbrain only indexes code when `sync --strategy code` runs; without it
|
||||
// code-def stays not_built. Pin that the adapter issues that pass.
|
||||
const marker = path.join(homeDir, "sync-calls.log");
|
||||
writeShim(`#!/usr/bin/env bash
|
||||
if [ "$1" = "sync" ]; then printf '%s\\n' "$*" >> "${marker}"; exit 0; fi
|
||||
if [ "$1" = "sources" ]; then echo '{"sources":[{"id":"code","local_path":"/r","page_count":1}]}'; exit 0; fi
|
||||
exit 1
|
||||
`);
|
||||
await new GbrainProvider().refresh({ id: "code" }, { env: env(), consented: true });
|
||||
const log = fs.readFileSync(marker, "utf-8");
|
||||
expect(log).toContain("--strategy code");
|
||||
expect(log).toContain("--full");
|
||||
});
|
||||
|
||||
test("engine-down (pglite WASM) degrades to PROVIDER_UNAVAILABLE, not PROVIDER_ERROR", async () => {
|
||||
// Reproduces garrytan/gbrain#223: engine fails to init; must degrade cleanly.
|
||||
writeShim(`#!/usr/bin/env bash
|
||||
echo "PGLite failed to initialize its WASM runtime." >&2
|
||||
echo " Original error: Aborted()." >&2
|
||||
exit 1
|
||||
`);
|
||||
await expect(new GbrainProvider().search("q", { env: env() })).rejects.toMatchObject({
|
||||
code: "PROVIDER_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => {
|
||||
await expect(
|
||||
new GbrainProvider().search("q", { env: { PATH: binDir, HOME: homeDir } }),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── R1: the repo-policy deny tier vetoes recorded consent ───────────────────
|
||||
// Two consent stores must never disagree about whether code may leave a repo:
|
||||
// gstack-gbrain-repo-policy (per-remote trust tiers) is the single authority.
|
||||
describe("consent unification — deny tier wins (R1)", () => {
|
||||
function makeRepo(dir: string, url: string): string {
|
||||
const repo = path.join(dir, "repo");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
const git = (...a: string[]) => execFileSync("git", a, { cwd: repo });
|
||||
git("init", "-q", ".");
|
||||
git("remote", "add", "origin", url);
|
||||
return repo;
|
||||
}
|
||||
const POLICY_BIN = path.join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
|
||||
const URL = "https://github.com/acme/veto-widget.git";
|
||||
|
||||
test("recorded consent survives when no policy store exists", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
|
||||
try {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
expect(hasConsent(repo, env)).toBe(true);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("deny tier vetoes recorded consent", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
|
||||
try {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" });
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
// Flipping the tier back restores the recorded consent — the veto is
|
||||
// live policy, not a destructive rewrite of the consent store.
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
|
||||
expect(hasConsent(repo, env)).toBe(true);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("unreadable policy store fails closed (consent vetoed)", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
|
||||
try {
|
||||
const env = { ...process.env, GSTACK_HOME: home };
|
||||
const repo = makeRepo(home, URL);
|
||||
setConsent(repo, true, env);
|
||||
execFileSync(POLICY_BIN, ["set", URL, "read-write"], { env, encoding: "utf-8" });
|
||||
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o000);
|
||||
try {
|
||||
expect(hasConsent(repo, env)).toBe(false);
|
||||
} finally {
|
||||
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o600);
|
||||
}
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
|
@ -67,6 +67,13 @@ const MODULE_SINKS = [
|
|||
'bin/gstack-gbrain-sync.ts',
|
||||
'bin/gstack-memory-ingest.ts',
|
||||
'browse/src/server.ts',
|
||||
// Code-intelligence adapters (fork port wave 2): the gbrain adapter shells
|
||||
// repo content to the user's gbrain DB and the Sourcebot adapter POSTs
|
||||
// queries to a self-hosted HTTP endpoint — both sensitive-class
|
||||
// (repo-content) sinks, fail-closed. Registered so a refactor that drops
|
||||
// their writeReceipt calls fails CI, not just the tree-sweep scanner.
|
||||
'lib/code-intelligence/gbrain-adapter.ts',
|
||||
'lib/code-intelligence/sourcebot-adapter.ts',
|
||||
// Unconditional: context-bill ships in the same tree as this tripwire. A
|
||||
// missing file must fail loudly (a rename/move that drops its receipt wiring
|
||||
// is exactly what this pins), not silently soften the assertion.
|
||||
|
|
|
|||
Loading…
Reference in New Issue