Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ on a schema mismatch is **rebuild** (ADR-0006).

## [Unreleased]

### Added

- **feat-015 — read-only graph query surface (`ckg query --graph` / `ckg_query`).**
An escape hatch for precise **structural** questions no typed verb covers
(e.g. "classes tagged `Repository` with no inbound `CALLS`", "interfaces
implemented by >5 classes"). A bounded, read-only **Cypher subset**:
- **CLI:** `ckg query --graph '<cypher>'` with `--format table|json`,
`--limit`, and `ckg query --schema` (the queryable vocabulary).
- **MCP:** a `ckg_query` tool, registered only when the backend is
query-capable and `query.allow_in_mcp` is on; results carry the staleness +
`query_lang_version` envelope.
- **Safety by design:** caller text is never executed — it is parsed into a
validated AST (the single trust boundary), then **compiled** to native
Cypher (Kuzu, Neo4j) or **interpreted** over the storage API (SurrealDB and
any backend without a native query language). All three backends return
identical rows (a shared conformance suite proves it). Writes/DDL, procedure
calls, unbounded paths, and un-joined Cartesian products are rejected with a
clear reason.
- **Bounded, no silent caps:** `query.max_rows` / `timeout_ms` /
`max_expansions` are enforced on every backend and reported via `truncated` +
`stopped_reason`. `ckg status` reports the query-language version and whether
the active backend is query-capable.
- New `query:` config block; guide 13. `NodeKind`/`EdgeKind` vocabulary
unchanged.

## [0.6.3] — 2026-07-07

### Added
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,11 @@ provenance.

### Serve it to an agent

Read-only over MCP — **10 tools**: `ckg_repo_map`, `ckg_search`, `ckg_symbol`,
`ckg_impact`, `ckg_neighbors`, `ckg_status`, `ckg_routes`, `ckg_decisions`,
`ckg_explain`, `ckg_history`:
Read-only over MCP — **10 typed tools**: `ckg_repo_map`, `ckg_search`,
`ckg_symbol`, `ckg_impact`, `ckg_neighbors`, `ckg_status`, `ckg_routes`,
`ckg_decisions`, `ckg_explain`, `ckg_history` — plus `ckg_query` (a read-only
[structural query](docs/guides/13-graph-query.md), added when the backend is
query-capable):

```bash
ckg setup # wire your agent for you (writes .mcp.json)
Expand Down
1 change: 1 addition & 0 deletions docs/design/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ history. Supersede with a new doc; never delete.
|---|---|---|
| [design-001-core-contracts-module](design-001-core-contracts-module.md) | feat-001 | accepted |
| [design-009-temporal-evolution-layer](design-009-temporal-evolution-layer.md) | feat-009 | accepted |
| [design-015-read-only-graph-query](design-015-read-only-graph-query.md) | feat-015 | accepted |

_(Add a row per feature as its design is written.)_
506 changes: 506 additions & 0 deletions docs/design/design-015-read-only-graph-query.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/features/TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Legend: `proposed` → `accepted` → `in-progress` → `shipped` (also
| [012](feat-012-llm-enrichment.md) | LLM enrichment (summaries, tags) | 3 diff | 0.4 | shipped (tags + summaries) | 006 | — | ✅ |
| [013](feat-013-agent-auto-configuration.md) | Agent auto-configuration & frictionless first run | 4 adoption | 0.6.2 | **shipped (0.6.2)** | 008 | — | ✅ |
| [014](feat-014-watch-and-ci-indexing.md) | Watch mode (local) + CI-triggered central indexing | 4 adoption | 0.6.3 | **shipped (0.6.3)** | 004 | — | ✅ |
| [015](feat-015-read-only-graph-query.md) | Read-only graph query (`ckg query --graph` / `ckg_query`) | 1 serve | 0.6.4 | **in-progress (chunk 1)** | 001,003,008 | — | ✅ |

---

Expand Down
321 changes: 321 additions & 0 deletions docs/features/feat-015-read-only-graph-query.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/guides/10-using-over-mcp.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Using the CKG from an agent (MCP) or in-process

> **TL;DR:** Serve the graph over **10 read-only tools** — an MCP server
> **TL;DR:** Serve the graph over **10 read-only tools** (plus `ckg_query`, the
> [structural-query](13-graph-query.md) escape hatch, when the backend supports
> it) — an MCP server
> (`ckg serve-mcp`, stdio or HTTP) for external clients like Claude Code, or the
> in-process AgentForge toolset for framework agents. Every response carries a
> staleness envelope.
Expand Down
116 changes: 116 additions & 0 deletions docs/guides/13-graph-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Ad-hoc structural queries (`ckg query --graph` / `ckg_query`)

> **TL;DR:** For precise **structural** questions no typed verb answers — "public
> methods on classes tagged `Repository` with no inbound `CALLS`", "interfaces
> implemented by >5 classes" — run a **read-only Cypher subset** query:
> `ckg query --graph 'MATCH … WHERE … RETURN …'` or the `ckg_query` MCP tool. It is
> the escape hatch; for semantic "find code about X" use `ckg_search`.

The typed verbs (`ckg search`, `ckg impact`, `ckg routes`, …) each answer one
question well, but they are a **closed set**. When a question doesn't map to a
verb, the query surface lets you ask it directly against the graph — without us
shipping a new verb — while keeping the same read-only safety and staleness
guarantees (feat-015).

Retrieval vs query, at a glance: **retrieval** (`ckg_search`) discovers code by
meaning (vectors + ranking); **query** interrogates the graph with exact
predicates. Use retrieval to find, query to filter/aggregate.

---

## 1. The surface

```bash
# structural query (read-only)
ckg query --graph 'MATCH (f:Function) WHERE NOT (f)<-[:CALLS]-() RETURN f.name, f.path LIMIT 50'

# output formats
ckg query --graph '<q>' --format table # default, aligned columns
ckg query --graph '<q>' --format json # {columns, rows, truncated, stopped_reason}

# see the queryable vocabulary (node/edge kinds + properties)
ckg query --schema
```

The natural-language path — `ckg query "how does auth work"` (hybrid retrieval) —
is unchanged; `--graph` selects the structural surface.

Over MCP, the `ckg_query` tool takes `{ query, limit? }` and returns the columnar
result plus the usual staleness envelope (`indexed_commit`, `dirty`, `truncated`,
`query_lang_version`). It is registered only when the backend is query-capable and
`query.allow_in_mcp` is on.

---

## 2. The language (a bounded, read-only Cypher subset)

Supported (see `ckg query --schema` for the live vocabulary):

- **`MATCH`** node/relationship patterns over the locked node/edge kinds:
`(c:Class)-[:IMPLEMENTS]->(i:Interface)`, directions `->`/`<-`/`-`, bounded
variable-length `[:CALLS*1..3]`.
- **`WHERE`** — comparisons (`= <> < <= > >=`), `AND`/`OR`/`NOT`, `IN […]`, string
predicates (`STARTS WITH` / `ENDS WITH` / `CONTAINS`), and pattern existence
(`(a)-[:KIND]->(b)`).
- **`RETURN`** — property projections, aliases (`AS`), `DISTINCT`, and aggregates
`count` / `min` / `max` / `avg` / `collect`.
- **`ORDER BY … [ASC|DESC]`, `SKIP`, `LIMIT`.**

Queryable node properties (mapped identically on every backend): `name`, `kind`,
`path`, `start_line`, `end_line`, `source`, `extractor`, `commit`, `confidence`.

**Rejected** (with a clear reason, never a stack trace): any write/DDL
(`CREATE/MERGE/SET/DELETE/…`), procedure `CALL`, unbounded paths (`[*]`), and
disconnected patterns that would form a Cartesian product.

### Examples

```cypher
-- classes tagged "Repository"
MATCH (c:Class)-[:TAGGED]->(t:PatternTag) WHERE t.name = "Repository" RETURN c.name

-- interfaces by how many classes implement them
MATCH (c:Class)-[:IMPLEMENTS]->(i:Interface)
RETURN i.name, count(c) AS impls ORDER BY impls DESC

-- functions nothing calls (dead-ish code)
MATCH (f:Function) WHERE NOT (f)<-[:CALLS]-() RETURN f.name, f.path

-- what a function reaches within 3 call hops
MATCH (f:Function {name: "handle_login"})-[:CALLS*1..3]->(g:Function) RETURN DISTINCT g.name
```

---

## 3. Safety & bounds

The query is never executed as caller text: it is parsed into a validated
internal AST (the single trust boundary), then either **compiled** to the
backend's native Cypher (Kuzu, Neo4j) or **interpreted** over the storage API
(SurrealDB and any backend without a native query language). Every backend
returns identical rows — proven by a shared conformance suite.

Every run is bounded and the caps are reported (never silently applied):

```yaml
query:
enabled: true # disable the structural surface wholesale
max_rows: 1000 # row cap; a clipped result sets truncated=true
timeout_ms: 5000 # per-query wall-clock budget
max_expansions: 50000 # intermediate-row / traversal ceiling
allow_in_mcp: true # expose ckg_query as an MCP tool (vs CLI-only)
```

When a bound trips, the result carries `truncated: true` and a `stopped_reason`
(`row_cap` / `expansion_cap` / `timeout`), so a partial answer is never mistaken
for a complete one. `ckg status` reports the query-language version and whether
the active backend is query-capable.

---

## 4. When to use a query vs a verb

Reach for `ckg_query` when the question is **structural and exact** and no verb
fits. If you find yourself writing the *same* query repeatedly, that is a signal
it should become a first-class verb — tell us; the common ad-hoc queries are the
best candidates to promote.
1 change: 1 addition & 0 deletions docs/guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ setup. They build on each other in order.
| 10 | [Using over MCP](10-using-over-mcp.md) | Serve the CKG to an agent over 10 read-only tools (stdio/HTTP) or in-process. |
| 11 | [Agent auto-configuration](11-agent-auto-configuration.md) | `ckg setup` writes your agent's MCP config (repo `.mcp.json` / `--scope user`) + `--hooks` nudge; reversible. |
| 12 | [Watch mode & CI indexing](12-watch-and-ci-indexing.md) | Keep the graph fresh: `ckg watch` (local, conditional triggers) + `ckg ci init` (central, CI-as-sole-writer). |
| 13 | [Ad-hoc structural queries](13-graph-query.md) | Escape hatch for structural questions no verb covers: a read-only Cypher subset via `ckg query --graph` / the `ckg_query` tool. |

> New here? Start with **[Getting started → a single repo](getting-started/1-single-repo.md)**.
> Building an agent on top? Jump to **[10 — Using over MCP](10-using-over-mcp.md)**.
Expand Down
96 changes: 96 additions & 0 deletions src/agentforge_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,16 @@ async def _map(args: argparse.Namespace) -> int:


async def _query(args: argparse.Namespace) -> int:
# feat-015: --schema and --graph select the structural surface; the
# natural-language retrieval path (below) is unchanged.
if args.schema:
return await _query_schema(args)
if args.graph is not None:
return await _query_graph(args)
if args.limit is not None:
print("--limit only applies to --graph", file=sys.stderr)
return 2

from agentforge_graph.temporal import TemporalError

cg = await CodeGraph.open(repo_path=args.path, config=args.config)
Expand All @@ -756,6 +766,68 @@ async def _query(args: argparse.Namespace) -> int:
return 0


async def _query_schema(args: argparse.Namespace) -> int:
"""feat-015: print the queryable vocabulary (needs no index build)."""
from agentforge_graph.cli_format import render_table

cg = await CodeGraph.open(repo_path=args.path, config=args.config)
try:
desc = cg.describe_schema()
finally:
await cg.close()
if args.format == "json":
import json

print(json.dumps(desc.to_dict(), indent=2))
return 0
print(f"query language v{desc.lang_version}\n")
print("node kinds: " + ", ".join(desc.node_kinds))
print("edge kinds: " + ", ".join(desc.edge_kinds) + "\n")
print(
render_table(
("property", "type", "description"),
[(p.name, p.type, p.doc) for p in desc.node_properties],
)
)
print("\n" + desc.attrs_note)
return 0


async def _query_graph(args: argparse.Namespace) -> int:
"""feat-015: run a read-only structural query and render the result."""
from agentforge_graph.cli_format import render_json, render_table
from agentforge_graph.config import QueryConfig, resolve_config
from agentforge_graph.store.query import QueryDisabled, QueryError

qcfg = QueryConfig.load(resolve_config(args.config, args.path))
if not qcfg.enabled:
print("the query surface is disabled (query.enabled=false)", file=sys.stderr)
return 2
cg = await CodeGraph.open(repo_path=args.path, config=args.config)
try:
rt = await cg.query_graph(args.graph, qcfg.to_settings(args.limit))
except QueryError as exc:
print(f"query error: {exc}", file=sys.stderr)
return 2
except QueryDisabled as exc:
print(f"query unavailable: {exc}", file=sys.stderr)
return 2
finally:
await cg.close()

if args.format == "json":
print(
render_json(
rt.columns, rt.rows, truncated=rt.truncated, stopped_reason=rt.stopped_reason
)
)
else:
print(render_table(rt.columns, rt.rows))
if rt.truncated:
print(f"... result truncated ({rt.stopped_reason})", file=sys.stderr)
return 0


async def _doctor(args: argparse.Namespace) -> int:
"""ENH-026: validate config readiness without indexing — for one repo or a
whole workspace, reporting every problem (and its fix) in one pass."""
Expand Down Expand Up @@ -925,6 +997,30 @@ def build_parser() -> argparse.ArgumentParser:
help="reconstruct results as of a git commit (feat-009; needs temporal + backfill)",
)
qry.add_argument("--config", default=None, help="path to ckg.yaml")
# feat-015: read-only structural query surface (Cypher subset).
qry.add_argument(
"--graph",
metavar="CYPHER",
default=None,
help="run a read-only structural query (Cypher subset) instead of NL retrieval",
)
qry.add_argument(
"--schema",
action="store_true",
help="print the queryable vocabulary (node/edge kinds + properties) and exit",
)
qry.add_argument(
"--format",
default="table",
choices=["table", "json"],
help="output format for --graph/--schema (default: table)",
)
qry.add_argument(
"--limit",
type=int,
default=None,
help="cap rows for --graph (clamped to the server max)",
)
qry.set_defaults(func=_query)

mp = sub.add_parser("map", help="print a budget-aware, centrality-ranked repo map")
Expand Down
56 changes: 56 additions & 0 deletions src/agentforge_graph/cli_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Reusable columnar output rendering for the CLI (feat-015).

Introduced for ``ckg query --graph`` but written as a standalone helper other
verbs can adopt later without a rewrite — not inline in the query handler. Two
formats: an aligned text table (human) and JSON rows (machine).
"""

from __future__ import annotations

import json
from collections.abc import Sequence
from typing import Any


def _cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)


def render_table(columns: Sequence[str], rows: Sequence[Sequence[Any]]) -> str:
"""A monospace, left-aligned table with a header underline. Empty result
still prints the header so the shape is visible."""
headers = list(columns)
body = [[_cell(v) for v in row] for row in rows]
widths = [len(h) for h in headers]
for row in body:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
lines = [
" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)),
" ".join("-" * widths[i] for i in range(len(headers))),
]
lines += [" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)) for row in body]
if not body:
lines.append("(no rows)")
return "\n".join(lines)


def render_json(
columns: Sequence[str],
rows: Sequence[Sequence[Any]],
*,
truncated: bool = False,
stopped_reason: str | None = None,
) -> str:
"""JSON with the column order, row arrays, and the truncation envelope."""
payload = {
"columns": list(columns),
"rows": [list(row) for row in rows],
"truncated": truncated,
"stopped_reason": stopped_reason,
}
return json.dumps(payload, indent=2, default=str)
Loading