Add project scoping, project CRUD, CLI config file, and Neo4j docs

Scope all AST nodes/queries by project, add /api/projects CRUD endpoints
and matching ac-cli commands (use, project create/update/delete/list,
-p/--project), persist server URL and selected project to
~/.agenticcode/config.properties, and add a Neo4j/Cypher introduction
for the team. Also clarify CLAUDE.md's commit policy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ingo Schnabel
2026-06-14 09:20:59 +02:00
parent 5ea36f7c4a
commit da9308e98a
26 changed files with 1264 additions and 93 deletions

View File

@@ -1,11 +1,24 @@
# AgenticCode
## 0. Hard Rules
* **NEVER ask questions in response text.** Every question to the user MUST go through the `AskUserQuestion` tool. No
exceptions. If you need to ask something, use `AskUserQuestion`. If you need clarification, use `AskUserQuestion`. If
you need a decision, use `AskUserQuestion`. The tool provides a freetext option automatically — use it.
* **Never assume anything about the user's intent.** Ask questions and wait for the user to clarify.
## 1. Core Rules
- Never assume. Always read files first with tools.
- Make all assumptions explicit and visible.
- Prioritize good structure, modularization, and maintainability.
- Keep solutions simple — only as abstract as necessary.
- Validate state before and after changes using terminal commands.
- Remove dead/unreachable code immediately (ask first if unsure).
- Be willing to backtrack if heading in the wrong direction.
- Trivial tasks: Ask "Trivial? Direct edit or full workflow?"
- Never run `git commit` (or `git push`) without the user explicitly asking for it in that turn. A prior commit approval
does not carry over to later changes.
## 2. Mandatory 4-Phase Workflow
@@ -15,7 +28,7 @@
1. **PROPOSAL** – Clear plan + impact. Wait for `OK PROPOSAL`.
2. **VALIDATE** – Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`.
3. **IMPLEMENT** – One unit at a time. Show exact diff only.
4. **VERIFICATION** – Run relevant tests. Suggest commit message.
4. **VERIFICATION** – Run relevant tests. Suggest a commit message, but do not commit unless asked (see Core Rules).
Quarkus-based server for parsing, storing, and agentically querying source code (Natural/Software AG and Java).
Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched with semantic information, and exposed via an agent-optimized API.

View File

@@ -61,14 +61,19 @@ flowchart TD
## Key API Tools (Agent-Facing)
| Endpoint | Description |
|---|---|
| `POST /api/ingest` | Parse and ingest source files into the graph |
| `GET /api/modules/{name}/callers` | Who calls this module? |
| `GET /api/modules/{name}/callees` | What does this module call? |
| `GET /api/modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
| `GET /api/search/identifier?name=` | Find an identifier across all modules |
| `MCP tools/*` | All of the above as MCP tool-use endpoints |
| Endpoint | Description |
|----------------------------------------------------------|-----------------------------------------------------------------|
| `GET /api/projects` | List all projects |
| `POST /api/projects/{project}` | Create a project |
| `PUT /api/projects/{project}` | Update a project's description |
| `DELETE /api/projects/{project}` | Delete a project and all its data |
| `POST /api/projects/{project}/ingest/java` | Parse and ingest a Java source file into the project's graph |
| `POST /api/projects/{project}/ingest/natural` | Parse and ingest a Natural source file into the project's graph |
| `GET /api/projects/{project}/modules/{name}/callers` | Who calls this module? |
| `GET /api/projects/{project}/modules/{name}/callees` | What does this module call? |
| `GET /api/projects/{project}/modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
| `GET /api/projects/{project}/search/identifier?name=` | Find an identifier across all modules in the project |
| `MCP tools/*` | All of the above as MCP tool-use endpoints |
---
@@ -183,7 +188,11 @@ mvn -pl ac-cli -am package
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar --help
```
By default the CLI talks to `http://localhost:8080`. Override with `-s/--server <url>` or the `AC_SERVER_URL` environment variable.
By default the CLI talks to `http://localhost:8080`. Override with `-s/--server <url>`, the `AC_SERVER_URL` environment
variable, or the config file `~/.agenticcode/config.properties` (key `server.url`, written automatically by `connect`).
Most commands operate on a project. Select one with `-p/--project <name>`, the `AC_PROJECT` environment variable, the
config file (key `project`, written automatically by `use`), or by running `use <project>` in the interactive shell.
### Interactive shell
@@ -197,6 +206,9 @@ java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar
AgenticCode interactive shell. Type 'help' for commands, 'exit' to quit.
agenticcode> connect http://my-server:8080
Connected to http://my-server:8080
agenticcode> project create my-app -d "My application"
agenticcode> use my-app
Using project my-app
agenticcode> callers MY-MODULE
[ ... ]
agenticcode> ingest ./src/main/natural
@@ -205,21 +217,40 @@ OK ./src/main/natural/FOO.nat
agenticcode> exit
```
`connect <url>` sets the server for the rest of the session (overridden by `-s` on an individual command). `help`/`?` shows available commands, `exit`/`quit` ends the session.
`connect <url>` sets the server for the rest of the session and persists it to the config file (overridden by `-s` on an
individual command). `use <project>` does the same for the project (overridden by `-p`). `help`/`?` shows available
commands, `exit`/`quit` ends the session.
### Managing projects
```bash
# Create a project
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project create my-app -d "My application"
# Update its description
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project update my-app -d "New description"
# List all projects
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project list
# Delete a project and all its ingested data
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project delete my-app
```
### Ingest files or folders
Recursively walks a file or directory; `.java` files are sent to `/api/ingest/java` and `.nat`/`.nsn` files to `/api/ingest/natural`. Files with other extensions are skipped.
Recursively walks a file or directory; `.java` files are sent to `/api/projects/{project}/ingest/java` and `.nat`/`.nsn`
files to `/api/projects/{project}/ingest/natural`. Files with other extensions are skipped.
```bash
# Ingest a single file
# Ingest a single file into the selected project
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest src/main/java/com/example/Foo.java
# Ingest an entire directory tree
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest /path/to/natural-sources
# Ingest an entire directory tree into an explicit project
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest /path/to/natural-sources -p my-app
# Against a non-default server
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest ./src -s http://my-server:8080
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest ./src -p my-app -s http://my-server:8080
```
Output shows `OK`/`FAIL`/`SKIP`/`ERROR` per file plus a summary line; the process exits non-zero if any file failed.
@@ -228,19 +259,20 @@ Output shows `OK`/`FAIL`/`SKIP`/`ERROR` per file plus a summary line; the proces
```bash
# Who calls this module?
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callers MY-MODULE
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callers MY-MODULE -p my-app
# What does this module call?
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callees MY-MODULE
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callees MY-MODULE -p my-app
# Which DB tables does this module read/write?
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar db-accesses MY-MODULE
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar db-accesses MY-MODULE -p my-app
# Find an identifier across all modules
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier myVariable
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier myVariable -p my-app
```
Each query command prints the API's JSON response, pretty-printed.
Each query command prints the API's JSON response, pretty-printed. If no project is selected (no `use`, `-p`,
`AC_PROJECT`, or config file entry), the command fails with an error.
---

View File

@@ -12,12 +12,13 @@ import java.util.concurrent.Callable;
abstract class AbstractApiCommand implements Callable<Integer> {
static final String SERVER_URL_PROPERTY = "ac.server.url";
static final String PROJECT_PROPERTY = "ac.project";
private static final String DEFAULT_SERVER_URL = "http://localhost:8080";
@Option(names = {"-s", "--server"},
description = "Base URL of the AgenticCode server (default: ${DEFAULT-VALUE}, "
+ "or the most recent 'connect', or $AC_SERVER_URL)",
+ "or the most recent 'connect', or $AC_SERVER_URL, or the config file)",
defaultValue = DEFAULT_SERVER_URL)
String server = DEFAULT_SERVER_URL;
@@ -26,10 +27,13 @@ abstract class AbstractApiCommand implements Callable<Integer> {
if (DEFAULT_SERVER_URL.equals(server)) {
String connected = System.getProperty(SERVER_URL_PROPERTY);
String env = System.getenv("AC_SERVER_URL");
String configured = CliConfig.get(CliConfig.SERVER_URL_KEY);
if (connected != null && !connected.isBlank()) {
baseUrl = connected;
} else if (env != null && !env.isBlank()) {
baseUrl = env;
} else if (configured != null && !configured.isBlank()) {
baseUrl = configured;
}
}
return new ApiClient(baseUrl);

View File

@@ -0,0 +1,47 @@
package com.agenticcode.cli;
import picocli.CommandLine.Option;
/**
* Base class for subcommands that operate within a selected project.
*/
abstract class AbstractProjectCommand extends AbstractApiCommand {
private static final String UNSET = "";
@Option(names = {"-p", "--project"},
description = "Project to operate on (default: the most recent 'use', "
+ "or $AC_PROJECT, or the config file)",
defaultValue = UNSET)
String project = UNSET;
/**
* Resolves the project to operate on, or throws if none is configured.
*/
protected String resolveProject() {
if (!UNSET.equals(project)) {
return project;
}
String selected = System.getProperty(PROJECT_PROPERTY);
if (selected != null && !selected.isBlank()) {
return selected;
}
String env = System.getenv("AC_PROJECT");
if (env != null && !env.isBlank()) {
return env;
}
String configured = CliConfig.get(CliConfig.PROJECT_KEY);
if (configured != null && !configured.isBlank()) {
return configured;
}
throw new IllegalStateException(
"No project selected. Use 'use <project>' or -p/--project.");
}
/**
* Builds the {@code /api/projects/{project}} prefix for this command's project.
*/
protected String projectPath() {
return "/api/projects/" + encode(resolveProject());
}
}

