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:
15
CLAUDE.md
15
CLAUDE.md
@@ -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.
|
||||
|
||||
72
README.md
72
README.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
ac-cli/src/main/java/com/agenticcode/cli/CliConfig.java
Normal file
66
ac-cli/src/main/java/com/agenticcode/cli/CliConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
92
ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java
Normal file
92
ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
ac-cli/src/main/java/com/agenticcode/cli/UseCommand.java
Normal file
25
ac-cli/src/main/java/com/agenticcode/cli/UseCommand.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
/**
|
||||
* Outcome of a project create/update/delete operation.
|
||||
*/
|
||||
public enum ProjectOpResult {
|
||||
SUCCESS,
|
||||
CONFLICT,
|
||||
NOT_FOUND
|
||||
}
|
||||
539
x-docs/neo4j-introduction.md
Normal file
539
x-docs/neo4j-introduction.md
Normal 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.
|
||||
Reference in New Issue
Block a user