View File

@@ -19,6 +19,8 @@ import java.util.concurrent.Callable;
description = "Ingest source code and query the AgenticCode AST graph API.",
subcommands = {
ConnectCommand.class,
UseCommand.class,
ProjectCommand.class,
IngestCommand.class,
CallersCommand.class,
CalleesCommand.class,

View File

@@ -44,6 +44,23 @@ public final class ApiClient {
return send(request);
}
public ApiResponse putJson(String path, Object payload) throws IOException, InterruptedException {
String json = MAPPER.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(json))
.build();
return send(request);
}
public ApiResponse delete(String path) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + path))
.header("Accept", "application/json")
.DELETE()
.build();
return send(request);
}
private ApiResponse send(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return new ApiResponse(response.statusCode(), response.body());

View File

@@ -7,7 +7,7 @@ import picocli.CommandLine.Parameters;
* Lists modules called by a given module.
*/
@Command(name = "callees", mixinStandardHelpOptions = true, description = "List callees of a module")
final class CalleesCommand extends AbstractApiCommand {
final class CalleesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
@@ -15,6 +15,11 @@ final class CalleesCommand extends AbstractApiCommand {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/modules/" + encode(moduleName) + "/callees"));
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/callees"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -7,7 +7,7 @@ import picocli.CommandLine.Parameters;
* Lists modules that call a given module.
*/
@Command(name = "callers", mixinStandardHelpOptions = true, description = "List callers of a module")
final class CallersCommand extends AbstractApiCommand {
final class CallersCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
@@ -15,6 +15,11 @@ final class CallersCommand extends AbstractApiCommand {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/modules/" + encode(moduleName) + "/callers"));
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/callers"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/**
* Persistent CLI configuration stored at {@code ~/.agenticcode/config.properties}.
*
* <p>Used as a fallback for the server URL and selected project, below explicit
* command options, session state (set via {@code connect}/{@code use}), and
* environment variables.
*/
final class CliConfig {
static final String SERVER_URL_KEY = "server.url";
static final String PROJECT_KEY = "project";
private static final Path CONFIG_FILE =
Path.of(System.getProperty("user.home"), ".agenticcode", "config.properties");
private CliConfig() {
}
static @Nullable String get(String key) {
Properties properties = load();
return properties.getProperty(key);
}
static void set(String key, String value) {
Properties properties = load();
properties.setProperty(key, value);
save(properties);
}
private static Properties load() {
Properties properties = new Properties();
if (Files.isRegularFile(CONFIG_FILE)) {
try (var in = Files.newInputStream(CONFIG_FILE)) {
properties.load(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
return properties;
}
private static void save(Properties properties) {
try {
Path parent = CONFIG_FILE.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try (OutputStream out = Files.newOutputStream(CONFIG_FILE)) {
properties.store(out, "AgenticCode CLI configuration");
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

@@ -18,6 +18,7 @@ final class ConnectCommand implements Callable<Integer> {
@Override
public Integer call() {
System.setProperty(AbstractApiCommand.SERVER_URL_PROPERTY, url);
CliConfig.set(CliConfig.SERVER_URL_KEY, url);
System.out.println("Connected to " + url);
return 0;
}

View File

@@ -7,7 +7,7 @@ import picocli.CommandLine.Parameters;
* Lists database tables accessed by a given module.
*/
@Command(name = "db-accesses", mixinStandardHelpOptions = true, description = "List database accesses of a module")
final class DbAccessesCommand extends AbstractApiCommand {
final class DbAccessesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
@@ -15,6 +15,11 @@ final class DbAccessesCommand extends AbstractApiCommand {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/modules/" + encode(moduleName) + "/db-accesses"));
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/db-accesses"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -15,12 +15,23 @@ import java.util.stream.Stream;
* Recursively ingests Java and Natural source files into the AST graph.
*/
@Command(name = "ingest", mixinStandardHelpOptions = true, description = "Ingest a file or directory of Java/Natural source files")
final class IngestCommand extends AbstractApiCommand {
final class IngestCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "File or directory to ingest")
Path path;
private static @Nullable String endpointFor(Path file) {
String name = file.getFileName().toString();
if (name.endsWith(".java")) {
return "/ingest/java";
}
if (name.endsWith(".nat") || name.endsWith(".nsn")) {
return "/ingest/natural";
}
return null;
}
@Override
public Integer call() {
if (!Files.exists(path)) {
@@ -28,6 +39,14 @@ final class IngestCommand extends AbstractApiCommand {
return 1;
}
String projectPath;
try {
projectPath = projectPath();
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
ApiClient client = apiClient();
int failures = 0;
int ingested = 0;
@@ -42,8 +61,8 @@ final class IngestCommand extends AbstractApiCommand {
}
for (Path file : files) {
@Nullable String endpoint = endpointFor(file);
if (endpoint == null) {
@Nullable String suffix = endpointFor(file);
if (suffix == null) {
System.out.println("SKIP " + file);
skipped++;
continue;
@@ -51,7 +70,7 @@ final class IngestCommand extends AbstractApiCommand {
try {
String content = Files.readString(file);
ApiClient.ApiResponse response = client.postJson(endpoint, new IngestRequest(file.toString(), content));
ApiClient.ApiResponse response = client.postJson(projectPath + suffix, new IngestRequest(file.toString(), content));
if (response.isSuccess()) {
System.out.println("OK " + file);
ingested++;
@@ -72,15 +91,4 @@ final class IngestCommand extends AbstractApiCommand {
System.out.printf("%nIngested: %d, skipped: %d, failed: %d%n", ingested, skipped, failures);
return failures == 0 ? 0 : 1;
}
private static @Nullable String endpointFor(Path file) {
String name = file.getFileName().toString();
if (name.endsWith(".java")) {
return "/api/ingest/java";
}
if (name.endsWith(".nat") || name.endsWith(".nsn")) {
return "/api/ingest/natural";
}
return null;
}
}

View File

@@ -0,0 +1,92 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;
import java.util.concurrent.Callable;
/**
* Groups project management subcommands: create, update, delete, list.
*/
@Command(
name = "project",
mixinStandardHelpOptions = true,
description = "Create, update, delete or list projects",
subcommands = {
ProjectCommand.CreateCommand.class,
ProjectCommand.UpdateCommand.class,
ProjectCommand.DeleteCommand.class,
ProjectCommand.ListCommand.class
}
)
final class ProjectCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Spec
CommandSpec spec;
@Override
public Integer call() {
spec.commandLine().usage(System.out);
return 0;
}
@Command(name = "create", mixinStandardHelpOptions = true, description = "Create a new project")
static final class CreateCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String name;
@Option(names = {"-d", "--description"}, description = "Project description")
String description = "";
@Override
public Integer call() throws Exception {
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description)));
}
}
@Command(name = "update", mixinStandardHelpOptions = true, description = "Update a project's description")
static final class UpdateCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String name;
@Option(names = {"-d", "--description"}, description = "Project description")
String description = "";
@Override
public Integer call() throws Exception {
return printResponse(apiClient().putJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description)));
}
}
@Command(name = "delete", mixinStandardHelpOptions = true, description = "Delete a project and all its data")
static final class DeleteCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String name;
@Override
public Integer call() throws Exception {
return printResponse(apiClient().delete("/api/projects/" + encode(name)));
}
}
@Command(name = "list", mixinStandardHelpOptions = true, description = "List all projects")
static final class ListCommand extends AbstractApiCommand {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/projects"));
}
}
}

View File

@@ -0,0 +1,9 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
/**
* Request body for {@code POST}/{@code PUT /api/projects/{project}}.
*/
record ProjectRequest(@Nullable String description) {
}

View File

@@ -7,7 +7,7 @@ import picocli.CommandLine.Parameters;
* Searches for an identifier across all ingested modules.
*/
@Command(name = "search-identifier", mixinStandardHelpOptions = true, description = "Search for an identifier across all modules")
final class SearchIdentifierCommand extends AbstractApiCommand {
final class SearchIdentifierCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Identifier name")
@@ -15,6 +15,11 @@ final class SearchIdentifierCommand extends AbstractApiCommand {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/search/identifier?name=" + encode(name)));
try {
return printResponse(apiClient().get(projectPath() + "/search/identifier?name=" + encode(name)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,25 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;
import java.util.concurrent.Callable;
/**
* Sets the project used by subsequent commands in this session.
*/
@Command(name = "use", mixinStandardHelpOptions = true, description = "Select the project for this session")
final class UseCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String project;
@Override
public Integer call() {
System.setProperty(AbstractApiCommand.PROJECT_PROPERTY, project);
CliConfig.set(CliConfig.PROJECT_KEY, project);
System.out.println("Using project " + project);
return 0;
}
}

View File

@@ -15,7 +15,7 @@ import java.util.List;
/**
* REST API for ingesting source code and querying the unified AST graph.
*/
@Path("/api")
@Path("/api/projects/{project}")
@Produces(MediaType.APPLICATION_JSON)
public class AnalysisResource {
@@ -30,41 +30,41 @@ public class AnalysisResource {
@POST
@Path("/ingest/java")
@Consumes(MediaType.APPLICATION_JSON)
public Uni<Response> ingestJava(IngestRequest request) {
return ingestService.ingestJava(request.sourceFile(), request.content())
public Uni<Response> ingestJava(@PathParam("project") String project, IngestRequest request) {
return ingestService.ingestJava(project, request.sourceFile(), request.content())
.replaceWith(Response.status(Response.Status.ACCEPTED).build());
}
@POST
@Path("/ingest/natural")
@Consumes(MediaType.APPLICATION_JSON)
public Uni<Response> ingestNatural(IngestRequest request) {
return ingestService.ingestNatural(request.sourceFile(), request.content())
public Uni<Response> ingestNatural(@PathParam("project") String project, IngestRequest request) {
return ingestService.ingestNatural(project, request.sourceFile(), request.content())
.replaceWith(Response.status(Response.Status.ACCEPTED).build());
}
@GET
@Path("/modules/{name}/callers")
public Uni<List<CallReference>> callers(@PathParam("name") String name) {
return graphRepository.callers(name);
public Uni<List<CallReference>> callers(@PathParam("project") String project, @PathParam("name") String name) {
return graphRepository.callers(project, name);
}
@GET
@Path("/modules/{name}/callees")
public Uni<List<CallReference>> callees(@PathParam("name") String name) {
return graphRepository.callees(name);
public Uni<List<CallReference>> callees(@PathParam("project") String project, @PathParam("name") String name) {
return graphRepository.callees(project, name);
}
@GET
@Path("/modules/{name}/db-accesses")
public Uni<List<DbAccess>> dbAccesses(@PathParam("name") String name) {
return graphRepository.dbAccesses(name);
public Uni<List<DbAccess>> dbAccesses(@PathParam("project") String project, @PathParam("name") String name) {
return graphRepository.dbAccesses(project, name);
}
@GET
@Path("/search/identifier")
public Uni<List<IdentifierMatch>> searchIdentifier(@QueryParam("name") String name) {
return graphRepository.searchIdentifier(name);
public Uni<List<IdentifierMatch>> searchIdentifier(@PathParam("project") String project, @QueryParam("name") String name) {
return graphRepository.searchIdentifier(project, name);
}
/**

View File

@@ -0,0 +1,86 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* REST API for creating, updating, deleting and listing projects.
*/
@Path("/api/projects")
@Produces(MediaType.APPLICATION_JSON)
public class ProjectResource {
private final GraphRepository graphRepository;
public ProjectResource(GraphRepository graphRepository) {
this.graphRepository = graphRepository;
}
private static Response error(Response.Status status, String code, String message) {
return Response.status(status)
.entity(new ErrorBody(message, code, java.util.Map.of()))
.build();
}
@GET
public Uni<List<ProjectInfo>> list() {
return graphRepository.listProjects();
}
@POST
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
public Uni<Response> create(@PathParam("project") String project, ProjectRequest request) {
return graphRepository.createProject(project, request.description())
.map(result -> switch (result) {
case SUCCESS -> Response.status(Response.Status.CREATED).build();
case CONFLICT -> error(Response.Status.CONFLICT, "PROJECT_ALREADY_EXISTS",
"Project '" + project + "' already exists");
case NOT_FOUND -> throw new IllegalStateException("Unexpected NOT_FOUND on create");
});
}
@PUT
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
public Uni<Response> update(@PathParam("project") String project, ProjectRequest request) {
return graphRepository.updateProject(project, request.description())
.map(result -> switch (result) {
case SUCCESS -> Response.ok().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist");
case CONFLICT -> throw new IllegalStateException("Unexpected CONFLICT on update");
});
}
@DELETE
@Path("/{project}")
public Uni<Response> delete(@PathParam("project") String project) {
return graphRepository.deleteProject(project)
.map(result -> switch (result) {
case SUCCESS -> Response.noContent().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist");
case CONFLICT -> throw new IllegalStateException("Unexpected CONFLICT on delete");
});
}
/**
* Request body for {@code POST}/{@code PUT} {@code /api/projects/{project}}.
*/
public record ProjectRequest(@Nullable String description) {
}
/**
* Structured error response: {@code { "error": "...", "code": "...", "details": {} }}.
*/
public record ErrorBody(String error, String code, java.util.Map<String, Object> details) {
}
}

View File

@@ -22,16 +22,16 @@ public class AstIngestService {
this.graphRepository = graphRepository;
}
public Uni<Void> ingestJava(String sourceFile, String content) {
return ingest(javaParser, sourceFile, content);
public Uni<Void> ingestJava(String project, String sourceFile, String content) {
return ingest(project, javaParser, sourceFile, content);
}
public Uni<Void> ingestNatural(String sourceFile, String content) {
return ingest(naturalParser, sourceFile, content);
public Uni<Void> ingestNatural(String project, String sourceFile, String content) {
return ingest(project, naturalParser, sourceFile, content);
}
private Uni<Void> ingest(LanguageParser parser, String sourceFile, String content) {
private Uni<Void> ingest(String project, LanguageParser parser, String sourceFile, String content) {
LanguageParser.ParseResult result = parser.parse(sourceFile, content);
return graphRepository.save(result);
return graphRepository.save(project, result);
}
}

View File

@@ -20,11 +20,19 @@ import static org.hamcrest.Matchers.*;
@QuarkusTest
class AnalysisResourceIT {
private static final String PROJECT = "test-project";
@BeforeAll
static void ingestFixtures() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
ingest("/api/ingest/java", "SampleRequest.java", "fixtures/java/SampleRequest.java");
ingest("/api/ingest/natural", "YADDRBN0_SAMPLE.nat", "fixtures/natural/YADDRBN0_SAMPLE.nat");
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null))
.when().post("/api/projects/" + PROJECT)
.then()
.statusCode(201);
ingest("/api/projects/" + PROJECT + "/ingest/java", "SampleRequest.java", "fixtures/java/SampleRequest.java");
ingest("/api/projects/" + PROJECT + "/ingest/natural", "YADDRBN0_SAMPLE.nat", "fixtures/natural/YADDRBN0_SAMPLE.nat");
}
private static void ingest(String path, String sourceFile, String classpathResource) {
@@ -51,14 +59,14 @@ class AnalysisResourceIT {
@Test
void javaClassInheritanceConstantsAndFieldsAreSearchable() {
given()
.when().get("/api/search/identifier?name=serialVersionUID")
.when().get("/api/projects/" + PROJECT + "/search/identifier?name=serialVersionUID")
.then()
.statusCode(200)
.body("find { it.type == 'CONSTANT' }.name", equalTo("serialVersionUID"))
.body("find { it.type == 'CONSTANT' }.sourceFile", equalTo("SampleRequest.java"));
given()
.when().get("/api/search/identifier?name=partnerId")
.when().get("/api/projects/" + PROJECT + "/search/identifier?name=partnerId")
.then()
.statusCode(200)
.body("find { it.type == 'FIELD' }.name", equalTo("partnerId"));
@@ -67,7 +75,7 @@ class AnalysisResourceIT {
@Test
void javaCallGraphAndInheritanceAreQueryable() {
given()
.when().get("/api/modules/SampleRequest/callees")
.when().get("/api/projects/" + PROJECT + "/modules/SampleRequest/callees")
.then()
.statusCode(200)
.body("name", hasItem("BaseRequest"))
@@ -78,7 +86,7 @@ class AnalysisResourceIT {
@Test
void naturalCallTreeIsQueryable() {
given()
.when().get("/api/modules/YADDRBN0_SAMPLE/callees")
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/callees")
.then()
.statusCode(200)
.body("name", hasItems("INITIALIZATIONS", "CDRANGE", "R-ADDRESS_SP"));
@@ -87,7 +95,7 @@ class AnalysisResourceIT {
@Test
void naturalDbAccessesAreQueryable() {
given()
.when().get("/api/modules/YADDRBN0_SAMPLE/db-accesses")
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/db-accesses")
.then()
.statusCode(200)
.body("findAll { it.name == 'VERSVW_ADDRESS' }.mode", hasItems("READS", "WRITES"));
@@ -97,7 +105,7 @@ class AnalysisResourceIT {
void naturalConstantsAreSearchable() {
given()
.queryParam("name", "#MAX-ATTEMPTS")
.when().get("/api/search/identifier")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("find { it.type == 'CONSTANT' }.value", equalTo("3"));

View File

@@ -0,0 +1,95 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItem;
/**
* Covers create/update/delete/list of projects via {@link ProjectResource}.
*/
@QuarkusTest
class ProjectResourceIT {
@BeforeAll
static void setPort() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
}
@Test
void createUpdateListAndDeleteProject() {
String project = "crud-project";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest("initial description"))
.when().post("/api/projects/" + project)
.then()
.statusCode(201);
given()
.when().get("/api/projects")
.then()
.statusCode(200)
.body("name", hasItem(project));
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest("updated description"))
.when().put("/api/projects/" + project)
.then()
.statusCode(200);
given()
.when().delete("/api/projects/" + project)
.then()
.statusCode(204);
}
@Test
void createTwiceReturnsConflict() {
String project = "conflict-project";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null))
.when().post("/api/projects/" + project)
.then()
.statusCode(201);
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null))
.when().post("/api/projects/" + project)
.then()
.statusCode(409)
.body("code", org.hamcrest.Matchers.equalTo("PROJECT_ALREADY_EXISTS"));
given()
.when().delete("/api/projects/" + project)
.then()
.statusCode(204);
}
@Test
void updateAndDeleteUnknownProjectReturnNotFound() {
String project = "missing-project";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest("x"))
.when().put("/api/projects/" + project)
.then()
.statusCode(404)
.body("code", org.hamcrest.Matchers.equalTo("PROJECT_NOT_FOUND"));
given()
.when().delete("/api/projects/" + project)
.then()
.statusCode(404)
.body("code", org.hamcrest.Matchers.equalTo("PROJECT_NOT_FOUND"));
}
}

View File

@@ -8,41 +8,62 @@ import java.util.Map;
/**
* Cypher query strings for persisting and querying the unified AST graph.
*
* <p>Nodes are merged on {@code (type, name, sourceFile)}. Placeholder nodes for
* <p>Nodes are merged on {@code (type, name, sourceFile, project)}. Placeholder nodes for
* cross-file/external references (e.g. {@code CALLNAT} targets, {@code extends} of an
* unparsed class, included PDAs) use {@code sourceFile = ""}, so references to the same
* external symbol from different files converge onto a single node.
* external symbol from different files converge onto a single node within a project.
*/
public final class CypherQueries {
public static final String MERGE_NODE = """
MERGE (n:AstNode {type: $type, name: $name, sourceFile: $sourceFile})
MERGE (n:AstNode {type: $type, name: $name, sourceFile: $sourceFile, project: $project})
SET n.id = $id, n.language = $language, n.startLine = $startLine,
n.endLine = $endLine, n.dataType = $dataType, n.value = $value
""";
public static final String CALLERS = """
MATCH (m:AstNode {type: 'MODULE', name: $name})
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (caller:AstNode)-[:CALLS]->(target:AstNode)
WHERE target = m OR (m)-[:CONTAINS]->(target)
RETURN DISTINCT caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile
""";
public static final String CALLEES = """
MATCH (m:AstNode {type: 'MODULE', name: $name})
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (m)-[:CONTAINS*0..]->(source:AstNode)-[:CALLS|EXTENDS|IMPLEMENTS]->(callee:AstNode)
RETURN DISTINCT callee.name AS name, callee.type AS type, callee.sourceFile AS sourceFile
""";
public static final String DB_ACCESSES = """
MATCH (m:AstNode {type: 'MODULE', name: $name})
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (m)-[:CONTAINS*0..]->(f:AstNode)-[r:READS|WRITES]->(t:AstNode {type: 'DB_TABLE'})
RETURN DISTINCT t.name AS name, type(r) AS mode
""";
public static final String SEARCH_IDENTIFIER = """
MATCH (n:AstNode)
MATCH (n:AstNode {project: $project})
WHERE n.name = $name
RETURN n.type AS type, n.name AS name, n.sourceFile AS sourceFile,
n.startLine AS startLine, n.endLine AS endLine,
n.dataType AS dataType, n.value AS value
""";
public static final String PROJECT_EXISTS = """
MATCH (p:Project {name: $name})
RETURN p
""";
public static final String CREATE_PROJECT = """
CREATE (p:Project {name: $name, description: $description})
""";
public static final String UPDATE_PROJECT = """
MATCH (p:Project {name: $name})
SET p.description = $description
""";
public static final String DELETE_PROJECT = """
MATCH (p:Project {name: $name})
OPTIONAL MATCH (n:AstNode {project: $name})
DETACH DELETE p, n
""";
public static final String LIST_PROJECTS = """
MATCH (p:Project)
RETURN p.name AS name, p.description AS description
ORDER BY p.name
""";
private static final Map<EdgeType, String> MERGE_EDGE_BY_TYPE = buildMergeEdgeQueries();
private CypherQueries() {

View File

@@ -36,12 +36,13 @@ public class GraphRepository {
record.get("sourceFile").asString());
}
private static Map<String, @Nullable Object> toParams(AstNode node) {
private static Map<String, @Nullable Object> toParams(AstNode node, String project) {
Map<String, @Nullable Object> params = new java.util.HashMap<>();
params.put("id", node.id().toString());
params.put("type", node.type().name());
params.put("name", node.name());
params.put("sourceFile", node.sourceFile());
params.put("project", project);
params.put("language", node.language());
params.put("startLine", node.startLine());
params.put("endLine", node.endLine());
@@ -51,14 +52,15 @@ public class GraphRepository {
}
/**
* Persists all nodes and edges of a single file's parse result in one transaction.
* Persists all nodes and edges of a single file's parse result in one transaction,
* scoped to the given project.
*/
public Uni<Void> save(ParseResult result) {
public Uni<Void> save(String project, ParseResult result) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
session.executeWriteWithoutResult(tx -> {
for (AstNode node : result.nodes()) {
tx.run(CypherQueries.MERGE_NODE, toParams(node));
tx.run(CypherQueries.MERGE_NODE, toParams(node, project));
}
for (AstEdge edge : result.edges()) {
tx.run(CypherQueries.mergeEdge(edge.type()), Map.of(
@@ -71,21 +73,21 @@ public class GraphRepository {
}).replaceWithVoid();
}
public Uni<List<CallReference>> callers(String moduleName) {
return read(CypherQueries.CALLERS, moduleName, GraphRepository::toCallReference);
public Uni<List<CallReference>> callers(String project, String moduleName) {
return read(CypherQueries.CALLERS, Map.of("project", project, "name", moduleName), GraphRepository::toCallReference);
}
public Uni<List<CallReference>> callees(String moduleName) {
return read(CypherQueries.CALLEES, moduleName, GraphRepository::toCallReference);
public Uni<List<CallReference>> callees(String project, String moduleName) {
return read(CypherQueries.CALLEES, Map.of("project", project, "name", moduleName), GraphRepository::toCallReference);
}
public Uni<List<DbAccess>> dbAccesses(String moduleName) {
return read(CypherQueries.DB_ACCESSES, moduleName,
public Uni<List<DbAccess>> dbAccesses(String project, String moduleName) {
return read(CypherQueries.DB_ACCESSES, Map.of("project", project, "name", moduleName),
record -> new DbAccess(record.get("name").asString(), record.get("mode").asString()));
}
public Uni<List<IdentifierMatch>> searchIdentifier(String identifierName) {
return read(CypherQueries.SEARCH_IDENTIFIER, identifierName, record -> new IdentifierMatch(
public Uni<List<IdentifierMatch>> searchIdentifier(String project, String identifierName) {
return read(CypherQueries.SEARCH_IDENTIFIER, Map.of("project", project, "name", identifierName), record -> new IdentifierMatch(
NodeType.valueOf(record.get("type").asString()),
record.get("name").asString(),
record.get("sourceFile").asString(),
@@ -95,11 +97,76 @@ public class GraphRepository {
record.get("value").isNull() ? null : record.get("value").asString()));
}
private <T> Uni<List<T>> read(String query, String name, java.util.function.Function<Record, T> mapper) {
/**
* Creates a new project. Returns {@link ProjectOpResult#CONFLICT} if a project with
* this name already exists.
*/
public Uni<ProjectOpResult> createProject(String name, @Nullable String description) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
if (tx.run(CypherQueries.PROJECT_EXISTS, Map.of("name", name)).hasNext()) {
return ProjectOpResult.CONFLICT;
}
tx.run(CypherQueries.CREATE_PROJECT, Map.of("name", name, "description", description));
return ProjectOpResult.SUCCESS;
});
}
});
}
/**
* Updates an existing project's description. Returns {@link ProjectOpResult#NOT_FOUND}
* if no project with this name exists.
*/
public Uni<ProjectOpResult> updateProject(String name, @Nullable String description) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
if (!tx.run(CypherQueries.PROJECT_EXISTS, Map.of("name", name)).hasNext()) {
return ProjectOpResult.NOT_FOUND;
}
tx.run(CypherQueries.UPDATE_PROJECT, Map.of("name", name, "description", description));
return ProjectOpResult.SUCCESS;
});
}
});
}
/**
* Deletes a project and all AST nodes/edges associated with it. Returns
* {@link ProjectOpResult#NOT_FOUND} if no project with this name exists.
*/
public Uni<ProjectOpResult> deleteProject(String name) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
if (!tx.run(CypherQueries.PROJECT_EXISTS, Map.of("name", name)).hasNext()) {
return ProjectOpResult.NOT_FOUND;
}
tx.run(CypherQueries.DELETE_PROJECT, Map.of("name", name));
return ProjectOpResult.SUCCESS;
});
}
});
}
public Uni<List<ProjectInfo>> listProjects() {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeRead((TransactionContext tx) ->
tx.run(query, Map.of("name", name)).list(mapper));
tx.run(CypherQueries.LIST_PROJECTS).list(record -> new ProjectInfo(
record.get("name").asString(),
record.get("description").isNull() ? null : record.get("description").asString())));
}
});
}
private <T> Uni<List<T>> read(String query, Map<String, Object> params, java.util.function.Function<Record, T> mapper) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeRead((TransactionContext tx) ->
tx.run(query, params).list(mapper));
}
});
}

View File

@@ -0,0 +1,9 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* A project entry returned by {@link CypherQueries#LIST_PROJECTS}.
*/
public record ProjectInfo(String name, @Nullable String description) {
}

View File

@@ -0,0 +1,10 @@
package com.agenticcode.neo4jstore.graph;
/**
* Outcome of a project create/update/delete operation.
*/
public enum ProjectOpResult {
SUCCESS,
CONFLICT,
NOT_FOUND
}

View File

@@ -0,0 +1,539 @@
# Introduction to Neo4j (for AgenticCode)
This document is a practical introduction to Neo4j aimed at developers working on
AgenticCode who have no prior graph-database experience. It covers how Neo4j works,
how *this project* models its data, how the existing `CypherQueries` work, and how
graph queries map onto program-analysis questions.
---
## 1. How Neo4j works in general
Neo4j is a **graph database**. Instead of tables and rows (relational) or documents
(NoSQL document stores), everything is stored as:
- **Nodes** — entities, roughly like "rows" but with no fixed schema. Each node has:
- zero or more **labels** (a node's "type", e.g. `:AstNode`, `:Project`) — a node can have multiple labels
- a set of **properties** (key/value pairs, e.g. `name: "YADDRBN0"`, `startLine: 42`)
- **Relationships** — directed, typed edges between two nodes. Each relationship has:
- exactly one **type** (e.g. `:CALLS`, `:CONTAINS`, `:READS`)
- a direction (`(a)-[:CALLS]->(b)`)
- optionally its own properties
Everything is stored "pre-joined": a relationship is a physical pointer between two
nodes, stored once at creation time. This means **traversing a relationship is a
cheap pointer lookup**, not a join computed at query time. The more your queries are
about *connections* (who calls whom, what depends on what, how does data flow), the
better a graph database performs compared to a relational database with many JOINs.
### Key concepts
| Concept | Relational analogy | Example in AgenticCode |
|---|---|---|
| Node | Row | One `AstNode` (a function, variable, module, ...) |
| Label | Table name | `AstNode`, `Project` |
| Property | Column value | `name`, `type`, `sourceFile`, `startLine` |
| Relationship | Foreign key / join | `(:AstNode)-[:CALLS]->(:AstNode)` |
| Relationship type | — (no real equivalent) | `CALLS`, `CONTAINS`, `READS`, `WRITES` |
### Cypher — the query language
Cypher is Neo4j's query language. It's declarative and visual: patterns in a query
look like the graph itself, using ASCII-art for nodes `()` and relationships `-->`.
```cypher
MATCH (caller:AstNode)-[:CALLS]->(callee:AstNode {name: 'FOO'})
RETURN caller.name
```
This reads almost like English: "find all `AstNode`s that have a `CALLS`
relationship pointing to an `AstNode` named `FOO`, and return the caller's name."
### Schema-optional, but indexed in practice
Neo4j doesn't require you to declare a schema up front — any node can have any
properties. In practice, for performance, you create **indexes/constraints** on the
properties you frequently search by (e.g. an index on `AstNode.name` and
`AstNode.project`), so lookups like `MATCH (n:AstNode {name: $name})` don't scan
every node.
### How AgenticCode talks to Neo4j
- `ac-neo4j-store` uses the official `neo4j-java-driver`.
- `GraphRepository` opens a `Session`, runs Cypher via `session.executeWrite(...)` /
`session.executeRead(...)`, and maps result `Record`s to Java records
(`CallReference`, `DbAccess`, `IdentifierMatch`, `ProjectInfo`, ...).
- All Cypher query *strings* live in `CypherQueries` (per the project's "no inline
Cypher in services" convention) — `GraphRepository` only supplies parameters.
---
## 1a. Writing Cypher queries — syntax basics
Cypher reads like ASCII-art of the graph pattern you're looking for, followed by
what to do with what you found. Most queries are built from a small set of clauses,
used in roughly this order:
```
MATCH ... -- find a pattern in the graph
WHERE ... -- filter the matches
WITH ... -- reshape/aggregate before continuing
RETURN ... -- produce results
ORDER BY ...
SKIP / LIMIT ...
```
for writes:
```
CREATE ... -- always create new node(s)/relationship(s)
MERGE ... -- find-or-create (upsert)
SET ... -- add/overwrite properties
DELETE / DETACH DELETE ...
```
### Patterns: nodes and relationships
```cypher
(n) -- any node, bound to variable n
(n:AstNode) -- node with label AstNode
(n:AstNode {name: 'FOO'}) -- label + property filter (inline)
(a)-[:CALLS]->(b) -- directed relationship of type CALLS
(a)-[r:CALLS]->(b) -- relationship bound to variable r
(a)-[:CALLS|CONTAINS]->(b) -- either relationship type
(a)-[:CALLS*1..3]->(b) -- variable-length path, 1 to 3 hops
(a)-[:CONTAINS*0..]->(b) -- 0 or more hops (a itself, or any descendant)
(a)--(b) -- relationship, direction/type don't matter
```
- `()` = node, `[]` = relationship, `-->`/`<--`/`--` = direction.
- Anything not given a variable name (`()`, `[:CALLS]`) is just a pattern shape —
you can't reference it later, but it still constrains the match.
- Property filters in `{...}` are an **AND** of equality checks; for anything more
complex (ranges, `IN`, regex, `IS NULL`, ...), match without the filter and use
`WHERE` instead.
### MATCH + WHERE — finding things
```cypher
MATCH (m:AstNode {type: 'MODULE'})
WHERE m.project = $project AND m.name STARTS WITH 'YADDR'
RETURN m.name, m.sourceFile
```
- `{type: 'MODULE'}` and `WHERE m.project = $project` are equivalent ways to filter
— inline filters are slightly more index-friendly, `WHERE` is more flexible
(operators: `=`, `<>`, `<`, `>`, `IN`, `STARTS WITH`/`CONTAINS`/`ENDS WITH`,
`IS NULL`, `AND`/`OR`/`NOT`, regex with `=~`).
- Multiple `MATCH` clauses (or comma-separated patterns) are joined like an inner
join on shared variables — see `CALLERS` in section 3.3, which uses two `MATCH`
clauses connected via `m`/`target`.
### RETURN — shaping output
```cypher
RETURN m.name AS name, m.type AS type -- alias columns (becomes the JSON key)
RETURN DISTINCT caller.name -- de-duplicate rows
RETURN count(*) AS total -- aggregation
```
Aggregations (`count`, `collect`, `sum`, `avg`, `min`, `max`) implicitly group by
every *other* non-aggregated expression in the `RETURN` — there's no separate
`GROUP BY`.
### Parameters — never inline user input
```cypher
MATCH (n:AstNode {project: $project, name: $name}) RETURN n
```
`$name`-style parameters are supplied separately as a map
(`tx.run(query, Map.of("project", project, "name", name))` in `GraphRepository`).
Always use parameters instead of string-concatenating values into the query —
it avoids Cypher injection (the graph equivalent of SQL injection) and lets Neo4j
cache/reuse the query plan.
### CREATE vs MERGE — the most important distinction for writes
```cypher
CREATE (p:Project {name: $name}) -- always inserts a new node, even if one
-- with the same properties exists
MERGE (n:AstNode {type: $type, name: $name, sourceFile: $sourceFile, project: $project})
ON CREATE SET n.id = $id -- only runs if a new node was created
ON MATCH SET n.lastSeen = timestamp() -- only runs if an existing node matched
SET n.language = $language -- runs either way
```
- `MERGE` treats everything inside `{...}` as the **identity** to match-or-create —
put only the stable "key" properties there (as `MERGE_NODE` does), and use a
separate `SET` for everything else, otherwise a single differing property (e.g. a
changed line number) would cause `MERGE` to create a duplicate node instead of
updating the existing one.
- `ON CREATE SET` / `ON MATCH SET` let you distinguish "first time" vs "update" —
not currently used in this project, but useful for e.g. tracking `createdAt` vs
`updatedAt`.
### SET / REMOVE / DELETE
```cypher
SET n.dataType = $dataType -- set/overwrite a property
SET n += $propsMap -- merge a map of properties into a node
REMOVE n.dataType -- remove a property entirely
DELETE n -- delete a node (fails if it has relationships)
DETACH DELETE n -- delete a node and all its relationships
```
### WITH — chaining query stages
`WITH` passes variables (optionally aggregated/filtered/reshaped) from one part of a
query to the next — it's how you build multi-step queries:
```cypher
MATCH (m:AstNode {type: 'MODULE', project: $project})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
WITH m, count(f) AS functionCount
WHERE functionCount > 10
RETURN m.name, functionCount
ORDER BY functionCount DESC
```
### A minimal mental checklist when writing a new query
1. Sketch the pattern: which node(s) am I anchoring on, and what path connects them
to what I want to return? (Draw it as `()-->()` on paper first.)
2. Anchor on the most selective filter first (usually `project` + `name` +
`type`, ideally backed by an index — see section 4.4).
3. Use `MATCH`/`WHERE` for reads, `MERGE` (with a minimal identity key) for
idempotent writes, `CREATE` only when duplicates are impossible/acceptable.
4. Always pass values as `$parameters`, never string-concatenate.
5. Try it in Neo4j Browser with literal values before wiring it into
`CypherQueries`/`GraphRepository`.
For anything beyond this, the official
[Cypher manual](https://neo4j.com/docs/cypher-manual/current/) and the interactive
[Cypher cheat sheet](https://neo4j.com/docs/cypher-cheat-sheet/) are the best
references.
---
## 2. Structure of the database
### 2.1 Node labels and properties
AgenticCode currently uses two node labels:
- **`:AstNode`** — every parsed element (module, function, variable, data structure,
DB table, constant, field). The `type` *property* (a `NodeType` enum value)
distinguishes what kind of AST element it is — it is **not** a separate label.
- **`:Project`** — a logical grouping/namespace for ingested source code.
```mermaid
classDiagram
class AstNode {
+String id
+String type
+String name
+String sourceFile
+String project
+String language
+int startLine
+int endLine
+String dataType
+String value
}
class Project {
+String name
+String description
}
```
`AstNode.type` is one of (from `NodeType`):
```
MODULE | FUNCTION | VARIABLE | DATA_STRUCTURE | DB_TABLE | CONSTANT | FIELD
```
### 2.2 Relationship types
From `EdgeType`:
```
CONTAINS | CALLS | READS | WRITES | USES_TYPE | EXTENDS | IMPLEMENTS | INCLUDES
```
### 2.3 Conceptual schema (entity/relationship view)
```mermaid
erDiagram
MODULE ||--o{ FUNCTION : CONTAINS
FUNCTION ||--o{ FUNCTION : CALLS
FUNCTION ||--o{ VARIABLE : READS
FUNCTION ||--o{ VARIABLE : WRITES
FUNCTION ||--o{ DB_TABLE : READS
FUNCTION ||--o{ DB_TABLE : WRITES
FUNCTION ||--o{ DATA_STRUCTURE : USES_TYPE
MODULE ||--o{ MODULE : EXTENDS
MODULE ||--o{ MODULE : IMPLEMENTS
MODULE ||--o{ MODULE : INCLUDES
PROJECT ||--o{ MODULE : "scopes (via project property)"
```
> Note: `MODULE`, `FUNCTION`, `VARIABLE`, `DATA_STRUCTURE`, `DB_TABLE` are not
> separate labels in the real database — they are all `:AstNode` nodes whose `type`
> property has that value. The diagram shows the *conceptual* model; section 2.1
> shows the *physical* model.
### 2.4 Example graph instance
A small Java class `SampleRequest extends BaseRequest` with a method
`getPartnerId()` that reads field `partnerId`, ingested into project `demo`, looks
like this:
```mermaid
graph TD
M["AstNode<br/>type=MODULE<br/>name=SampleRequest<br/>project=demo"]
BASE["AstNode<br/>type=MODULE<br/>name=BaseRequest<br/>project=demo"]
F["AstNode<br/>type=FUNCTION<br/>name=getPartnerId<br/>project=demo"]
V["AstNode<br/>type=FIELD<br/>name=partnerId<br/>project=demo"]
M -- CONTAINS --> F
M -- EXTENDS --> BASE
F -- READS --> V
```
### 2.5 Project isolation
Every `:AstNode` carries a `project` property, and is **merged** (deduplicated) on
`(type, name, sourceFile, project)`. This means:
- The same module name can exist independently in two different projects.
- A `:Project` node is separate metadata (`name`, `description`) used only by the
project CRUD API — it is not itself connected to `:AstNode`s via relationships;
the link is the shared `project` property value.
- Deleting a project (`DELETE_PROJECT`) removes the `:Project` node **and** every
`:AstNode` (and its relationships, via `DETACH DELETE`) with that `project` value.
```mermaid
graph LR
subgraph "project = orderapp"
A1[AstNode MODULE Order]
A2[AstNode FUNCTION calculateTotal]
A1 -- CONTAINS --> A2
end
subgraph "project = billingapp"
B1[AstNode MODULE Order]
B2[AstNode FUNCTION calculateTotal]
B1 -- CONTAINS --> B2
end
P1[Project name=orderapp] -. "same project value, no edge" .-> A1
P2[Project name=billingapp] -. "same project value, no edge" .-> B1
```
Two modules named `Order` in different projects are **distinct nodes** — there is no
relationship between them and no way to accidentally cross-reference data between
projects.
---
## 3. Explaining `CypherQueries`
All queries live in
`ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java`.
Parameters (`$name`, `$project`, ...) are supplied by `GraphRepository` as a
`Map<String, Object>`.
### 3.1 `MERGE_NODE` — upsert a parsed AST element
```cypher
MERGE (n:AstNode {type: $type, name: $name, sourceFile: $sourceFile, project: $project})
SET n.id = $id, n.language = $language, n.startLine = $startLine,
n.endLine = $endLine, n.dataType = $dataType, n.value = $value
```
- `MERGE` = "find a node matching this pattern, or create it if it doesn't exist."
The properties inside `{...}` form the **identity key**. Two ingests of the same
`(type, name, sourceFile, project)` combination update the *same* node rather than
creating duplicates — this makes re-ingesting a changed file idempotent.
- `SET` then (re-)writes the remaining properties, including a fresh `id` (UUID) and
line numbers, so re-parsing a file refreshes its data.
- Placeholder nodes for external references (e.g. a `CALLNAT` target that hasn't
been ingested yet) use `sourceFile = ""`. Once the real file is ingested with the
same `(type, name, project)`, the `MERGE` matches the same placeholder node and
enriches it — this is how forward references resolve.
### 3.2 Edge merges — `mergeEdge(EdgeType)`
```cypher
MATCH (a:AstNode {id: $sourceId}), (b:AstNode {id: $targetId})
MERGE (a)-[:CALLS]->(b)
```
(generated once per `EdgeType` value, e.g. `CALLS`, `CONTAINS`, `READS`, ...)
- Nodes are matched by their stable `id` (UUID) — not by name — so edges always
point at the exact node instances created/merged by `MERGE_NODE`.
- `MERGE` on the relationship avoids duplicate edges if the same call/read/write is
ingested twice.
- Because `id` is globally unique, edges can never accidentally cross between
projects, even without an explicit `project` check.
### 3.3 `CALLERS` — who calls this module?
```cypher
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (caller:AstNode)-[:CALLS]->(target:AstNode)
WHERE target = m OR (m)-[:CONTAINS]->(target)
RETURN DISTINCT caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile
```
- First finds the anchor `MODULE` node `m` (scoped by `project`).
- Then finds any `CALLS` edge whose target is either `m` itself, **or** something
`m` `CONTAINS` (i.e. a function defined inside that module) — so calling a
function inside `SampleRequest` counts as "calling `SampleRequest`".
- `target = m` is a node-identity comparison (same internal node).
### 3.4 `CALLEES` — what does this module call?
```cypher
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (m)-[:CONTAINS*0..]->(source:AstNode)-[:CALLS|EXTENDS|IMPLEMENTS]->(callee:AstNode)
RETURN DISTINCT callee.name AS name, callee.type AS type, callee.sourceFile AS sourceFile
```
- `[:CONTAINS*0..]` is a **variable-length path**: "zero or more `CONTAINS` hops."
`*0` means `source` can be `m` itself; more hops would reach functions-within-
functions etc.
- `[:CALLS|EXTENDS|IMPLEMENTS]` is **relationship-type alternation** — match any one
of these three edge types in a single step.
- Result: every module/function/class that `m` (or anything it contains) calls,
extends, or implements.
### 3.5 `DB_ACCESSES` — which DB tables, and how?
```cypher
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
MATCH (m)-[:CONTAINS*0..]->(f:AstNode)-[r:READS|WRITES]->(t:AstNode {type: 'DB_TABLE'})
RETURN DISTINCT t.name AS name, type(r) AS mode
```
- Same "module or anything it contains" pattern as `CALLEES`.
- `r` is bound to the **relationship itself** (not just a node), so `type(r)`
returns the relationship's type as a string (`"READS"` or `"WRITES"`) — this
becomes the `mode` in the response.
- Filters the target node by `type: 'DB_TABLE'`.
### 3.6 `SEARCH_IDENTIFIER` — find a name anywhere in the project
```cypher
MATCH (n:AstNode {project: $project})
WHERE n.name = $name
RETURN n.type AS type, n.name AS name, n.sourceFile AS sourceFile,
n.startLine AS startLine, n.endLine AS endLine,
n.dataType AS dataType, n.value AS value
```
- A flat scan over all `:AstNode`s in the project filtered by exact `name`. With an
index on `(project, name)` this is fast even for large graphs.
- Returns every kind of node (function, variable, constant, ...) that has this name
— useful for "where is `partnerId` used/defined?"
### 3.7 Project CRUD queries
```cypher
-- PROJECT_EXISTS
MATCH (p:Project {name: $name}) RETURN p
-- CREATE_PROJECT
CREATE (p:Project {name: $name, description: $description})
-- UPDATE_PROJECT
MATCH (p:Project {name: $name}) SET p.description = $description
-- DELETE_PROJECT (cascades to all AstNodes in the project)
MATCH (p:Project {name: $name})
OPTIONAL MATCH (n:AstNode {project: $name})
DETACH DELETE p, n
-- LIST_PROJECTS
MATCH (p:Project) RETURN p.name AS name, p.description AS description ORDER BY p.name
```
- `CREATE` (unlike `MERGE`) always creates a new node — `GraphRepository` checks
`PROJECT_EXISTS` first to return `409 Conflict` instead of creating duplicates.
- `DETACH DELETE` removes a node **and** all its relationships in one step — required
because Neo4j refuses to delete a node that still has relationships attached.
`OPTIONAL MATCH` ensures the query doesn't fail if the project has no `AstNode`s
yet (an empty/never-ingested project).
---
## 4. Using Neo4j for program analysis (and beyond)
### 4.1 Why a graph model fits program analysis
Most interesting questions about source code are graph questions:
| Question | Graph operation |
|---|---|
| "Who calls this function?" | incoming `CALLS` edges (1 hop) |
| "What's the full call tree under this module?" | variable-length traversal (`*0..N` or `*0..`) |
| "Is there a cycle in the call graph?" | path-finding / cycle detection |
| "What tables does this module touch, transitively?" | traversal + filter by node type |
| "What's the shortest call path from A to B?" | `shortestPath()` |
| "Which modules are most central / most depended-upon?" | graph algorithms (degree, PageRank via GDS) |
| "Show me everything that would break if I delete this function" | reverse traversal of `CALLS`/`USES_TYPE`/`READS`/`WRITES` |
A relational database *can* answer these with recursive CTEs, but each additional
hop means another self-join; performance degrades quickly with depth. In Neo4j,
traversal cost depends on the size of the *result*, not the size of the *whole
table* — a 5-hop traversal over a 10-million-node graph is fast if only a handful of
nodes match.
### 4.2 Patterns already used in AgenticCode
- **Call graph navigation** (`CALLERS`/`CALLEES`): direct application of the table
above — "who calls / is called by this module."
- **Data/DB lineage** (`DB_ACCESSES`): traversal from a module down to the DB tables
it reads/writes, classified by access mode.
- **Cross-module search** (`SEARCH_IDENTIFIER`): flat lookup by property, independent
of graph structure — Neo4j is also a perfectly good "property store" for this.
- **Multi-tenancy** (`project` property): a lightweight way to partition the graph
without separate databases, while still allowing (in principle) cross-project
queries later if ever needed (e.g. "does any project call this shared library?").
### 4.3 Ideas for future enrichment / queries
These map directly to the `EnrichmentPipelines` design and the agent-facing API:
- **Impact analysis**: `MATCH (n {name:$name})<-[:CALLS|READS|WRITES|USES_TYPE*1..3]-(dep) RETURN dep` —
"what depends on this, up to 3 hops away?"
- **Dead code detection**: `MATCH (f:AstNode {type:'FUNCTION', project:$project}) WHERE NOT ()-[:CALLS]->(f) RETURN f` —
functions with no incoming `CALLS` edge.
- **Full call chains to a DB table**: `MATCH path = (m:AstNode {type:'MODULE'})-[:CONTAINS*0..]->()-[:READS|WRITES]->(t:AstNode {type:'DB_TABLE', name:$table}) RETURN path` —
every module that ultimately touches a given table, with the full path for
explanation.
- **Cycle detection in the call graph**: `MATCH (f:AstNode)-[:CALLS*1..10]->(f)` (with a
hop limit to bound cost) — useful for spotting recursive or circular dependencies
in legacy Natural code.
### 4.4 Practical tips while developing
- **Neo4j Browser** (`http://localhost:7474`) is the fastest way to explore: run any
`CypherQueries` string directly with literal values to see the actual graph and
tune the query before wiring it into `GraphRepository`.
- **`EXPLAIN`/`PROFILE`** a query (`PROFILE MATCH ...`) to see whether it's using an
index or doing a full label scan — important once a project has many thousands of
`AstNode`s.
- **Add indexes** for properties used in `WHERE`/`MATCH` patterns, e.g.:
```cypher
CREATE INDEX ast_node_project_name IF NOT EXISTS
FOR (n:AstNode) ON (n.project, n.name);
CREATE INDEX ast_node_project_type_name IF NOT EXISTS
FOR (n:AstNode) ON (n.project, n.type, n.name);
```
(Not yet present in the codebase — worth adding once ingest volumes grow; see the
open TODO "Deploy unified AST schema to Neo4j (constraints + indexes)".)
- **Resetting data** during development: `MATCH (n) DETACH DELETE n` wipes the
entire database (all projects) — use the project-scoped `DELETE_PROJECT` query
instead when you only want to clear one project.