100 Commits

Author SHA1 Message Date
Ingo Schnabel
234ea76911 Roadmap 2026-09-23 15:31:45 +02:00
Ingo Schnabel
7e0d75cc5e Roadmap 2026-09-23 12:39:15 +02:00
Ingo Schnabel
432ddf1c11 Roadmap 2026-09-23 09:30:00 +02:00
Ingo Schnabel
133e4e888d Roadmap 2026-09-23 09:03:59 +02:00
Ingo Schnabel
26d862b5e8 Typescript 2026-09-23 09:03:46 +02:00
Ingo Schnabel
10971915e9 Typescript 2026-09-23 08:15:53 +02:00
Ingo Schnabel
7043c8ab0b Performance Part 4 2026-09-07 16:46:03 +02:00
Ingo Schnabel
3182385e5f Performance Part 3 2026-09-06 20:21:04 +02:00
Ingo Schnabel
af68b8916c Performance Part 3 2026-09-06 20:15:22 +02:00
Ingo Schnabel
e5873f0e1d Performance Part 3 2026-09-06 18:30:30 +02:00
Ingo Schnabel
d6924a1dc2 Performance Part 3 2026-09-06 14:04:49 +02:00
Ingo Schnabel
56314d5e73 Performance Part 3 2026-09-06 13:44:10 +02:00
Ingo Schnabel
c4ff96b5fe Performance Part 2 2026-09-06 10:11:00 +02:00
Ingo Schnabel
9ce92c974c Performance Part 1 2026-09-05 17:39:57 +02:00
Ingo Schnabel
736fb48512 Performance 2026-09-05 10:27:31 +02:00
Ingo Schnabel
40f9aaf551 Cleanup 2026-09-04 19:02:46 +02:00
Ingo Schnabel
c3eaa0f55e Cleanup 2026-09-04 19:02:31 +02:00
Ingo Schnabel
039ff1e176 Roadmap 2026-08-27 18:24:28 +02:00
Ingo Schnabel
2732d69bd1 No DB-ACCESS without JPA Entities 2026-08-27 17:30:57 +02:00
Ingo Schnabel
8bfef47e1e CONTAINS Cycles 2026-08-23 11:22:53 +03:00
Ingo Schnabel
bd86a01e92 New feature 2026-08-21 23:55:50 +03:00
Ingo Schnabel
7adf7e54e4 Bug fixes 2026-08-20 17:11:16 +03:00
Ingo Schnabel
4b8a441566 New features 2026-08-20 10:32:15 +03:00
Ingo Schnabel
5091cba820 New features 2026-08-19 22:39:10 +03:00
Ingo Schnabel
ec924971a9 New features 2026-08-19 13:18:24 +03:00
Ingo Schnabel
56e20aee3b New features 2026-08-19 10:55:03 +03:00
Ingo Schnabel
29269b83c3 New features 2026-08-19 09:52:24 +03:00
Ingo Schnabel
907286dabb New features 2026-08-18 17:22:31 +03:00
Ingo Schnabel
f5ca2584f3 New features 2026-08-18 13:17:13 +03:00
Ingo Schnabel
0e54cc1589 New features 2026-08-18 12:54:39 +03:00
Ingo Schnabel
3309afe0e1 New features 2026-08-18 12:02:09 +03:00
Ingo Schnabel
cf5d5ea821 Reap feature 2026-08-14 10:25:39 +03:00
Ingo Schnabel
b6007b2096 Reap feature 2026-08-10 13:36:44 +03:00
Ingo Schnabel
97081718fd Java improvements 2026-08-09 13:07:52 +03:00
Ingo Schnabel
93589c2eb9 Java improvements 2026-08-09 10:30:26 +03:00
Ingo Schnabel
e2e8448b85 Java improvements 2026-08-06 18:54:35 +02:00
Ingo Schnabel
64d1a75f7c Java improvements 2026-08-06 17:54:28 +02:00
Ingo Schnabel
bb45865966 Java improvements 2026-08-06 14:56:00 +02:00
Ingo Schnabel
6cce4526c4 Java improvements 2026-08-06 14:25:35 +02:00
Ingo Schnabel
e29e91528c Java improvements 2026-08-06 14:22:39 +02:00
Ingo Schnabel
54fa071730 Java improvements 2026-08-06 14:22:23 +02:00
Ingo Schnabel
cac0cba379 Java improvements 2026-08-06 12:57:11 +02:00
Ingo Schnabel
19f59ff04e Logging 2026-08-06 10:25:05 +02:00
Ingo Schnabel
892847d5be Logging 2026-08-06 10:17:39 +02:00
Ingo Schnabel
54ed6bc822 Performance 2026-08-06 08:46:03 +02:00
Ingo Schnabel
949ce5d568 Performance 2026-08-05 16:01:07 +02:00
Ingo Schnabel
e07a071dbd Memory 2026-08-05 13:31:18 +02:00
Ingo Schnabel
ea654d0ce5 Return 409 NOT_INGESTED 2026-08-05 12:19:59 +02:00
Ingo Schnabel
2c56eea161 Remove MCP 2026-08-04 12:57:08 +02:00
Ingo Schnabel
3db477f4a3 Roadmap: vier Findings aus dem upms-Webservice-Audit (107-110) + Datenpunkt zu 26
Gefunden beim Beantworten der Frage, ob ein W*-Webservice-Modul eine
Provisionsberechnung ausloesen kann.

107 (Known bug, der folgenreichste): jeder /modules/{name}/-Endpunkt
antwortet 200 mit einer leeren Huelle fuer ein Modul, das gar nicht
existiert - digest fuer WXSPOD0S und fuer NOSUCHMOD123 sind identisch.
search/identifier liefert korrekt []. Dokumentiert ist 404. Das hat bei
mir zu einem falschen Ergebnis gefuehrt: call-tree auf 13 nicht
ingestierte Module las sich als "analysiert, nichts gefunden" statt
"nicht analysierbar". Gleiche Klasse wie 103.

108: dispatch-table versteht nur den DECIDE-Verteiler, nicht das
Dispatch-Tabellen-Idiom (ASSIGN #WT-OBJ-PROG(n) = 'MODUL' + CALLNAT
#W-ACT-PROG) - 10 der 116 offenen dynamischen Aufrufe. Verwandt mit 83,
aber ueber ein Array-Element statt einen Skalar.

109: variables/{name}/writes liefert den Ort, nicht den geschriebenen
Wert - fuehrt bis vor die Zeile und hoert dort auf. assignedValue gibt
es bei dispatch-table bereits.

110: keine Erreichbarkeitsabfrage. "Wer kann X ausloesen" musste als
Client-BFS ueber /callers gebaut werden: ~100 Requests fuer eine Frage,
die in Cypher ein gebundener shortestPath ist.

Zu 26: MCP-Fehler reproduziert, diesmal ab dem allerersten Tool-Aufruf
der Sitzung - spricht gegen Idle-Timeout, fuer eine nie zustande
gekommene Session. REST lief parallel normal.
2026-08-02 19:30:24 +02:00
Ingo Schnabel
38d5fafdc8 Versions 2026-07-28 16:22:56 +02:00
Ingo Schnabel
2cf16274c3 Bug fixes 2026-07-28 09:30:03 +02:00
Ingo Schnabel
d4b14a3b6d Bug fixes 2026-07-27 16:20:42 +02:00
Ingo Schnabel
4e1798eb02 README 2026-07-27 10:52:09 +02:00
Ingo Schnabel
a07405620a UI tests 2026-07-27 10:49:33 +02:00
Ingo Schnabel
c8ab0274c3 UI tests 2026-07-27 08:55:37 +02:00
Ingo Schnabel
364bcb2932 FollowWiring Bug 2026-07-20 09:01:09 +02:00
Ingo Schnabel
76159b0d25 Resolve dynamic by agent 2026-07-20 08:03:14 +02:00
Ingo Schnabel
ecfd8f94e2 Java bugs 2026-07-19 21:26:19 +02:00
Ingo Schnabel
9693c25edb Resolve dynamic by agent 2026-07-19 20:20:25 +02:00
Ingo Schnabel
acebfdca83 Resolve dynamic by agent 2026-07-19 18:13:42 +02:00
Ingo Schnabel
5880ebad46 Resolve dynamic by agent 2026-07-19 18:08:19 +02:00
Ingo Schnabel
dcaadce964 Resolve dynamic by agent 2026-07-19 15:58:24 +02:00
Ingo Schnabel
284bb9f110 Resolve dynamic by agent 2026-07-19 13:35:14 +02:00
Ingo Schnabel
826508f70a Resolve dynamic by agent 2026-07-19 13:00:17 +02:00
Ingo Schnabel
0dfc85a11b Fixes 2026-07-19 10:34:15 +02:00
Ingo Schnabel
2a45f9fdc2 Bugs 2026-07-19 08:34:46 +02:00
Ingo Schnabel
1095ce94fe Bugs 2026-07-18 22:22:11 +02:00
Ingo Schnabel
0f3d9c8ec1 Skript + Bugs 2026-07-18 18:31:46 +02:00
Ingo Schnabel
8d27d7cc7e Skript + Bugs 2026-07-17 16:36:48 +02:00
Ingo Schnabel
ee4bbd31d6 Skript + Bugs 2026-07-17 16:28:51 +02:00
Ingo Schnabel
30c583610e Skript + Bugs 2026-07-17 16:27:38 +02:00
Ingo Schnabel
c5e6a90038 Skript + Bugs 2026-07-17 16:25:22 +02:00
Ingo Schnabel
9d234ebd0d Bug fix 71 2026-07-17 13:37:26 +02:00
Ingo Schnabel
9da3e42b4e Bug fix 69 2026-07-17 09:13:18 +02:00
Ingo Schnabel
1224efbf5b Bug fix 66 2026-07-16 18:25:13 +02:00
Ingo Schnabel
5a53731305 Bug fix 2026-07-16 17:39:09 +02:00
Ingo Schnabel
692d4a13cc Bug fix 2026-07-16 17:37:44 +02:00
Ingo Schnabel
fbc8317daa Bug fix 2026-07-16 16:51:03 +02:00
Ingo Schnabel
0d46fcb489 Bug fix 2026-07-16 16:50:42 +02:00
Ingo Schnabel
e0ce83c422 Bug fix 2026-07-16 16:50:39 +02:00
Ingo Schnabel
12ff54d485 Bug fix 2026-07-16 14:14:34 +02:00
Ingo Schnabel
8e44df68c7 Bug fix 2026-07-16 12:53:26 +02:00
Ingo Schnabel
52c03130b3 Roadmap 2026-07-15 20:44:03 +02:00
Ingo Schnabel
12750f2086 Refresh and Tokenizer 2026-07-15 20:04:43 +02:00
Ingo Schnabel
9f42190097 Claude 2026-07-15 13:39:57 +02:00
Ingo Schnabel
17d8a7853c Scanner 2026-07-15 13:19:15 +02:00
Ingo Schnabel
9d6b577be5 Bug fixes 2026-07-15 13:10:10 +02:00
Ingo Schnabel
4d2cbf37cd Bug fixes 2026-07-14 19:41:02 +02:00
Ingo Schnabel
2d3d3d33f3 Bug fixes 2026-07-14 18:04:15 +02:00
Ingo Schnabel
e98e9930f1 Bug fixes 2026-07-14 14:46:57 +02:00
Ingo Schnabel
b8539ca011 Bug fixes + ingest 2026-07-14 12:16:22 +02:00
Ingo Schnabel
5fb86cb743 Bug fixes + ingest 2026-07-14 10:53:02 +02:00
Ingo Schnabel
c2b70d01de Bug fixes 2026-07-14 10:15:41 +02:00
Ingo Schnabel
ebf25ad773 Bug fixes 2026-07-14 09:59:06 +02:00
Ingo Schnabel
438b0841d4 M2 + deploy 2026-07-14 08:07:54 +02:00
Ingo Schnabel
cafc057fa9 SLoC count 2026-07-13 17:43:32 +02:00
Ingo Schnabel
c49901e919 Payload and sql 2026-07-13 14:29:39 +02:00
Ingo Schnabel
3cf712fc98 Copycode expansion, user-exit LoC split, XML payload
- 46a: general .cpy copycode expansion — statement-level INCLUDE <member> <args>
  expanded (deep + coarse) before parsing via CopycodePreprocessor with positional
  &1&… substitution and real file positions; per-ingest CopycodeLibrary resolves
  .cpy name→path. Copycodes not ingestible as standalone modules.
- 47: generated vs. user-exit LoC/SLoC split — generatedDir/userExitDir pair,
  UserExitMetrics.scan stamps userExitLoc/userExitSloc on generated twins;
  /loc reports total, user-exit, and generated-exclusive per language and total.
- 46b: XML payload tag-builder + derived tags; SQL statement extraction.

Tests: CopycodeExpansionIT, UserExitLocIT, ProjectResourceIT,
NaturalIncludeMacroAndXmlPayloadIT, ac-parser-natural (61) all green.
2026-07-13 13:57:24 +02:00
Ingo Schnabel
d7f26f278d Payload and sql 2026-07-12 23:19:04 +02:00
424 changed files with 53940 additions and 4251 deletions

13
.claude/settings.json Normal file
View File

@@ -0,0 +1,13 @@
{
"permissions": {
"deny": [
"Bash(git commit:*)",
"Bash(git push:*)"
],
"ask": [
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(git:*)"
]
}
}

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
# Build context for ac-code-server's Dockerfile.jvm is the repository root (item 192: the image also
# carries ac-parser-typescript/sidecar). Send only what the Dockerfile COPYs — never .git, target/
# trees, ac-ui/node_modules or the sidecar's own node_modules (npm ci installs those in the image).
*
!ac-code-server/target/quarkus-app/
!ac-parser-typescript/sidecar/package.json
!ac-parser-typescript/sidecar/package-lock.json
!ac-parser-typescript/sidecar/extract.mjs

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"agenticcode": {
"type": "sse",
"url": "http://localhost:8787/mcp/sse"
}
}
}

View File

@@ -6,15 +6,28 @@
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.
* **Always use agentic code** see x-docs/mcp-api-usage-ac-implementation.md to understand the code, get an overview and
* **Always use agentic code** see x-docs/agent-api-usage-ac-implementation.md to understand the code, get an overview
and
if somethinmg is missing or not working, report it directly. Also. find identifier via agentic code. if agentic code
is not running, report it.
* **NEVER start Docker yourself — the human starts it.** If you need the server/Neo4j for analysis and
`http://localhost:8787` is unreachable, **stop and ask the user to run `./manage-ac.sh deploy`** (via
`AskUserQuestion`, per the rule above). Do not run `./manage-ac.sh deploy`, `docker compose up/start/restart`, or
otherwise bring containers up on your own initiative — not to "just check something", not because it is
faster. Starting the stack is the human's call, like committing. The same goes for stopping or restarting
it: ask first. You may *read* container state (`docker ps`, logs) freely.
* **Clean up what you start.** Any long-running probe you launch (`cypher-shell`, background Bash, a query
against Neo4j) must be stopped before you report done. `TaskStop` kills the *client*, not the JVM behind
a `docker exec` — verify with `ps` on the host **and** inside the container, then say so. Never report
"all shells stopped" from the task list alone.
* If agentic code is not ingested, refresh it again (`ac refresh` / `POST /api/projects/ac/refresh`)
* After implementation do a refresh again to get the latest changes.
* **Keep REST API, MCP, and `ac-cli` in sync.** Every REST endpoint added or changed in
`ac-code-server/.../api/*Resource.java` MUST have a matching MCP tool in `.../mcp/Mcp*Tools.java` **and** a matching
`ac-cli` command, delivered in the same change. Treat REST + MCP + CLI as one unit of work — none of the three is
"done" without the other two.
* **A deep refresh is long and mutates the graph.** Do not abort one halfway: the earlier enrichment steps
are already committed, so the graph is left partially updated and every later query silently answers from
it. If one must be stopped, say plainly that the graph is now in a half-updated state.
* **Keep the REST API and `ac-cli` in sync.** Every REST endpoint added or changed in
`ac-code-server/.../api/*Resource.java` MUST have a matching `ac-cli` command, delivered in the same change. Treat
REST + CLI as one unit of work — neither is "done" without the other.
## 1. Core Rules
- Never assume. Always read files first with tools.
@@ -27,10 +40,14 @@
- 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.
- **The human decides what to commit and when.** Committing is a human decision, not yours. Do not stage, group, or
propose commits on your own initiative, and do not nudge toward committing. You may prepare a suggested commit message
when asked, but the act — and its timing and scope — is the human's call alone.
- All features must be tracked in `x-docs/roadmap.md`. Once a feature has been implemented, mark it `[x]` and add a
timestamp (date) indicating when it was completed.
- For **every feature** that changes API behavior or what an agent can query, you MUST update
`x-docs/mcp-api-usage-ac-implementation.md` to reflect it (new/changed endpoints, response fields, semantics) — treat
`x-docs/agent-api-usage-ac-implementation.md` to reflect it (new/changed endpoints, response fields, semantics) —
treat
this doc
update as part of the feature's Definition of Done, not an optional follow-up.
- Never add a `Co-Authored-By:` line (or any AI-attribution trailer) to commit messages.
@@ -38,34 +55,32 @@
server runs at `http://localhost:8787` with this repo already ingested as project `ac`. Prefer it
over grep/Explore for call graphs, callers/callees, DB access, dataflow, and module overviews — it's
exactly the tool this project builds, so using it here is both faster and the best test of its own
output. Usage guide: `x-docs/mcp-api-usage-ac-implementation.md`. Re-ingest after code changes
output. Usage guide: `x-docs/agent-api-usage-ac-implementation.md`. Re-ingest after code changes
(`ac refresh` / `POST /api/projects/ac/refresh`, or `--deep`/`?deep=true` for a full field-level
pass) before trusting query results.
- **Tool priority: MCP first, then REST API, then the `ac` CLI, then grep/Explore.** Try the
`mcp__agenticcode__*` MCP tools before anything else. If an MCP call fails or misbehaves (e.g. a
session/protocol error), fall back to the equivalent REST endpoint
(`GET /api/projects/ac/modules/{name}/...`) rather than abandoning the API — don't let one broken
MCP call push you straight to grep. The `ac <command>` CLI (`ac callers`, `ac callees`,
- **Tool priority: REST API first, then the `ac` CLI, then grep/Explore.** Query the REST endpoints
(`GET /api/projects/ac/modules/{name}/...`) before anything else. The `ac <command>` CLI (`ac callers`,
`ac callees`,
`ac call-tree`, `ac context`, `ac db-accesses`, `ac refresh`, ...) is a convenience wrapper over the same REST API,
useful for quick manual checks. **If the server is unavailable** (`http://localhost:8787`
unreachable), try starting it first with `./deploy.sh up` before falling back. **Only when it still
can't answer the question** (info the API doesn't expose, or the analysis needs exact source
text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent) exactly as you
would on any other codebase.
- **If a needed capability is missing from MCP/API/CLI** (not just unreachable, but the question is
unreachable), **ask the user to run `./manage-ac.sh deploy`** — never start it yourself (see Hard Rules).
**Only when it still can't answer the question** (info the API doesn't expose, or the analysis needs
exact source text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent)
exactly as you would on any other codebase.
- **If a needed capability is missing from the API/CLI** (not just unreachable, but the question is
one none of them can answer at all), don't silently fall back to grep and move on — after finishing
the task with the grep-based fallback, use `AskUserQuestion` to tell the user what was missing and
ask whether it should be added as a feature in `x-docs/roadmap.md`.
## 2. Mandatory 4-Phase Workflow
0. **Clarify Intent** -Assess if the user's request is fully clear.
``0. **Clarify Intent** -Assess if the user's request is fully clear.
- **Clear**: State your understanding and proceed.
- **Unclear**: Use `AskUserQuestion` tool with concrete options. Repeat until unambiguous.
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 a commit message, but do not commit unless asked (see Core Rules).
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.
@@ -73,14 +88,14 @@ Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched
## Architecture Overview
```
Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST + MCP)
Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST)
```
- **Natural Parser**: custom implementation (no OSS parser available for Software AG Natural)
- **Java Parser**: JavaParser library
- **Graph Store**: Neo4j — nodes for modules, functions, variables, data structures, DB accesses
- **Enrichment**: call graph, identifier index, data structures, ADABAS/SQL access patterns
- **API**: Quarkus REST (JAX-RS) + MCP endpoint, optimized for AI agent tool use
- **API**: Quarkus REST (JAX-RS), optimized for AI agent tool use
## Tech Stack
@@ -107,8 +122,7 @@ agenticcode/
│ ├── src/main/java/de/agenticcode/
│ │ ├── api/ # JAX-RS Resources
│ │ ├── service/ # Business logic
│ │ ├── enrichment/ # Enrichment pipelines
│ │ └── mcp/ # MCP tool endpoints
│ │ └── enrichment/ # Enrichment pipelines
│ └── src/main/resources/
│ └── application.properties
├── ac-parser-natural/ # Natural parser module
@@ -155,7 +169,7 @@ mvn test -pl ac-parser-natural
Requirements: Java 21+, Maven 3.9+, Docker (for Testcontainers).
```bash
# Start Neo4j locally for dev
# Start Neo4j locally for dev — for the human to run, not the agent (see Hard Rules)
docker run -p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/agenticcode \
neo4j:5
@@ -220,7 +234,7 @@ The API is designed primarily for AI agent tool use:
- `GET /api/search/identifier?name=...` — find an identifier across all modules
- `POST /api/ingest` — parse and ingest source code into the graph
MCP endpoint structure: `@docs/api-endpoints.md`
Endpoint structure: `@docs/api-endpoints.md`
## Natural Parser: Key Constructs
@@ -281,5 +295,4 @@ mvn test -Dtest="*IT"
| Multi-module Maven | Parsers are independently testable and replaceable |
| Custom Natural parser | No OSS parser available for Software AG Natural |
| JavaParser over tree-sitter | Mature Java library, type-safe AST API |
| MCP alongside REST | Native integration with Claude Code and other AI agents |

583
README.md
View File

@@ -2,7 +2,10 @@
> AI-agent-optimized code analysis platform — parse, store, enrich, and query source code as a graph.
AgenticCode ingests source code (Natural/Software AG and Java), parses it into a unified AST, persists it in Neo4j, enriches it with semantic information (call graphs, DB accesses, data structures), and exposes everything through an agent-ready REST + MCP API.
AgenticCode ingests source code (Natural/Software AG, Java and TypeScript/React), parses it into a unified AST, persists
it in Neo4j,
enriches it with semantic information (call graphs, DB accesses, data structures, dynamic-dispatch resolution), and
exposes everything through an agent-ready **REST** API, a **CLI**, and a **web UI**.
---
@@ -35,12 +38,13 @@ flowchart TD
end
subgraph API["Agentic API — Quarkus"]
REST["REST API\nJAX-RS · RESTEasy Reactive"]
MCP["MCP Endpoint\ntool-use optimized"]
REST["REST API\nJAX-RS · RESTEasy Reactive · tool-use optimized"]
end
subgraph Agents["AI Agents"]
AG(["Claude / LLM Agent\ncall graph · DB access · identifier search\ncode generation · program analysis"])
subgraph Clients["Clients"]
AG(["Claude / LLM Agent\nvia REST"])
CLI(["ac CLI"])
UI(["Web UI\nReact + Vite"])
end
N --> NP
@@ -52,43 +56,380 @@ flowchart TD
PI --> NEO
NEO --> CG & DB & ID & DS
CG & DB & ID & DS --> REST
CG & DB & ID & DS --> MCP
REST --> AG
MCP --> AG
REST --> AG & CLI & UI
```
---
## Key API Tools (Agent-Facing)
## Quick Start
| 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 |
**Prerequisites:** Java 21+, Maven 3.9+, Docker (with the Compose plugin). Node 20+ only if you want to run the web UI.
The repo ships a `docker-compose.yml` (Neo4j 5 + `ac-code-server` + `ac-ui`) and a `manage-ac.sh` wrapper.
```bash
git clone https://github.com/your-org/agenticcode.git
cd agenticcode
# Build all modules, start Neo4j + ac-code-server + ac-ui, and install the `ac` CLI launcher
./manage-ac.sh deploy
```
`./manage-ac.sh deploy` builds the project, brings the Compose stack up (leaving an already-running Neo4j untouched),
serves the web UI at `http://localhost:5174`, and installs the `ac` launcher to `~/.local/bin/ac`. See
[`manage-ac.sh`](#manage-acsh--the-stack-manager) below for all commands.
Once up:
- **Web UI** — `http://localhost:5174`
- **REST API** — `http://localhost:8787/api`
- **OpenAPI / health** — `http://localhost:8787/q/openapi`, `http://localhost:8787/q/health`
- **Neo4j browser** — `http://localhost:7474` (credentials `neo4j` / `agenticcode`)
### Ingest a codebase
Ingestion is **project-root based**: you register a project pointing at a server-side source root, then trigger a scan.
There is no per-file upload step.
```bash
# 1. Create a project pointing at a source root, with its language
ac project create upms /path/to/natural-sources -l natural -d "UPMS legacy"
# 2. Scan the root into the graph
# default: fast whole-root pass (call graph + identifier index; field/dataflow detail is
# resolved lazily per module on demand). Add --deep for a full field-level ingest up front.
ac refresh -p upms
ac refresh -p upms --deep # full field-level pass
# 3. Query it
ac callees WGEAGB0S -p upms
```
Re-run `ac refresh` after the sources change; it reconciles per file (unchanged files are left as-is).
`ac refresh <MODULE> -p upms` deep-ingests one module plus its transitive `CALLNAT`/`PERFORM` dependency tree (lazy
Tier-2).
### `manage-ac.sh` — the stack manager
`manage-ac.sh` builds and runs the whole stack (Neo4j + `ac-code-server` + `ac-ui`) via docker-compose and installs the
`ac` CLI. Run it with no argument (or `help`) to print the command list — a bare invocation deliberately does **not**
deploy, since a full deploy bumps the version and rebuilds everything.
```bash
./manage-ac.sh <command>
```
| Command | What it does |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `deploy` | Full deploy: bump version, `mvn clean install`, rebuild + restart `ac-code-server`, rebuild + start `ac-ui`, install/refresh the `ac` CLI. Neo4j is left running if already up. |
| `restart` | Restart `ac-code-server` only — **no build**. Also the way to abort a long server-side job (a deep refresh keeps running after its HTTP client is killed). |
| `stop` | Stop `ac-code-server` only; Neo4j and `ac-ui` keep running. |
| `down` | Stop the whole stack, Neo4j included. **The graph volume is kept** (never `down -v`). |
| `status` | Show containers, the answering server version, and the ingested projects — works even when the stack is down. |
| `cli` | Build and (re)install `ac` only — no Docker involved. |
| `ui` | Rebuild + restart `ac-ui` only (npm build runs inside Docker; no local Node needed). |
| `logs [-f]` | Last 200 lines of server logs; `-f` to follow. |
The server answers on `http://localhost:8787`, and the **Dockerized UI on `http://localhost:5174`** (distinct from the
local Vite dev server on 5173). The version bump lives in the Maven build, so every `deploy` (a full `install`) bumps
`agenticcode.version` and re-stamps the CLI; `mvn test`/`compile`/`quarkus:dev` do not.
### `rebuild-and-refresh.sh` — redeploy then deep-refresh
A one-shot convenience script that redeploys the server and re-ingests the given project(s) from scratch — use it after
code changes that affect parsing or enrichment, so the graph reflects the new build. **One or more project names are
required** (there is no default; running it with no argument prints usage and exits).
```bash
./rebuild-and-refresh.sh upms # one project
./rebuild-and-refresh.sh upms pur # several, refreshed in order
```
It runs the full sequence, blocking until done: **stop** the server → **`manage-ac.sh deploy`** (version bump +
server/UI rebuild) → **wait** for `http://localhost:8787/api/projects` to answer (timeout `READY_TIMEOUT`, default 300
s) → **deep-refresh** each named project synchronously → print per-project timings and ring the terminal bell (and
`notify-send` if available).
Overridable via env: `AC` (CLI launcher, default `ac`), `AC_SERVER_URL` (default `http://localhost:8787`),
`READY_TIMEOUT`.
> A deep refresh is long and mutates the graph — **don't interrupt it once running**; the earlier enrichment steps are
> already committed, so an aborted refresh leaves the graph half-updated.
### Dev mode (hot reload)
```bash
mvn clean install
docker compose up -d neo4j # just the Neo4j service
cd ac-code-server && mvn quarkus:dev # server with hot reload
```
---
## REST API
All analysis endpoints are scoped to a project: `/api/projects/{project}/...`. Responses are concise machine-readable
JSON with `limit`/`offset` pagination where lists can be large.
### Projects & ingestion
| Endpoint | Description |
|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `GET /api/projects` | List all projects |
| `POST /api/projects/{project}` | Create a project (body: `root`, `language`, optional `description`, `excludeDirs`, `generatedDir`, `userExitDir`, `counterparts`) |
| `PUT /api/projects/{project}` | Update a project (unset fields left unchanged) |
| `DELETE /api/projects/{project}` | Delete a project and all its data |
| `POST /api/projects/{project}/refresh[?deep=true]` | (Re)scan the whole root — call-graph pass, or full field-level with `deep=true` |
| `POST /api/projects/{project}/refresh/{name}` | Deep-ingest one module + its dependency tree (`maxDepth`, `maxNodes`, `neighborhood`) |
| `POST /api/projects/{project}/recreate` | Drop and rebuild the project's graph from disk |
### Call graph & structure
| Endpoint | Description |
|----------------------------------------------------------------------------------|------------------------------------------------------------|
| `GET .../modules` | List modules (filter by name/kind) |
| `GET .../modules/{name}/callers` | Who calls this module (`scope=external\|internal`) |
| `GET .../modules/{name}/callees` | What this module calls (`scope=external\|internal`) |
| `GET .../modules/{name}/call-tree` | Recursive callee tree |
| `GET .../modules/{name}/context` | Compact module summary for agents |
| `GET .../modules/{name}/digest` | One-line module digest |
| `GET .../modules/{name}/functions` | Subroutines/methods in the module |
| `GET .../modules/{name}/functions/{fn}/callers` | Callers of a specific function |
| `GET .../modules/{name}/functions/{fn}/overrides` | Polymorphic overrides of a method |
| `GET .../modules/{name}/graph` | Ego graph (bounded neighbourhood; `dir`, `depth`, `limit`) |
| `GET .../modules/{name}/source` · `GET .../nodes/{id}/source` · `GET .../source` | Source text of a module / node / file |
### Data, DB access & payload
| Endpoint | Description |
|-----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `GET .../modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
| `GET .../store?slice=` · `GET .../store/{slice}/accesses?field=&mode=` | Frontend Redux store: slices, state keys, who reads/writes them (item 194) |
| `GET .../bindings?dto=&field=&mode=&module=` | DTO field bindings: which component reads/writes which backend field, with its Java counterpart (item 195) |
| `GET .../theme?unused=` · `GET .../theme/{token}/usages` · `GET .../styles` | MUI theme tokens with use counts, where a token is read, and the sx/style/styled/CSS inventory with hard-coded literals (item 196) |
| `GET .../modules/{name}/sql-statements` | SQL/ADABAS statements |
| `GET .../modules/{name}/workfile-accesses` | Natural work-file reads/writes |
| `GET .../modules/{name}/data-structures` | Data structures used by the module |
| `GET .../modules/{name}/payload` | Parameter-data-area I/O contract |
| `GET .../modules/{name}/columns` · `.../modules/{name}/dispatch-table` | Entity columns · DECIDE dispatch table |
| `GET .../data-structures/{name}/fields` · `.../db-tables/{name}/columns` | Fields of a structure · columns of a table |
### Search & dataflow
| Endpoint | Description |
|----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| `GET .../search/identifier?name=[&priorityModule=]` | Find an identifier across modules (`priorityModule` pins one module's matches to the front) |
| `GET .../search/value` · `.../search/source` · `.../search/annotation` | Search literals · source text · annotations |
| `GET .../variables/{name}/reads` · `.../writes` | Where a variable is read / written (optional `module=`) |
| `GET .../variables/{name}/flow-forward` · `.../flow-backward` · `.../field-flow` | Argument→parameter dataflow, and shared-field producer→consumer |
| `GET .../nodes/{id}` | Inspect a raw graph node |
| `GET .../loc` · `GET /api/version` | Project LOC metrics · server version |
### Dynamic-dispatch (Natural `CALLNAT <var>`)
| Endpoint | Description |
|--------------------------------------|--------------------------------------------------------------------|
| `GET .../dynamic-calls/unresolved` | Dynamic call sites no resolver recovered — candidates to pin |
| `GET .../dynamic-calls/overrides` | Manual overrides (flagged `obsolete` if auto-resolution caught up) |
| `POST .../dynamic-calls/overrides` | Pin a dynamic call site to target module(s) |
| `DELETE .../dynamic-calls/overrides` | Reset one override (`originFile`+`lineNo`) or all |
Dynamic `CALLNAT` targets are recovered automatically where possible: direct string literals, indirect lookup-array
copies, cross-module parameter flow, and **constant-folded string assembly** (`MOVE 'YABALKEY' TO #M` +
`MOVE 'GN0' TO SUBSTR(#M,6,3)` → `YABALGN0`). A manual override always wins over an auto-fold. What can't be recovered
stays visible as an unresolved site.
---
## Agent usage (Claude Code & other agents)
The server exposes every query capability as REST endpoints under `http://localhost:8787/api` — e.g.
`/projects`, `/projects/{p}/modules`, `.../callers`, `.../callees`, `.../call-tree`, `.../db-accesses`,
`/search/identifier`, `.../context`, `.../payload`, `.../dispatch-table`, `/variables/{n}/flow-forward` and
`/flow-backward`, `/variables/{n}/field-flow`, `/variables/{n}/reads` and `/writes`,
`/dynamic-calls/unresolved`, `/dynamic-calls/overrides`, `/refresh`, and more. The OpenAPI spec is served at
`/q/openapi`. A full usage guide with response fields and semantics lives in [
`x-docs/agent-api-usage-ac-implementation.md`](x-docs/agent-api-usage-ac-implementation.md).
---
## Web UI
A React + Vite + Tailwind front-end (`ac-ui/`) for browsing projects and modules interactively — built for reading
legacy Natural/Java and planning migrations.
### Run it
If you ran `./manage-ac.sh deploy` (or `./manage-ac.sh ui`), the UI is **already built and served in Docker
at `http://localhost:5174`** — no local Node needed. For front-end development, run the Vite dev server instead:
```bash
cd ac-ui
npm install
npm run dev # Vite dev server on http://localhost:5173 (auto-bumps to 5174 etc. if taken)
```
It talks to the REST API on `http://localhost:8787` (the same server `./manage-ac.sh deploy` starts). Other scripts:
`npm run build` (type-check + production build), `npm run preview` (serve the build), `npm run gen:api` (regenerate the
typed client `src/api/schema.ts` from the live server's `/q/openapi`), `npm run e2e` (Playwright tests).
### How to use it
**1. Pick a project.** The landing page lists every project (name, language, root). Click one — or use the project
dropdown in the top bar to switch at any time.
**2. Find a module.** The left pane lists all modules. Filter with the search box (a **regex** on name or file — toggle
**names / source** to search identifiers *inside* the code instead), and narrow by kind with the **all kinds** dropdown.
Each row shows the module kind, sloc, and ingest status (`INGESTED · FULL`, call-graph-only, …). Click a module to open
it. `refresh project` re-scans the root; the per-module `refresh` / `ingest +callers/callees` buttons deepen just that
module on demand.
**3. Read the module.** The detail pane has one tab per view:
| Tab | What it shows |
|---------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Source** | The full source with a subroutine **outline** (click to jump), **find-in-file** (regex, `n/m` match counter), and **Ctrl/⌘-click any identifier to "identify"** it (see below). |
| **Dossier** | The I/O contract — every parameter-data-area field with direction (REQUEST/RESPONSE) and source; line numbers link back into Source. |
| **Data-flow** | Trace one field: **comes-from (backward)**, **flows-to (forward)**, and **field-flow (producer → consumer)** across calls. Seed it from a field's **data-flow →** link in the identify popover. |
| **Impact** | The blast radius — transitive **callers** grouped by hop distance (direct / 2 hops / 3 hops …). |
| **Overview** | One-screen summary: description, kind, loc/sloc, functions, all callees (internal + external), callers, DB tables, plus a per-browser **notes** field for migration annotations. |
| **Calls** | **Called-by** (callers) and **calls** (callees) side by side, each tagged with the edge kind (`CALLNAT`, `PERFORM`, `CALLNAT_DYNAMIC`, `EXTENDS`, …). |
| **Call-tree** | The transitive callee tree; nodes **expand lazily** on click. |
| **Graph** | An interactive **ego graph** around the module, with `dir` (in/out/both), `depth`, and `limit` controls. Dynamic/unresolved edges are colour-coded (orange); a legend explains the node/edge types. |
**4. Identify an identifier (Ctrl/⌘-click in Source).** A popover shows every match for that name across the project (
e.g. *"25 matches"*), with the **current module's** declaration pinned to the top and the rest grouped as *"N in other
modules"*. Expand an entry for its type, scope, and its **reads/writes** occurrence list, plus **open definition →** and
**data-flow →** links that jump to the definition or seed the Data-flow tab.
**5. Save a view.** `save view` / `saved (N)` in the top bar bookmarks the current module+tab so you can jump back while
working through a codebase.
---
## CLI
`ac` (installed by `./manage-ac.sh deploy` to `~/.local/bin/ac`) is a client for ingesting and querying the graph —
handy for scripting and bulk work without going through HTTP directly.
```bash
ac --help
ac version
```
Every command prints the API's JSON response, pretty-printed, and exits non-zero on error. The general form is:
```
ac <command> [args] [-p <project>] [-s <server>] [--limit N] [--offset N]
```
**Where the server and project come from** (each row overrides the ones below it):
| Setting | Command flag | Environment | Config file (`~/.agenticcode/config.properties`) | Shell command |
|------------|--------------------|-----------------|--------------------------------------------------|-----------------|
| Server URL | `-s` / `--server` | `AC_SERVER_URL` | `server.url` | `connect <url>` |
| Project | `-p` / `--project` | `AC_PROJECT` | `project` | `use <project>` |
Default server is `http://localhost:8787`. `connect` and `use` write their values to the config file, so once set they
persist across invocations and you can drop `-s`/`-p`. Commands that need a project fail with a clear error if none is
selected.
> Not deployed via `manage-ac.sh`? Build the uber-jar with `mvn -pl ac-cli -am package` and run
> `java -jar ac-cli/target/ac-cli-*.jar ...` — same commands.
### Interactive shell
Running `ac` with no arguments starts a `psql`-like shell with line editing and history (`~/.agenticcode_history`):
```
AgenticCode interactive shell. Type 'help' for commands, 'exit' to quit.
agenticcode> connect http://my-server:8787
agenticcode> use upms
agenticcode> callers WGEAGB0S
agenticcode> exit
```
`connect <url>` and `use <project>` set (and persist) the server/project for the session.
### Common commands
```bash
# Projects
ac project create upms /path/to/sources -l natural -d "UPMS legacy"
ac project list
ac project update upms -d "New description"
ac project rename upms upms_alt # every node, override and counterpart reference follows
ac project delete upms
# Ingest / refresh
ac refresh -p upms # fast whole-root scan
ac refresh -p upms --deep # full field-level
ac refresh WGEAGB0S -p upms # one module + its dependency tree
# Query the call graph
ac callers WGEAGB0S -p upms
ac callees WGEAGB0S -p upms
ac call-tree WGEAGB0S -p upms
ac context WGEAGB0S -p upms
ac functions WGEAGB0S -p upms
# Data & DB
ac db-accesses WGEAGB0S -p upms
ac sql-statements WGEAGB0S -p upms
ac payload WGEAGB0S -p upms
ac data-structure-fields SOME-PDA -p upms
# Search & dataflow
ac search-identifier I-LINE-LEV -p upms
ac search-value 'YABAL' -p upms
ac variable-reads '#I-LINE-LEV' -p upms
ac flow-forward '#SOME-FIELD' -p upms
# Dynamic dispatch
ac dynamic-calls unresolved -p upms
ac dynamic-calls set --file X.nat --line 403 --target YABALGN0 -p upms
ac dynamic-calls reset --file X.nat --line 403 -p upms
```
### Command reference
`ac --help` lists everything; `ac <command> --help` shows a command's options. The main commands:
| Group | Commands |
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Project & ingest** | `project create\|update\|list\|delete`, `refresh [MODULE] [--deep] [--max-depth N] [--max-nodes N] [--neighborhood]`, `recreate` |
| **Call graph** | `callers`, `callees`, `call-tree`, `context`, `digest`, `functions`, `function-callers`, `function-overrides`, `ego-graph`, `modules`, `loc` |
| **Data & DB** | `db-accesses`, `sql-statements`, `workfile-accesses`, `payload`, `dispatch-table`, `module-data-structures`, `data-structure-fields`, `db-table-columns`, `entity-columns`, `store`, `store-accesses`, `bindings`, `theme`, `theme-usages`, `styles` |
| **Search & dataflow** | `search-identifier`, `search-value`, `search-source`, `search-annotation`, `variable-reads`, `variable-writes`, `flow-forward`, `flow-backward`, `field-flow` |
| **Source & nodes** | `module-source`, `file-source`, `node-source`, `inspect-node` |
| **Dynamic dispatch** | `dynamic-calls unresolved\|overrides\|set\|reset` |
| **Session / misc** | `connect`, `use`, `version` |
**Useful options.** Most list commands accept `--limit` / `--offset` for pagination. `callers` and `callees` take
`--scope external` (module-to-module `CALLNAT`/inheritance — the default for `callers`) or `--scope internal` (
own-subroutine `PERFORM` wiring). `search-identifier` takes `--type`, `--module`, `--source-file`, and
`--priority-module` (pin one module's matches to the front so its local declaration survives the limit). For Natural
names, a leading sigil (`#`, `&`, `+`) is optional — `ac search-identifier I-LINE-LEV` and `'#I-LINE-LEV'` are
equivalent.
Everything the CLI does is also available as a REST endpoint — the two are kept in lockstep.
---
## Tech Stack
| Component | Technology |
|---|---|
| Server | [Quarkus](https://quarkus.io) (latest stable) |
| Language | Java 21 — Records, Sealed Classes, Virtual Threads |
| Graph DB | Neo4j 5.x via `neo4j-java-driver` |
| REST | RESTEasy Reactive (JAX-RS) |
| Natural Parser | Custom implementation |
| Java Parser | [JavaParser](https://javaparser.org) |
| Tests | JUnit 5 + RestAssured + Testcontainers |
| Build | Maven (multi-module) |
| Component | Technology |
|----------------|---------------------------------------------------------|
| Server | [Quarkus](https://quarkus.io) (latest stable) |
| Language | Java 21 — Records, Sealed Classes, Virtual Threads |
| Graph DB | Neo4j 5.x via `neo4j-java-driver` |
| REST | RESTEasy Reactive (JAX-RS) |
| Natural Parser | Custom implementation |
| Java Parser | [JavaParser](https://javaparser.org) |
| CLI | Picocli uber-jar |
| Web UI | React + Vite + Tailwind, typed via `openapi-typescript` |
| Tests | JUnit 5 + RestAssured + Testcontainers |
| Build | Maven (multi-module) |
---
@@ -132,176 +473,48 @@ erDiagram
}
```
Full schema with Cypher examples: [`x-docs/ast-graph-schema.md`](x-docs/ast-graph-schema.md).
---
## Project Structure
```
agenticcode/
├── ac-code-server/ # Quarkus application (REST + MCP)
├── ac-cli/ # Command-line client (ingest + query)
├── ac-parser-core/ # Shared AST model + LanguageParser SPI
├── ac-parser-natural/ # Custom Natural/Software AG parser
├── ac-parser-java/ # Java parser (JavaParser-based)
├── ac-neo4j-store/ # Neo4j persistence + Cypher queries
└── docs/
├── ast-schema.md
├── natural-grammar.md
├── api-endpoints.md
└── enrichment-pipelines.md
├── ac-code-server/ # Quarkus application (REST API)
├── ac-cli/ # Picocli command-line client (`ac`)
├── ac-ui/ # React + Vite web UI
├── ac-parser-core/ # Shared AST model + LanguageParser SPI
├── ac-parser-natural/ # Custom Natural/Software AG parser
├── ac-parser-java/ # Java parser (JavaParser-based)
├── ac-neo4j-store/ # Neo4j persistence + Cypher queries
├── docker-compose.yml # Neo4j + ac-code-server
├── manage-ac.sh # build / run / install-CLI wrapper
└── x-docs/ # architecture, schema, API-usage, roadmap, features
```
---
## Getting Started
**Prerequisites:** Java 21+, Maven 3.9+, Docker (with the Compose plugin)
The repo ships a `docker-compose.yml` (Neo4j 5 + the `ac-code-server` container) and a
`deploy.sh` wrapper around it.
**Full stack (recommended):**
## Testing
```bash
# Clone
git clone https://github.com/your-org/agenticcode.git
cd agenticcode
# Build, (re)build + start neo4j + ac-code-server, and install the `ac` CLI
./deploy.sh up
mvn test # all tests (integration tests use Testcontainers Neo4j)
mvn test -Dtest="*Test" # unit tests only
mvn test -Dtest="*IT" # integration tests only (need Docker)
```
`./deploy.sh up` builds the project, brings the Compose stack up (leaving an already-running
Neo4j untouched), and installs the `ac` CLI launcher. Other subcommands: `./deploy.sh stop`
(stop only the server), `./deploy.sh logs [-f]`, `./deploy.sh cli` (rebuild the CLI only).
**Dev mode (hot reload):** run only Neo4j from Compose and the server from Maven:
```bash
mvn clean install
# Start just the Neo4j service from docker-compose.yml
docker compose up -d neo4j
# Run the server in dev mode (hot reload)
cd ac-code-server && mvn quarkus:dev
```
The API is available at `http://localhost:8787/api` and the Neo4j browser at `http://localhost:7474`
(credentials `neo4j` / `agenticcode`).
---
## CLI
`ac-cli` is a standalone command-line client for ingesting source code and querying the graph, useful for bulk-loading a codebase or scripting agent workflows without going through HTTP directly.
```bash
# Build the CLI (produces an executable uber-jar)
mvn -pl ac-cli -am package
# Run it
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar --help
```
By default the CLI talks to `http://localhost:8787`. 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
Running the jar with no arguments starts a `psql`-like interactive shell with line editing and history (`~/.agenticcode_history`):
```bash
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:8787
Connected to http://my-server:8787
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
OK ./src/main/natural/FOO.nat
...
agenticcode> exit
```
`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/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 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 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 -p my-app -s http://my-server:8787
```
Output shows `OK`/`FAIL`/`SKIP`/`ERROR` per file plus a summary line; the process exits non-zero if any file failed.
### Query the graph
```bash
# Who calls this 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 -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 -p my-app
# Find an identifier across all modules
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier myVariable -p my-app
# List all identifiers in the project
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier -p my-app
```
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.
Integration tests spin up their own Neo4j via Testcontainers — independent of the Compose stack.
---
## Supported Languages
| Language | Status | Parser |
|---|---|---|
| Natural (Software AG) | 🚧 In development | Custom |
| Java | 🚧 In development | JavaParser |
| Python | 📋 Planned | — |
| COBOL | 📋 Planned | — |
| Language | Status | Parser |
|-----------------------|-------------|------------|
| Natural (Software AG) | ✅ Supported | Custom |
| Java | ✅ Supported | JavaParser |
| Python | 📋 Planned | — |
| COBOL | 📋 Planned | — |
```
---

View File

@@ -1,5 +1,7 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Option;
import java.net.URLEncoder;
@@ -30,7 +32,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
return new ApiClient(baseUrl);
}
protected static String encode(String value) {
static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
@@ -45,6 +47,32 @@ abstract class AbstractApiCommand implements Callable<Integer> {
return path + (path.contains("?") ? "&" : "?") + key + "=" + value;
}
/**
* Appends an URL-encoded {@code key=value} to {@code path}, choosing {@code ?} or {@code &} based
* on whether the path already has a query string. Skips when {@code value} is {@code null}.
*/
protected static String appendQuery(String path, String key, @org.jspecify.annotations.Nullable String value) {
if (value == null) {
return path;
}
return path + (path.contains("?") ? "&" : "?") + key + "=" + encode(value);
}
/**
* Item 131: says so when the server cut the answer. Written to <b>stderr</b> so piping the body
* into {@code jq} stays clean, and printed at all because the alternative — a capped list that
* looks like the whole set — is what made a real audit report 17 missing annotations that were
* never missing.
*/
private static void warnIfTruncated(ApiClient.ApiResponse response) {
if (!"true".equalsIgnoreCase(String.valueOf(response.header("X-AC-Truncated")))) {
return;
}
@Nullable String total = response.header("X-AC-Total-Count");
System.err.println("note: this answer is truncated" + (total == null ? "" : " (" + total + " rows match)")
+ " — re-run with a larger --limit, page with --offset, or use --count-only");
}
/**
* Prints the response body and returns an exit code derived from the HTTP status.
*/
@@ -53,6 +81,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
if (!pretty.isBlank()) {
System.out.println(pretty);
}
warnIfTruncated(response);
if (!response.isSuccess()) {
System.err.println("HTTP " + response.statusCode());
return 1;

View File

@@ -23,9 +23,14 @@ import java.util.concurrent.Callable;
ProjectCommand.class,
RefreshCommand.class,
CallersCommand.class,
FunctionCallersCommand.class,
CalleesCommand.class,
CallTreeCommand.class,
ReachesCommand.class,
DuplicatesCommand.class,
EgoGraphCommand.class,
DbAccessesCommand.class,
WorkfileAccessesCommand.class,
SqlStatementsCommand.class,
ContextCommand.class,
FunctionsCommand.class,
@@ -38,19 +43,32 @@ import java.util.concurrent.Callable;
DbTableColumnsCommand.class,
EntityColumnsCommand.class,
SearchIdentifierCommand.class,
SearchReferencesCommand.class,
RestEndpointsCommand.class,
CounterpartsCommand.class,
StoreCommand.class,
StoreAccessesCommand.class,
BindingsCommand.class,
ThemeCommand.class,
ThemeUsagesCommand.class,
StylesCommand.class,
ModulesCommand.class,
LocCommand.class,
ModuleDataStructuresCommand.class,
PayloadCommand.class,
CommentsCommand.class,
DispatchTableCommand.class,
DynamicCallsCommand.class,
DigestCommand.class,
FunctionOverridesCommand.class,
SearchValueCommand.class,
SearchAnnotationCommand.class,
SearchSourceCommand.class,
InspectNodeCommand.class,
NodeSourceCommand.class,
ModuleSourceCommand.class,
VersionCommand.class,
ClearCommand.class
FileSourceCommand.class,
VersionCommand.class
}
)
public final class AgenticCodeCli implements Callable<Integer> {

View File

@@ -78,13 +78,26 @@ public final class ApiClient {
private ApiResponse send(HttpRequest request) throws IOException, InterruptedException {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return new ApiResponse(response.statusCode(), response.body());
return new ApiResponse(response.statusCode(), response.body(), response.headers());
}
/**
* Result of an API call.
*/
public record ApiResponse(int statusCode, String body) {
/**
* Item 131: the response now carries its headers, not just the body. Without them a truncated
* answer looked complete on the command line — the same silent cut the item is about, moved one
* layer out.
*/
public record ApiResponse(int statusCode, String body, java.net.http.HttpHeaders headers) {
/**
* @return a header's value, or {@code null} when the server did not send it
*/
public @org.jspecify.annotations.Nullable String header(String name) {
return headers.firstValue(name).orElse(null);
}
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;

View File

@@ -0,0 +1,58 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 195: which component reads/writes which DTO field, with the field's backend counterpart.
*/
@Command(name = "bindings", mixinStandardHelpOptions = true,
description = "List DTO field bindings (generated Fields path objects on SmartInput/SmartOutput/tables) with their backend counterpart")
final class BindingsCommand extends AbstractProjectCommand {
@Option(names = "--dto", description = "Only fields of this DTO (the declaring interface, e.g. Broker)")
@Nullable String dto;
@Option(names = "--field", description = "Only this field name")
@Nullable String field;
@Option(names = "--mode", description = "reads | writes (default: both)")
@Nullable String mode;
@Option(names = "--module", description = "Only bindings in this module (identity or short name)")
@Nullable String module;
@Option(names = "--partial", description = "true: only partial paths (rooted at a prop/local); false: only full paths")
@Nullable Boolean partial;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/bindings", "dto", dto);
path = appendQuery(path, "field", field);
path = appendQuery(path, "mode", mode);
path = appendQuery(path, "module", module);
if (partial != null) {
path = appendQuery(path, "partial", partial.toString());
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -14,7 +15,11 @@ final class CallTreeCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Option(names = "--depth", description = "Maximum number of CALLS hops to traverse")
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--depth", description = "Maximum number of module hops to traverse (a call made from "
+ "inside a subroutine is still one hop)")
int depth = -1;
@Override
@@ -24,7 +29,7 @@ final class CallTreeCommand extends AbstractProjectCommand {
if (depth >= 0) {
path += "?depth=" + depth;
}
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -14,6 +15,9 @@ final class CalleesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--scope", description = "Filter by call kind: 'external' (CALLNAT only) or 'internal' (PERFORM only)")
String scope = "";
@@ -31,7 +35,7 @@ final class CalleesCommand extends AbstractProjectCommand {
path += "?scope=" + encode(scope);
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,20 +1,24 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Lists modules that call a given module.
*/
@Command(name = "callers", mixinStandardHelpOptions = true, description = "List callers of a module")
@Command(name = "callers", mixinStandardHelpOptions = true, description = "List callers of a module (default: external module callers only)")
final class CallersCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Option(names = "--scope", description = "Filter by call kind: 'external' (CALLNAT only) or 'internal' (PERFORM only)")
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--scope", description = "'external' (default) = module callers (CALLNAT/inheritance); 'internal' = own-subroutine PERFORM wiring")
String scope = "";
@Option(names = "--limit", description = "Max items to return")
@@ -31,7 +35,7 @@ final class CallersCommand extends AbstractProjectCommand {
path += "?scope=" + encode(scope);
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,28 +0,0 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Deletes all projects and data, leaving an empty Neo4j database.
*/
@Command(name = "clear", mixinStandardHelpOptions = true, description = "Delete ALL projects and data (empties the database)")
final class ClearCommand extends AbstractApiCommand {
@Option(names = {"-y", "--yes"}, description = "Skip the confirmation prompt")
boolean yes;
@Override
public Integer call() throws Exception {
if (!yes) {
System.out.print("Delete ALL projects and data? This cannot be undone. (y/N) ");
System.out.flush();
String answer = new java.io.BufferedReader(new java.io.InputStreamReader(System.in)).readLine();
if (answer == null || !answer.trim().equalsIgnoreCase("y")) {
System.out.println("Aborted.");
return 0;
}
}
return printResponse(apiClient().delete("/api/projects"));
}
}

View File

@@ -63,7 +63,7 @@ final class CliConfig {
}
/**
* The ac-cli version stamped into the jar at build time by {@code deploy.sh}
* The ac-cli version stamped into the jar at build time by {@code manage-ac.sh}
* ({@code stamp_cli_version}), or {@code "dev"} for a jar built outside that script.
*/
static String bundledVersion() {

View File

@@ -0,0 +1,44 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 141: shows a module's comment blocks and the declaration each one documents.
*/
@Command(name = "comments", mixinStandardHelpOptions = true, description = "Show a module's comment blocks")
final class CommentsCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Option(names = "--kind", description = "One kind only: LINE|BLOCK|JAVADOC (Java), NATURAL_BANNER|NATURAL_INLINE|SAG (Natural). Without it, every kind but the machine-written SAG directives.")
String kind = "";
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
String path = selector.append(projectPath() + "/modules/" + encode(moduleName) + "/comments");
if (!kind.isBlank()) {
path = appendQuery(path, "kind", kind);
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -15,6 +16,9 @@ final class ContextCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--include", description = "Comma-separated sections to include (functions,callers,callees,dbAccesses,sqlStatements,variableAccesses); default: all")
String include = "";
@@ -43,7 +47,7 @@ final class ContextCommand extends AbstractProjectCommand {
if (!query.isEmpty()) {
path += "?" + query;
}
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,52 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 193: lists a project's counterparts in another project — its outbound web-service calls with
* the backend handler serving them, its generated DTOs with the Java class they mirror, and their
* fields — or, with {@code --unmatched}, exactly those that have no twin yet.
*/
@Command(name = "counterparts", mixinStandardHelpOptions = true,
description = "List the project's COUNTERPART_OF twins in its counterpart project(s): web-service calls -> handlers, generated DTOs -> classes, fields -> fields")
final class CounterpartsCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Only the rows declared in this module (identity or short name)")
@Nullable String module;
@Option(names = "--kind", description = "rest | dto | field (default: all)")
@Nullable String kind;
@Option(names = "--unmatched", description = "Only rows without a counterpart (what is not served / mirrored yet)")
boolean unmatched;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/counterparts", "module", module);
path = appendQuery(path, "kind", kind);
if (unmatched) {
path = appendQuery(path, "unmatched", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -1,6 +1,8 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
@@ -13,10 +15,17 @@ final class DataStructureFieldsCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Data structure name")
String structureName;
@Option(names = "--source-file",
description = "Restrict to the definition in this source file (only needed when the name is not unique)")
@Nullable
String sourceFile;
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/data-structures/" + encode(structureName) + "/fields"));
String path = projectPath() + "/data-structures/" + encode(structureName) + "/fields"
+ (sourceFile == null ? "" : "?sourceFile=" + encode(sourceFile));
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -14,6 +15,9 @@ final class DbAccessesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--depth", description = "Maximum CALLS depth for transitive access resolution (default: direct only)")
int depth = -1;
@@ -31,7 +35,7 @@ final class DbAccessesCommand extends AbstractProjectCommand {
path += "?depth=" + depth;
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -14,10 +15,13 @@ final class DigestCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/digest"));
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/digest")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -14,10 +15,13 @@ final class DispatchTableCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/dispatch-table"));
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/dispatch-table")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,23 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
/**
* Item 114: lists the identities the ingest skipped because they exist in more than one file. Without
* this the set was only ever visible in the ingest response — gone by the time anyone wondered why a
* module answers "not found".
*/
@Command(name = "duplicates", mixinStandardHelpOptions = true,
description = "List identities skipped at ingest because they exist in more than one file")
final class DuplicatesCommand extends AbstractProjectCommand {
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/duplicates"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,133 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Spec;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
/**
* Item 82: groups the manual dynamic-{@code CALLNAT} override subcommands: list unresolved call
* sites, list/set/reset overrides.
*/
@Command(
name = "dynamic-calls",
mixinStandardHelpOptions = true,
description = "Inspect and pin unresolvable dynamic CALLNAT targets",
subcommands = {
DynamicCallsCommand.UnresolvedCommand.class,
DynamicCallsCommand.OverridesCommand.class,
DynamicCallsCommand.SetCommand.class,
DynamicCallsCommand.ResetCommand.class
}
)
final class DynamicCallsCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Spec
CommandSpec spec;
@Override
public Integer call() {
spec.commandLine().usage(System.out);
return 0;
}
@Command(name = "unresolved", mixinStandardHelpOptions = true,
description = "List unresolved dynamic CALLNAT call sites (originFile+lineNo to pin)")
static final class UnresolvedCommand extends AbstractProjectCommand {
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/dynamic-calls/unresolved"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}
@Command(name = "overrides", mixinStandardHelpOptions = true,
description = "List manual dynamic-CALLNAT overrides (with obsolete flag)")
static final class OverridesCommand extends AbstractProjectCommand {
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/dynamic-calls/overrides"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}
@Command(name = "set", mixinStandardHelpOptions = true,
description = "Pin a dynamic CALLNAT call site (--file + --line) to one or more --target modules")
static final class SetCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Option(names = "--file", required = true, description = "Call site file (originFile)")
String file;
@Option(names = "--line", required = true, description = "Call site line number")
int line = -1;
@SuppressWarnings("NullAway.Init")
@Option(names = "--target", required = true, description = "Target module name; repeatable for branches")
List<String> targets;
@Option(names = "--variable", description = "Dispatch variable name (for reference)")
String variable = "";
@Option(names = "--note", description = "Free-text note")
String note = "";
@Override
public Integer call() throws Exception {
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("originFile", file);
body.put("lineNo", line);
body.put("targets", targets);
if (!variable.isBlank()) {
body.put("variable", variable);
}
if (!note.isBlank()) {
body.put("note", note);
}
return printResponse(apiClient().postJson(projectPath() + "/dynamic-calls/overrides", body));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}
@Command(name = "reset", mixinStandardHelpOptions = true,
description = "Reset overrides: one call site (--file + --line) or all (omit both)")
static final class ResetCommand extends AbstractProjectCommand {
@Option(names = "--file", description = "Call site file; omit (with --line) to reset all")
String file = "";
@Option(names = "--line", description = "Call site line; omit (with --file) to reset all")
int line = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/dynamic-calls/overrides";
path = appendQuery(path, "originFile", file.isEmpty() ? null : file);
path = appendQuery(path, "lineNo", line);
return printResponse(apiClient().delete(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}
}

View File

@@ -0,0 +1,46 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 49: prints the bounded call-graph neighbourhood (nodes + edges) around a module.
*/
@Command(name = "ego-graph", mixinStandardHelpOptions = true,
description = "Bounded call-graph neighbourhood (nodes + edges) around a module")
final class EgoGraphCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--depth", description = "Max call hops from the module (clamped to the configured maximum)")
int depth = -1;
@Option(names = "--direction", description = "Traversal direction: out (callees), in (callers) or both")
String direction = "";
@Option(names = "--limit", description = "Cap on the number of nodes returned (BFS order)")
int limit = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/modules/" + encode(moduleName) + "/graph";
if (!direction.isEmpty()) {
path += "?direction=" + encode(direction);
}
path = appendQuery(path, "depth", depth);
path = appendQuery(path, "limit", limit);
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -14,10 +15,13 @@ final class EntityColumnsCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Entity module (class) name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/columns"));
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/columns")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,37 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Shows the source text for a project file by its relative path (not a module name) — the whole file
* by default, or a line range with {@code --start-line}/{@code --end-line}. Use for files that aren't
* standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module. Read from disk.
*/
@Command(name = "file-source", mixinStandardHelpOptions = true, description = "Show a file's source by relative path (whole file, or a line range)")
final class FileSourceCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Relative source file path (e.g. as reported by data-structure fields)")
String file;
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
int startLine = -1;
@Option(names = "--end-line", description = "Last line to include (1-based); omit with --start-line for the whole file")
int endLine = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/source?file=" + encode(file);
path = appendQuery(path, "startLine", startLine);
path = appendQuery(path, "endLine", endLine);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,38 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
* Lists the FUNCTION-level callers of a subroutine/method — who PERFORMs (Natural) or calls (Java
* cross-class) the given function in a module, with call-site line numbers. Finer-grained than the
* module-level {@code callers} command (item 52).
*/
@Command(name = "function-callers", mixinStandardHelpOptions = true,
description = "List the subroutines/methods that PERFORM/call a given function in a module")
final class FunctionCallersCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name that defines the subroutine/method")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@SuppressWarnings("NullAway.Init")
@Parameters(index = "1", description = "Subroutine/method name")
String functionName;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/modules/" + encode(moduleName)
+ "/functions/" + encode(functionName) + "/callers";
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -2,6 +2,7 @@ package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -17,6 +18,9 @@ final class FunctionOverridesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module (base class) name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Parameters(index = "1", arity = "0..1", description = "Function name (omit for all abstract methods)")
@Nullable String functionName;
@@ -25,9 +29,10 @@ final class FunctionOverridesCommand extends AbstractProjectCommand {
try {
String path = projectPath() + "/modules/" + encode(moduleName) + "/functions/overrides";
if (functionName != null && !functionName.isBlank()) {
path = projectPath() + "/modules/" + encode(moduleName) + "/functions/" + encode(functionName) + "/overrides";
path = projectPath() + "/modules/" + encode(moduleName) + "/functions/"
+ encode(functionName) + "/overrides";
}
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -15,6 +16,9 @@ final class FunctionsCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module (class) name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = {"--include-inherited"}, description = "Include functions inherited from ancestors")
boolean includeInherited;
@@ -25,7 +29,7 @@ final class FunctionsCommand extends AbstractProjectCommand {
if (includeInherited) {
path += "?includeInherited=true";
}
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -89,7 +89,7 @@ final class InteractiveShell {
terminal.writer().println("ac-cli version " + result.cliVersion());
if (result.mismatched()) {
terminal.writer().println("ERROR: version mismatch — ac-cli is v" + result.cliVersion()
+ " but the server is v" + result.serverVersion() + ". Run './deploy.sh cli' to reinstall a matching ac-cli.");
+ " but the server is v" + result.serverVersion() + ". Run './manage-ac.sh cli' to reinstall a matching ac-cli.");
}
}
}

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -14,10 +15,13 @@ final class ModuleDataStructuresCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/modules/" + encode(moduleName) + "/data-structures"));
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/data-structures")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,36 @@
package com.agenticcode.cli;
import picocli.CommandLine.Option;
/**
* Item 115: the {@code --source-file} selector shared by every command that addresses a module by
* name.
*
* <p>A Java simple name can identify several modules — nested {@code @Nested} test classes,
* {@code Builder}, {@code WorkingStorage}. Those endpoints answer {@code 409 AMBIGUOUS_NAME} and list
* the candidates; this option repeats the request against one of them. Natural module names are unique
* by construction, so the option never has to be given there.
*
* <p>A mixin rather than a base-class field so it does not appear on the project-level commands, where
* it would be silently ignored.
*/
final class ModuleSelector {
@Option(names = {"--source-file"},
description = "Pick one module when the name is ambiguous (as listed in a 409 AMBIGUOUS_NAME "
+ "response); a source path relative to the project root")
String sourceFile = "";
/**
* @return {@code path} with the selector appended, using {@code &} when the path already carries a
* query string and {@code ?} otherwise. Choosing the separator here is the point: several commands
* append their own parameters after the base path, and a fixed {@code "?"} would emit two of them.
*/
String append(String path) {
if (sourceFile == null || sourceFile.isBlank()) {
return path;
}
return path + (path.indexOf('?') >= 0 ? '&' : '?')
+ "sourceFile=" + AbstractApiCommand.encode(sourceFile.strip());
}
}

View File

@@ -1,32 +1,39 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Shows the source-text snippet for a module by name and line range, read from the project root
* on disk.
* Shows the source text for a module by name — the whole file by default, or a line range when
* {@code --start-line}/{@code --end-line} are given. Read from the project root on disk.
*/
@Command(name = "module-source", mixinStandardHelpOptions = true, description = "Show a source snippet for a module by line range")
@Command(name = "module-source", mixinStandardHelpOptions = true, description = "Show a module's source (whole file, or a line range)")
final class ModuleSourceCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Option(names = "--start-line", required = true, description = "First line to include (1-based)")
int startLine;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--end-line", required = true, description = "Last line to include (1-based)")
int endLine;
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
int startLine = -1;
@Option(names = "--end-line", description = "Last line to include (1-based); omit with --start-line for the whole file")
int endLine = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/modules/" + encode(moduleName) + "/source"
+ "?startLine=" + startLine + "&endLine=" + endLine;
return printResponse(apiClient().get(path));
String path = projectPath() + "/modules/" + encode(moduleName) + "/source";
// Whole file when neither bound is given; otherwise pass both (the server rejects a
// half-open range).
path = appendQuery(path, "startLine", startLine);
path = appendQuery(path, "endLine", endLine);
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,30 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
* Shows the XML wire payload contract of a module: the tag/field/direction triples extracted from
* the {@code ADD-XML-LINE} emit idiom.
*/
@Command(name = "payload", mixinStandardHelpOptions = true, description = "Show the XML payload contract for a module")
final class PayloadCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/payload")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -1,26 +1,30 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.*;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import picocli.CommandLine.Spec;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
/**
* Groups project management subcommands: create, update, delete, clear, list.
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
*/
@Command(
name = "project",
mixinStandardHelpOptions = true,
description = "Create, update, delete, clear or list projects",
description = "Create, update, delete, recreate, rename, show or list projects",
subcommands = {
ProjectCommand.CreateCommand.class,
ProjectCommand.UpdateCommand.class,
ProjectCommand.DeleteCommand.class,
ProjectCommand.ClearCommand.class,
ProjectCommand.RecreateCommand.class,
ProjectCommand.RenameCommand.class,
ProjectCommand.ShowCommand.class,
ProjectCommand.ListCommand.class
}
)
@@ -54,15 +58,35 @@ final class ProjectCommand implements Callable<Integer> {
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
List<String> excludeDirs = List.of();
@SuppressWarnings("NullAway.Init")
@Option(names = {"-l", "--language"}, required = true,
description = "Project source language (natural/java/typescript)")
String language;
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
String userExitDir = "";
@Override
public Integer call() throws Exception {
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs)));
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
language, generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@Command(name = "update", mixinStandardHelpOptions = true,
description = "Update a project's description, root folder and/or exclude-dirs (unset fields are left unchanged)")
description = "Update a project's description, root, exclude-dirs, language, counterparts and/or generated/user-exit dirs (unset fields are left unchanged)")
static final class UpdateCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@@ -79,31 +103,133 @@ final class ProjectCommand implements Callable<Integer> {
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
List<String> excludeDirs = List.of();
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/typescript)")
String language = "";
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
String userExitDir = "";
@Override
public Integer call() throws Exception {
return printResponse(apiClient().putJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description,
root.isBlank() ? null : root,
excludeDirs.isEmpty() ? null : excludeDirs)));
excludeDirs.isEmpty() ? null : excludeDirs,
language.isBlank() ? null : language,
generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@Command(name = "delete", mixinStandardHelpOptions = true, description = "Delete a project and all its data")
/**
* Item 79: one verb for deleting, with the blast radius spelled out rather than implied by a missing
* argument. {@code --all} and a project name are mutually exclusive and one is required — picocli
* enforces that through the {@link ArgGroup}, so forgetting the name is a usage error, never an empty
* database. This replaces {@code ac clear} and {@code ac project clear}, which were two commands of the
* same name and wildly different reach ({@code ac project clear} was also a byte-for-byte duplicate of
* this one).
*/
@Command(name = "delete", mixinStandardHelpOptions = true,
description = "Delete one project, or every project with --all")
static final class DeleteCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String name;
@ArgGroup(multiplicity = "1")
Target target;
@Option(names = {"-y", "--yes"}, description = "Skip the confirmation prompt (--all only)")
boolean yes;
/**
* Inherited verbatim from the old {@code ac clear}: only {@code --all} is guarded this way.
*/
private static boolean confirmed() throws IOException {
System.out.print("Delete ALL projects and data? This cannot be undone. (y/N) ");
System.out.flush();
String answer = new BufferedReader(new InputStreamReader(System.in)).readLine();
return answer != null && answer.trim().equalsIgnoreCase("y");
}
@Override
public Integer call() throws Exception {
return printResponse(apiClient().delete("/api/projects/" + encode(name)));
if (target.all) {
if (!yes && !confirmed()) {
System.out.println("Aborted.");
return 0;
}
return printResponse(apiClient().delete("/api/projects"));
}
return printResponse(apiClient().delete("/api/projects/" + encode(Objects.requireNonNull(target.name))));
}
static final class Target {
@Parameters(index = "0", description = "Project name")
@Nullable
String name;
@Option(names = "--all", description = "Delete ALL projects and data (empties the database)")
boolean all;
}
}
@Command(name = "clear", mixinStandardHelpOptions = true,
description = "Delete a project and all its data (alias of 'delete')")
static final class ClearCommand extends AbstractApiCommand {
/**
* Item 202: rename a project in place (nodes, overrides and other projects' counterparts follow).
*/
@Command(name = "rename", mixinStandardHelpOptions = true,
description = "Rename a project; every node, override and counterpart reference follows")
static final class RenameCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Current project name")
String name;
@SuppressWarnings("NullAway.Init")
@Parameters(index = "1", description = "New project name")
String newName;
@Override
public Integer call() throws Exception {
return printResponse(apiClient().postJson("/api/projects/" + encode(name) + "/rename",
java.util.Map.of("newName", newName)));
}
}
/**
* Item 78: re-initialise a project from its own stored config — no need to look up and re-type
* {@code root}/{@code language}/{@code generatedDir}/{@code userExitDir}, and no window in which they
* could be lost. Refuses with {@code 400 ROOT_NOT_FOUND} — before deleting anything — if the stored
* root no longer resolves.
*/
@Command(name = "recreate", mixinStandardHelpOptions = true,
description = "Wipe a project's graph and ingest it again from its stored config")
static final class RecreateCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
String name;
@Option(names = "--deep", description = "Run the full field-level ingest (slower)")
boolean deep;
@Override
public Integer call() throws Exception {
String path = "/api/projects/" + encode(name) + "/recreate" + (deep ? "?deep=true" : "");
return printResponse(apiClient().post(path));
}
}
@Command(name = "show", mixinStandardHelpOptions = true,
description = "Show one project's config and its last whole-root ingest (ingestedAt, mode, file counts)")
static final class ShowCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Project name")
@@ -111,7 +237,7 @@ final class ProjectCommand implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return printResponse(apiClient().delete("/api/projects/" + encode(name)));
return printResponse(apiClient().get("/api/projects/" + encode(name)));
}
}

View File

@@ -8,8 +8,12 @@ import java.util.List;
* Request body for {@code POST}/{@code PUT /api/projects/{project}}.
*
* <p>{@code root} is the server-side path the project's sources live under (required on create);
* {@code excludeDirs} are path components skipped when scanning {@code root}. On update, {@code null}
* fields leave the stored values unchanged.
* {@code excludeDirs} are path components skipped when scanning {@code root}. {@code language} is
* required on create (item 47); {@code generatedDir}/{@code userExitDir} are directory names for the
* generated vs. user-exit LoC split (set together or not at all). On update, {@code null} fields
* leave the stored values unchanged.
*/
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
@Nullable List<String> counterparts) {
}

View File

@@ -0,0 +1,57 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 110: answers "can this module reach any of these targets, and by which route" — the question
* {@code call-tree} cannot, because it walks downward from one root and returns a flat closure
* without paths.
*/
@Command(name = "reaches", mixinStandardHelpOptions = true,
description = "Check whether a module reaches (or is reached by) given target modules, with a witness path")
final class ReachesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@SuppressWarnings("NullAway.Init")
@Option(names = "--target", required = true, split = ",",
description = "Target module name(s); repeat the option or separate with commas")
String[] targets;
@Option(names = "--direction",
description = "down (default): paths from the module to each target. up: paths from each target to the module")
String direction = "down";
@Option(names = "--depth", description = "Maximum number of module hops to traverse")
int depth = -1;
@Option(names = "--limit", description = "Maximum number of witness paths to return (default 50)")
int limit = -1;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/modules/" + encode(moduleName) + "/reaches"
+ "?target=" + encode(String.join(",", targets))
+ "&direction=" + encode(direction);
if (depth >= 0) {
path += "&depth=" + depth;
}
if (limit >= 0) {
path += "&limit=" + limit;
}
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -37,6 +37,25 @@ final class RefreshCommand extends AbstractProjectCommand {
@Option(names = "--max-nodes", description = "Module refresh only: max number of files to ingest; default server setting")
int maxNodes = -1;
@Option(names = "--neighborhood", description = "Module refresh only: also deep-ingest the module's transitive callers (not just callees/data areas)")
boolean neighborhood;
@Option(names = "--paths", split = ",",
description = "Whole-project refresh only: re-ingest just these relative source paths (repeatable or comma-separated) "
+ "instead of the whole root. Paths matching no file come back under 'unresolved'")
List<String> paths = List.of();
@Option(names = "--changed-only",
description = "Whole-project refresh only: re-parse only files whose content differs from the graph's stored hash. "
+ "Enrichment still runs in full; a changed Natural copycode re-parses everything (its text is inlined at parse time)")
boolean changedOnly;
@Option(names = "--profile",
description = "DIAGNOSTIC: run the enrichment steps under Cypher PROFILE and log the dominant operators of every step "
+ "slower than 5 s, to see where a step spends its time. Costs 10-30% on its own, so this run's absolute "
+ "timings are not comparable to a normal refresh")
boolean profile;
@Override
public Integer call() throws Exception {
if (name != null && !name.isBlank()) {
@@ -48,12 +67,24 @@ final class RefreshCommand extends AbstractProjectCommand {
if (maxNodes >= 0) {
params.add("maxNodes=" + maxNodes);
}
if (neighborhood) {
params.add("scope=neighborhood");
}
if (!params.isEmpty()) {
path += "?" + String.join("&", params);
}
return printResponse(apiClient().post(path));
}
String path = projectPath() + "/refresh" + (deep ? "?deep=true" : "");
if (!paths.isEmpty()) {
path = appendQuery(path, "paths", String.join(",", paths));
}
if (changedOnly) {
path = appendQuery(path, "changedOnly", "true");
}
if (profile) {
path = appendQuery(path, "profile", "true");
}
return printResponse(apiClient().post(path));
}
}

View File

@@ -0,0 +1,42 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 130: lists a project's REST endpoints — composed path, HTTP verb, declaring class and handler
* method. Answers "which code runs for this URL" directly, instead of composing the class-level and
* method-level {@code @Path} by hand from two annotation searches.
*/
@Command(name = "rest-endpoints", mixinStandardHelpOptions = true,
description = "List the project's REST endpoints (path, HTTP method, declaring class, handler)")
final class RestEndpointsCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Only the endpoints declared by this class (identity or short name)")
@Nullable String module;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/rest-endpoints", "module", module);
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -19,6 +19,9 @@ final class SearchAnnotationCommand extends AbstractProjectCommand {
@Option(names = "--type", description = "Filter by node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
@Nullable String type;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@@ -32,6 +35,9 @@ final class SearchAnnotationCommand extends AbstractProjectCommand {
if (type != null) {
path += "&type=" + encode(type);
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {

View File

@@ -12,9 +12,27 @@ import picocli.CommandLine.Parameters;
@Command(name = "search-identifier", mixinStandardHelpOptions = true, description = "Search for an identifier across all modules, or list all identifiers")
final class SearchIdentifierCommand extends AbstractProjectCommand {
@Parameters(index = "0", arity = "0..1", description = "Identifier name (omit to list all identifiers)")
@Parameters(index = "0", arity = "0..1", description = "Identifier name — a Java type's short or fully-qualified form; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
@Nullable String name;
@Option(names = "--contains", description = "Match names that contain the given name (case-insensitive) instead of equalling it; requires a name")
boolean contains;
@Option(names = "--type", description = "Node type filter (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
@Nullable String type;
@Option(names = "--module", description = "Scope to nodes of this module (by name)")
@Nullable String module;
@Option(names = "--priority-module", description = "Pin this module's matches to the front (no filtering) so its local declaration survives the limit when a name recurs across modules")
@Nullable String priorityModule;
@Option(names = "--source-file", description = "Scope to this exact source file (relative path)")
@Nullable String sourceFile;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@@ -25,8 +43,16 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
public Integer call() throws Exception {
try {
String path = projectPath() + "/search/identifier";
if (name != null) {
path += "?name=" + encode(name);
path = appendQuery(path, "name", name);
path = appendQuery(path, "type", type);
path = appendQuery(path, "module", module);
path = appendQuery(path, "priorityModule", priorityModule);
path = appendQuery(path, "sourceFile", sourceFile);
if (contains) {
path = appendQuery(path, "contains", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));

View File

@@ -0,0 +1,49 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 128: every place a type is mentioned — imports, declared type positions, annotation usages,
* calls, inheritance and wiring — not just its callers. This is what scopes a rename honestly:
* {@code callers} sees calls alone, so a file that only imports or declares the type was invisible.
*/
@Command(name = "references", mixinStandardHelpOptions = true,
description = "Find every reference site of a type (imports, type positions, annotations, calls, inheritance)")
final class SearchReferencesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Type identity (FQN) or short name")
String name;
@Option(names = "--kind",
description = "Narrow to one kind: CALL, IMPORT, TYPE, ANNOTATION, EXTENDS, IMPLEMENTS, INJECTS, CLASS_LITERAL, INCLUDE")
@Nullable String kind;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/search/references", "name", name);
path = appendQuery(path, "kind", kind);
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,38 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Regex search over module source text (item 54): greps the source of all modules for a pattern and
* prints {module, sourceFile, lineNo, line} hits. Case-insensitive by default.
*/
@Command(name = "search-source", mixinStandardHelpOptions = true, description = "Regex-search module source text (grep across the project)")
final class SearchSourceCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Java regular expression to search for")
String regex;
@Option(names = "--limit", description = "Max matches to return (default 200)")
int limit = -1;
@Option(names = "--case-sensitive", description = "Match case-sensitively (default: case-insensitive)")
boolean caseSensitive;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/search/source?regex=" + encode(regex);
path = appendQuery(path, "limit", limit);
if (caseSensitive) {
path += "&ignoreCase=false";
}
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -17,6 +17,12 @@ final class SearchValueCommand extends AbstractProjectCommand {
@Option(names = "--contains", description = "Match values that contain the given string, not just exact matches")
boolean contains;
@Option(names = "--include-comments", description = "Also search comment blocks (item 141); their hits come back as kind=COMMENT")
boolean includeComments;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@@ -30,6 +36,12 @@ final class SearchValueCommand extends AbstractProjectCommand {
if (contains) {
path += "&contains=true";
}
if (includeComments) {
path = appendQuery(path, "includeComments", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {

View File

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
@@ -14,6 +15,9 @@ final class SqlStatementsCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--depth", description = "Maximum CALLS depth for transitive statement resolution (default: direct only)")
int depth = -1;
@@ -24,7 +28,7 @@ final class SqlStatementsCommand extends AbstractProjectCommand {
if (depth >= 0) {
path += "?depth=" + depth;
}
return printResponse(apiClient().get(path));
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -0,0 +1,53 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 194: who reads and writes one store slice — reducers, selectors, wrapper hooks, getState() chains.
*/
@Command(name = "store-accesses", mixinStandardHelpOptions = true,
description = "List the access sites of a store slice: reducers writing it, components/hooks/thunks reading it")
final class StoreAccessesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "The slice (reducer key or RTK slice name)")
String slice = "";
@Option(names = "--field", description = "Only this top-level state key")
@Nullable String field;
@Option(names = "--mode", description = "reads | writes (default: both)")
@Nullable String mode;
@Option(names = "--module", description = "Only accesses from this module (identity or short name)")
@Nullable String module;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/store/" + encode(slice) + "/accesses";
path = appendQuery(path, "field", field);
path = appendQuery(path, "mode", mode);
path = appendQuery(path, "module", module);
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,26 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 194: the frontend Redux store — its slices, their state keys and access counts.
*/
@Command(name = "store", mixinStandardHelpOptions = true,
description = "List the project's Redux store slices (reducer key, state keys with types and read/write counts, reducers)")
final class StoreCommand extends AbstractProjectCommand {
@Option(names = "--slice", description = "Only this slice (reducer key or RTK slice name)")
@Nullable String slice;
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(appendQuery(projectPath() + "/store", "slice", slice)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,50 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 196: the style inventory — sx/style/styled blocks and CSS rules with keys, literals and theme tokens.
*/
@Command(name = "styles", mixinStandardHelpOptions = true,
description = "List style blocks (sx, style, styled, css rules) with their CSS keys, hard-coded literals and theme tokens")
final class StylesCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Only blocks in this module (identity or short name)")
@Nullable String module;
@Option(names = "--kind", description = "sx | style | styled | css (default: all)")
@Nullable String kind;
@Option(names = "--with-literals", description = "Only blocks with hard-coded colour/length literals (what bypasses the theme)")
boolean withLiterals;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/styles", "module", module);
path = appendQuery(path, "kind", kind);
if (withLiterals) {
path = appendQuery(path, "withLiterals", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,29 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 196: the MUI theme's tokens with their project-side use counts.
*/
@Command(name = "theme", mixinStandardHelpOptions = true,
description = "List the theme tokens (createTheme leaves + theme constants) with values and project use counts; undeclared tokens the code reads are listed with declared=false")
final class ThemeCommand extends AbstractProjectCommand {
@Option(names = "--unused", description = "Only tokens with no project reference (MUI's own use of a token is not visible)")
boolean unused;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/theme";
if (unused) {
path = appendQuery(path, "unused", "true");
}
return printResponse(apiClient().get(path));
} 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;
/**
* Item 196: where one theme token is read — style blocks (with the CSS key it feeds) and plain code/prop reads.
*/
@Command(name = "theme-usages", mixinStandardHelpOptions = true,
description = "List where a theme token (palette.primary.dark or a constant name) is read")
final class ThemeUsagesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "The token: a dotted createTheme path (palette.primary.dark) or a theme constant (PRIMARY)")
String token = "";
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/theme/" + encode(token) + "/usages"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -34,7 +34,7 @@ final class VersionCheck {
}
/**
* @param cliVersion the bundled ac-cli version ({@code "dev"} for a non-deploy.sh build)
* @param cliVersion the bundled ac-cli version ({@code "dev"} for a non-manage-ac.sh build)
* @param serverVersion the server's version, or {@code null} if the server couldn't be reached
* @param errorStatus the HTTP status if the server request failed, otherwise {@code null}
*/

View File

@@ -27,7 +27,7 @@ final class VersionCommand extends AbstractApiCommand {
if (result.mismatched()) {
System.err.println("Version mismatch: ac-cli is v" + result.cliVersion() + " but the server is v"
+ result.serverVersion() + " — run './deploy.sh cli' to reinstall a matching ac-cli.");
+ result.serverVersion() + " — run './manage-ac.sh cli' to reinstall a matching ac-cli.");
return 1;
}
return 0;

View File

@@ -0,0 +1,40 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Lists Natural work-file (sequential/flat file I/O) accesses of a given module — the work-file
* analogue of {@link DbAccessesCommand} (work files are not DB tables).
*/
@Command(name = "workfile-accesses", mixinStandardHelpOptions = true,
description = "List work-file (READ/WRITE WORK FILE) accesses of a module")
final class WorkfileAccessesCommand extends AbstractProjectCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--limit", description = "Max items to return")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/modules/" + encode(moduleName) + "/workfile-accesses";
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(selector.append(path)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -2,6 +2,6 @@
# Used as the lowest-priority fallback for the server URL, below session state
# (connect), $AC_SERVER_URL, and ~/.agenticcode/config.properties.
server.url=http://localhost:8787
# Stamped by deploy.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via deploy.sh.
version=11
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=334

View File

@@ -0,0 +1,77 @@
package com.agenticcode.cli;
import org.junit.jupiter.api.Test;
import picocli.CommandLine;
import static org.junit.jupiter.api.Assertions.*;
/**
* Item 79: argument wiring of {@code ac project delete}.
*
* <p>The whole point of the command's shape is that the destructive case must be <em>asked for</em>. Before
* this, {@code clear} was bound twice at two levels — {@code ac clear} emptied the entire database while
* {@code ac project clear <name>} deleted one project — so a forgotten word was the difference between the
* two. Here a forgotten project name is a usage error rather than a silently wider delete, and that is
* enforced by picocli's {@code ArgGroup(multiplicity = "1")} rather than by a hand-written check.
*
* <p>These tests parse only. They never execute: {@code AbstractApiCommand.apiClient()} constructs its
* client inside the call, so there is no seam to inject a fake, and executing would fire real HTTP at
* localhost:8787. What the command <em>does</em> with a valid parse is therefore covered by
* {@code ProjectRecreateIT} at the API level, not here.
*/
class ProjectDeleteArgsTest {
private static CommandLine cli() {
return new CommandLine(new AgenticCodeCli());
}
/**
* A bare `ac project delete` must not mean "delete everything".
*/
@Test
void deleteWithoutAnyTargetIsRejected() {
CommandLine.MissingParameterException e = assertThrows(CommandLine.MissingParameterException.class,
() -> cli().parseArgs("project", "delete"));
String message = String.valueOf(e.getMessage());
assertTrue(message.contains("--all") || message.toLowerCase().contains("project"),
"the error should name what is missing; was: " + message);
}
/**
* Naming a project and --all at once is contradictory, not a merge of the two.
*/
@Test
void deleteRejectsAProjectNameTogetherWithAll() {
assertThrows(CommandLine.MutuallyExclusiveArgsException.class,
() -> cli().parseArgs("project", "delete", "upms", "--all"));
}
@Test
void deleteAcceptsAProjectName() {
assertDoesNotThrow(() -> cli().parseArgs("project", "delete", "upms"));
}
@Test
void deleteAcceptsAllOnItsOwn() {
assertDoesNotThrow(() -> cli().parseArgs("project", "delete", "--all"));
}
/**
* The commands removed in item 79 must be gone, not merely undocumented.
*/
@Test
void theClearCommandsNoLongerExist() {
assertThrows(CommandLine.UnmatchedArgumentException.class,
() -> cli().parseArgs("clear"));
assertThrows(CommandLine.UnmatchedArgumentException.class,
() -> cli().parseArgs("project", "clear", "upms"));
}
@Test
void recreateTakesAProjectNameAndAnOptionalDeepFlag() {
assertNotNull(cli().parseArgs("project", "recreate", "upms"));
assertNotNull(cli().parseArgs("project", "recreate", "upms", "--deep"));
assertThrows(CommandLine.MissingParameterException.class,
() -> cli().parseArgs("project", "recreate"));
}
}

View File

@@ -29,6 +29,10 @@
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-java</artifactId>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-typescript</artifactId>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-neo4j-store</artifactId>
@@ -51,8 +55,8 @@
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-sse</artifactId>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
@@ -74,11 +78,6 @@
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -134,6 +133,14 @@
<plugin>
<groupId>com.agenticcode</groupId>
<artifactId>ac-mvn-plugins</artifactId>
<executions>
<execution>
<id>bump-version</id>
<goals>
<goal>bump-version</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@@ -1,16 +1,36 @@
# Quarkus JVM (fast-jar) runtime image.
# Expects `mvn package` to have already produced target/quarkus-app/ on the host
# (build context is the ac-code-server module directory).
# Expects `mvn package` to have already produced ac-code-server/target/quarkus-app/ on the host.
# Build context is the REPOSITORY ROOT (see docker-compose.yml), because the image also carries the
# TypeScript sidecar from ac-parser-typescript/sidecar (item 192); the root .dockerignore keeps the
# context down to exactly the paths COPYed below.
# --- stage 1: the TypeScript sidecar with its pinned `typescript` dependency -------------------
FROM node:24-slim AS sidecar
WORKDIR /sidecar
COPY ac-parser-typescript/sidecar/package.json ac-parser-typescript/sidecar/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY ac-parser-typescript/sidecar/extract.mjs ./
# --- stage 2: the server ------------------------------------------------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /work/
COPY target/quarkus-app/lib/ /work/lib/
COPY target/quarkus-app/*.jar /work/
COPY target/quarkus-app/app/ /work/app/
COPY target/quarkus-app/quarkus/ /work/quarkus/
# Item 192: node + the sidecar. The JVM starts `node /work/sidecar/extract.mjs` per npm workspace of
# a `typescript` project during a deep pass (application.properties, %prod.agenticcode.typescript.*).
COPY --from=sidecar /usr/local/bin/node /usr/local/bin/node
COPY --from=sidecar /sidecar /work/sidecar
COPY ac-code-server/target/quarkus-app/lib/ /work/lib/
COPY ac-code-server/target/quarkus-app/*.jar /work/
COPY ac-code-server/target/quarkus-app/app/ /work/app/
COPY ac-code-server/target/quarkus-app/quarkus/ /work/quarkus/
EXPOSE 8787
ENV JAVA_OPTS="-Djava.util.logging.manager=org.jboss.logmanager.LogManager"
# JVM options are NOT set here. A `ENV JAVA_OPTS=...` used to sit at this spot and had no effect
# whatsoever: the exec-form ENTRYPOINT below runs `java` directly, with no shell to expand the
# variable, and the JVM itself only honours JDK_JAVA_OPTIONS / JAVA_TOOL_OPTIONS. Options now live
# in docker-compose.yml under JDK_JAVA_OPTIONS (heap cap + log manager) — keep them in one place,
# because a value set in compose replaces an image-level one rather than appending to it.
ENTRYPOINT ["java", "-jar", "/work/quarkus-run.jar"]

View File

@@ -0,0 +1,53 @@
package com.agenticcode.codeserver.api;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
import java.util.Map;
/**
* Item 136: makes an <em>unexpected</em> failure look like every other error this API returns —
* {@code { "error": ..., "code": "INTERNAL_ERROR", "details": {} }} — instead of Quarkus's default
* plain-text error page.
*
* <p>Why it matters: the API's whole contract is "errors are structured JSON with a {@code code} to
* branch on". A {@code Uncoercible: Cannot coerce NULL to Java int} escaping the identifier search
* broke that promise at exactly the moment a client most needs a machine-readable answer — it got an
* HTML-ish body with no {@code code} at all, and no way to tell a server fault from a bad request.
*
* <p><b>Pass-through is load-bearing.</b> JAX-RS picks the most specific mapper for an exception, and
* {@code Throwable} is the least specific one there is: without the {@link WebApplicationException}
* branch below, this mapper would also swallow every {@code 404}/{@code 405}/{@code 415} the runtime
* raises and the deliberate statuses built by {@link ProjectResource#error}, turning correct answers
* into {@code 500}s across the board.
*
* <p>The error id in the message is the correlation handle: the full stack trace goes to the server
* log under the same id, and never into the response — a client has no use for it and a stack trace
* is not something to hand out.
*/
@Provider
public class ApiExceptionMapper implements ExceptionMapper<Throwable> {
private static final Logger LOG = Logger.getLogger(ApiExceptionMapper.class);
@Override
public Response toResponse(Throwable exception) {
// A deliberate status (404 PROJECT_NOT_FOUND, 400 MISSING_NAME, 409 STALE_SOURCE, and the
// runtime's own routing failures) already carries its response. Hand it back untouched.
if (exception instanceof WebApplicationException webApplicationException) {
return webApplicationException.getResponse();
}
String errorId = java.util.UUID.randomUUID().toString();
LOG.errorf(exception, "Unhandled failure, error id %s", errorId);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.type(MediaType.APPLICATION_JSON)
.entity(ErrorResponse.of("INTERNAL_ERROR",
"Unexpected server error (error id " + errorId + "); see the server log for details",
Map.of("errorId", errorId)))
.build();
}
}

View File

@@ -10,4 +10,13 @@ public record ErrorResponse(String error, String code, Map<String, Object> detai
public static ErrorResponse of(String code, String message) {
return new ErrorResponse(message, code, Map.of());
}
/**
* Variant carrying machine-readable {@code details} — e.g. item 115's {@code candidates}, the
* {@code sourceFile}s a caller can pick from when a module name is ambiguous. Without them the
* caller would know the request failed but not how to repeat it successfully.
*/
public static ErrorResponse of(String code, String message, Map<String, Object> details) {
return new ErrorResponse(message, code, details);
}
}

View File

@@ -1,6 +1,7 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectMetadataCache;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
@@ -10,17 +11,24 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
* REST API for creating, updating, deleting and listing projects.
*/
@Path("/api/projects")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "projects", description = "Create, update, delete and list projects (ingest roots).")
// All endpoints drive the blocking Neo4j driver (e.g. clearAll's full-graph DETACH DELETE), so
// dispatch to a worker thread instead of blocking the Vert.x event loop.
@Blocking
@@ -31,14 +39,16 @@ public class ProjectResource {
private final GraphRepository graphRepository;
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final ProjectMetadataCache projectMetadataCache;
private final boolean scanOnCreate;
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
ProjectRootResolver rootResolver,
ProjectRootResolver rootResolver, ProjectMetadataCache projectMetadataCache,
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
this.graphRepository = graphRepository;
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.projectMetadataCache = projectMetadataCache;
this.scanOnCreate = scanOnCreate;
}
@@ -48,20 +58,95 @@ public class ProjectResource {
.build();
}
static Response error(Response.Status status, String code, String message, Map<String, Object> details) {
return Response.status(status)
.entity(ErrorResponse.of(code, message, details))
.build();
}
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "typescript");
private static @Nullable String normalize(@Nullable String value) {
return value == null || value.isBlank() ? null : value.strip();
}
/**
* Validates the item-47 fields. {@code language} is required on create (a project declares its
* source language) and, if present anywhere, must be one of {@link #SUPPORTED_LANGUAGES}.
* {@code generatedDir}/{@code userExitDir} are set together or not at all (a generated module is
* annotated with its user-exit twin's metrics, which needs both). Returns an error {@link Response}
* or {@code null} when valid.
*/
private static @Nullable Response validateLanguageAndDirs(ProjectRequest request, boolean onCreate) {
String language = normalize(request.language());
if (onCreate && language == null) {
return error(Response.Status.BAD_REQUEST, "LANGUAGE_REQUIRED",
"A project requires a 'language' (" + String.join("/", SUPPORTED_LANGUAGES) + ")");
}
if (language != null && !SUPPORTED_LANGUAGES.contains(language.toLowerCase())) {
return error(Response.Status.BAD_REQUEST, "LANGUAGE_UNSUPPORTED",
"Unsupported language '" + language + "'; expected one of " + SUPPORTED_LANGUAGES);
}
String generatedDir = normalize(request.generatedDir());
String userExitDir = normalize(request.userExitDir());
// Both-or-neither: enforced on create always, and on update whenever either is being set.
boolean either = generatedDir != null || userExitDir != null;
if ((onCreate || either) && (generatedDir == null) != (userExitDir == null)) {
return error(Response.Status.BAD_REQUEST, "GENERATED_USEREXIT_PAIR",
"'generatedDir' and 'userExitDir' must be provided together or not at all");
}
return null;
}
@GET
@Operation(summary = "List all projects")
public Uni<List<ProjectInfo>> list() {
return graphRepository.listProjects();
}
@GET
@Path("/{project}")
@Operation(summary = "One project's config and last whole-root ingest",
description = "Item 126: the project's configuration plus what its last whole-root ingest did "
+ "(ingestedAt, mode, filesExamined/Persisted/Failed, serverVersion). 'ingest' is null when "
+ "no whole-root ingest has been recorded, which is not the same as one that found nothing.")
@APIResponse(responseCode = "200", description = "The project.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> get(@PathParam("project") String project) {
return graphRepository.getProject(project)
.map(info -> info == null
? error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist")
: Response.ok(info).build());
}
@POST
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a project", description = "Registers a project with a server-side source root and Tier-1 scans it.")
@APIResponse(responseCode = "201", description = "Created.")
@APIResponse(responseCode = "400", description = "Invalid request.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "409", description = "Project already exists.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> create(@PathParam("project") String project, ProjectRequest request) {
if (request.root() == null || request.root().isBlank()) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "ROOT_REQUIRED",
"A project requires a 'root' folder (server-side path to its sources)"));
}
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty())
@Nullable Response invalid = validateLanguageAndDirs(request, true);
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts == null ? List.of() : counterparts)
.map(result -> switch (result) {
case SUCCESS -> {
scanTier1(project);
@@ -100,8 +185,23 @@ public class ProjectResource {
@PUT
@Path("/{project}")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Update a project")
@APIResponse(responseCode = "200", description = "Updated.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> update(@PathParam("project") String project, ProjectRequest request) {
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs())
@Nullable Response invalid = validateLanguageAndDirs(request, false);
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts)
.map(result -> switch (result) {
case SUCCESS -> Response.ok().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
@@ -111,12 +211,60 @@ public class ProjectResource {
}
@DELETE
@Operation(summary = "Clear the whole graph", description = "Deletes every node/edge across all projects.")
@APIResponse(responseCode = "204", description = "Cleared.")
public Uni<Response> clearAll() {
return graphRepository.clearAll().replaceWith(Response.noContent().build());
}
/**
* Item 202: rename a project in place — every node, override and counterpart reference follows.
*/
@POST
@Path("/{project}/rename")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Rename a project (item 202)",
description = "Rewrites the project key on every node and override and in other projects' counterparts lists. "
+ "Batched; an interrupted rename is finished by re-running it.")
@APIResponse(responseCode = "200", description = "The renamed project.")
@APIResponse(responseCode = "400", description = "newName missing, blank or equal to the current name.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "409", description = "A project with the new name already exists.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> rename(@PathParam("project") String project, RenameRequest request) {
String newName = request == null || request.newName() == null ? "" : request.newName().trim();
if (newName.isEmpty() || newName.equals(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "INVALID_REQUEST",
"newName is required and must differ from the current name"));
}
return graphRepository.renameProject(project, newName)
.flatMap(result -> switch (result) {
case SUCCESS -> {
projectMetadataCache.invalidate(project);
projectMetadataCache.invalidate(newName);
yield graphRepository.getProject(newName).map(info -> Response.ok(info).build());
}
case NOT_FOUND -> Uni.createFrom().item(error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist"));
case CONFLICT -> Uni.createFrom().item(error(Response.Status.CONFLICT, "PROJECT_EXISTS",
"Project '" + newName + "' already exists"));
});
}
/**
* Body of {@code POST /api/projects/{project}/rename}.
*/
public record RenameRequest(@Nullable String newName) {
}
@DELETE
@Path("/{project}")
@Operation(summary = "Delete a project")
@APIResponse(responseCode = "204", description = "Deleted.")
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> delete(@PathParam("project") String project) {
return graphRepository.deleteProject(project)
.map(result -> switch (result) {
@@ -133,9 +281,39 @@ public class ProjectResource {
* <p>{@code root} is required on create (the server-side path the project's sources live under)
* and optional on update (null leaves it unchanged). {@code excludeDirs} are path components
* skipped when scanning {@code root}; null on update leaves the stored list unchanged.
* {@code language} is required on create (item 47). {@code generatedDir}/{@code userExitDir} are
* directory names for the generated vs. user-exit LoC split; set together or not at all.
*/
public record ProjectRequest(@Nullable String description, @Nullable String root,
@Nullable List<String> excludeDirs) {
@Nullable List<String> excludeDirs, @Nullable String language,
@Nullable String generatedDir, @Nullable String userExitDir,
@Nullable List<String> counterparts) {
/**
* Pre-item-193 shape (no counterparts).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
}
/**
* Backward-compatible constructor for callers that predate the item-47 fields (language /
* generated / user-exit dir all absent).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
this(description, root, excludeDirs, null, null, null, null);
}
/**
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
*/
@Nullable List<String> normalizedCounterparts() {
if (counterparts == null) {
return null;
}
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
}
List<String> excludeDirsOrEmpty() {
return excludeDirs != null ? excludeDirs : List.of();

View File

@@ -0,0 +1,63 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.ProjectMetadataCache;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
import jakarta.ws.rs.ext.Provider;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* Item 130: stamps every project-scoped response with the facts that decide how to read an
* <em>empty</em> one.
*
* <ul>
* <li>{@code X-AC-Exclude-Dirs} — the directories the ingest skipped. "No callers" means "none
* outside tests" in a project excluding {@code test} and "none at all" in one that does not,
* and nothing in the body said which.</li>
* <li>{@code X-AC-Ingested-At} — when the graph was last walked (item 126), so a stale answer is
* visible at the point of use rather than only on the project listing.</li>
* <li>{@code X-AC-Ingest-Incomplete} — a whole-root pass is running or never finished (item 129).</li>
* </ul>
*
* <p><b>Headers, not body fields, and deliberately so.</b> Most endpoints answer with a bare JSON
* array ({@code db-accesses}, {@code functions}, {@code search/identifier}, …); adding a field there
* means restructuring array → object, which breaks the web UI's generated client, the CLI printers
* and every agent that indexes {@code [0]}. A header costs no shape change and covers every endpoint
* at once. The trade-off is real: an agent reading only the JSON body will not see these.
*/
@Provider
public class ProjectScopeHeaderFilter implements ContainerResponseFilter {
static final String EXCLUDE_DIRS = "X-AC-Exclude-Dirs";
static final String INGESTED_AT = "X-AC-Ingested-At";
static final String INCOMPLETE = "X-AC-Ingest-Incomplete";
@Inject
ProjectMetadataCache projects;
@Override
public void filter(ContainerRequestContext request, ContainerResponseContext response) {
@Nullable String project = request.getUriInfo().getPathParameters().getFirst("project");
if (project == null || project.isBlank()) {
return;
}
@Nullable ProjectInfo info = projects.get(project);
if (info == null) {
return;
}
List<String> excludeDirs = info.excludeDirs();
response.getHeaders().putSingle(EXCLUDE_DIRS, excludeDirs.isEmpty() ? "(none)" : String.join(",", excludeDirs));
if (info.ingest() != null) {
response.getHeaders().putSingle(INGESTED_AT, info.ingest().ingestedAt());
response.getHeaders().putSingle(INCOMPLETE, Boolean.toString(info.ingest().incomplete()));
} else {
// Item 126's "never recorded" state, carried through honestly rather than as a false "false".
response.getHeaders().putSingle(INCOMPLETE, "unknown");
}
}
}

View File

@@ -5,12 +5,15 @@ import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
/**
* Exposes the server's name/version (see {@link VersionInfo}) over REST.
*/
@Path("/api/version")
@Produces(MediaType.APPLICATION_JSON)
@Tag(name = "meta", description = "Server metadata.")
public class VersionResource {
private final VersionInfo versionInfo;
@@ -20,6 +23,7 @@ public class VersionResource {
}
@GET
@Operation(summary = "Server name and version")
public VersionInfo version() {
return versionInfo;
}

View File

@@ -1,80 +0,0 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.service.IngestSummary;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolResponse;
import io.smallrye.common.annotation.Blocking;
import jakarta.enterprise.context.ApplicationScoped;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
/**
* MCP {@code refresh} tool mirroring the {@code refresh} endpoints of {@code AnalysisResource}
* (roadmap item 42). Resolves and validates the project's root folder via the shared
* {@link ProjectRootResolver} (same guards as REST: {@code PROJECT_NOT_FOUND} / {@code ROOT_NOT_SET}
* / {@code ROOT_NOT_FOUND}), then runs the corresponding {@link ProjectIngestService} refresh and
* returns the {@link IngestSummary} as JSON.
*
* <p>{@link Blocking}: refresh walks the file system and persists to Neo4j. The eager
* {@code ingest_all}/{@code ingest_module}/{@code ingest_call_graph} tools were removed — projects
* are coarse-scanned on create and deep-ingested lazily per query, with {@code refresh} as the
* explicit (re-)ingest. Destructive project CRUD (create/update/delete/clearAll) is intentionally
* <em>not</em> exposed.
*/
@ApplicationScoped
@McpLogged
public class McpIngestTools {
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final McpSupport support;
public McpIngestTools(ProjectIngestService ingestService, ProjectRootResolver rootResolver, McpSupport support) {
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.support = support;
}
@Tool(name = "refresh", description = "Re-ingest a project from disk so the graph reflects the current files (also the remedy for a STALE_SOURCE read). With 'module', deep re-ingests that module and its dependency tree (bounded by maxDepth 1..20 and maxNodes). Without 'module', re-ingests the whole root: the call graph by default, or full field-level dataflow with deep=true.")
@Blocking
public ToolResponse refresh(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name to deep-refresh; omit to refresh the whole project", required = false) @Nullable String module,
@ToolArg(description = "Whole-project refresh only: deep (field-level) ingest", required = false) @Nullable Boolean deep,
@ToolArg(description = "Module refresh only: max dependency depth in hops (1..20); default server setting", required = false) @Nullable Integer maxDepth,
@ToolArg(description = "Module refresh only: max number of files to ingest; default server setting", required = false) @Nullable Integer maxNodes) {
if (module != null && !module.isBlank()) {
return run(project, info -> ingestService.refreshModule(info, module, maxDepth, maxNodes));
}
boolean deepIngest = deep != null && deep;
return run(project, info -> ingestService.refreshProject(info, deepIngest));
}
/**
* Resolves the project root, maps a validation {@link ProjectRootResolver.Failed} to a tool error,
* otherwise runs {@code ingest} and returns its {@link IngestSummary} (or an {@code INGEST_FAILED}
* tool error if the root could not be scanned).
*/
private ToolResponse run(String project, RootIngest ingest) {
return switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Failed failed -> support.error(failed.code(), failed.message());
case ProjectRootResolver.Resolved resolved -> {
try {
yield support.ok(ingest.run(resolved.project()));
} catch (IOException e) {
yield support.error("INGEST_FAILED",
"Failed to scan root '" + resolved.project().root() + "': " + e.getMessage());
}
}
};
}
@FunctionalInterface
private interface RootIngest {
IngestSummary run(com.agenticcode.neo4jstore.graph.ProjectInfo project) throws IOException;
}
}

View File

@@ -1,18 +0,0 @@
package com.agenticcode.codeserver.mcp;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Binds {@link McpLoggingInterceptor} to every {@code @Tool} method of the annotated class, so each
* MCP call is logged with its tool name and arguments.
*/
@InterceptorBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface McpLogged {
}

View File

@@ -1,43 +0,0 @@
package com.agenticcode.codeserver.mcp;
import io.quarkiverse.mcp.server.Tool;
import jakarta.annotation.Priority;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import org.jboss.logging.Logger;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Logs every MCP tool call ({@code @McpLogged}-annotated classes) with its tool name (from
* {@code @Tool.name()}) and argument values, so tool usage is visible in the server log the same
* way REST calls are visible via the access log.
*/
@McpLogged
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class McpLoggingInterceptor {
private static final Logger LOG = Logger.getLogger("com.agenticcode.mcp");
private static String formatArgs(Method method, Object[] args) {
Parameter[] params = method.getParameters();
return IntStream.range(0, args.length)
.mapToObj(i -> params[i].getName() + "=" + args[i])
.collect(Collectors.joining(", "));
}
@AroundInvoke
Object logCall(InvocationContext context) throws Exception {
Method method = context.getMethod();
Tool tool = method.getAnnotation(Tool.class);
if (tool != null) {
LOG.infof("MCP tool call: %s(%s)", tool.name(), formatArgs(method, context.getParameters()));
}
return context.proceed();
}
}

View File

@@ -1,568 +0,0 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.service.*;
import com.agenticcode.neo4jstore.graph.*;
import com.agenticcode.parsercore.ast.model.NodeType;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolResponse;
import io.smallrye.common.annotation.Blocking;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.util.*;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* MCP tools mirroring the read endpoints of {@code AnalysisResource}. Each tool delegates to the
* same {@link GraphRepository} query the REST resource uses, so the graph is queried identically
* whether an agent goes through REST or MCP.
*
* <p>All tools are {@link Blocking} (they drive the blocking Neo4j driver, exactly like the
* {@code @Blocking} REST resource) and return a {@link ToolResponse} whose text content is the same
* concise JSON the REST API returns. Every module/variable/data-structure tool takes the
* {@code project} as its first argument and returns a {@code PROJECT_NOT_FOUND} tool error if it
* does not exist.
*/
@ApplicationScoped
@McpLogged
public class McpQueryTools {
private static final int DEFAULT_PAGE_LIMIT = 50;
private final GraphRepository graphRepository;
private final McpSupport support;
private final ProjectRootResolver rootResolver;
private final DeepIngestCoordinator deepIngestCoordinator;
private final SourceSnippetService sourceSnippetService;
private final VersionInfo versionInfo;
private final int defaultCallTreeDepth;
private final int maxCallTreeDepth;
// -------------------------------------------------------------------------
// Projects & module discovery
// -------------------------------------------------------------------------
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
ProjectRootResolver rootResolver, DeepIngestCoordinator deepIngestCoordinator,
SourceSnippetService sourceSnippetService,
VersionInfo versionInfo,
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth) {
this.graphRepository = graphRepository;
this.support = support;
this.rootResolver = rootResolver;
this.deepIngestCoordinator = deepIngestCoordinator;
this.sourceSnippetService = sourceSnippetService;
this.versionInfo = versionInfo;
this.defaultCallTreeDepth = defaultCallTreeDepth;
this.maxCallTreeDepth = maxCallTreeDepth;
}
@Tool(name = "version", description = "AgenticCode server name and version.")
public ToolResponse version() {
return support.ok(versionInfo);
}
private static int effectiveLimit(@Nullable Integer limit) {
return limit != null ? Math.max(limit, 0) : DEFAULT_PAGE_LIMIT;
}
private static int effectiveOffset(@Nullable Integer offset) {
return offset != null ? Math.max(offset, 0) : 0;
}
private static DeepIngestRequired deepIngestHint(String module, ModuleIngestState state) {
String status = state.exists() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
String detail = state.exists()
? "Module '" + module + "' has only its call graph ingested; field-level dataflow requires a deep ingest."
: "Module '" + module + "' is not ingested. Ingest the call graph and/or deep-ingest it.";
return new DeepIngestRequired(status, module, detail, "ingest_module");
}
@Tool(name = "list_projects", description = "List all ingested projects (name, description, root, excludeDirs).")
@Blocking
public Uni<ToolResponse> listProjects() {
return graphRepository.listProjects().map(support::ok);
}
@Tool(name = "list_modules", description = "List MODULE nodes (name, sourceFile, moduleKind, loc, sloc) in a project, optionally filtered by source file, moduleKind (CLASS/INTERFACE/PROGRAM/SUBPROGRAM), and/or direct EXTENDS base class name. loc = physical lines, sloc = source (non-blank, non-comment) lines; null for modules ingested before this existed.")
@Blocking
public Uni<ToolResponse> listModules(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile,
@ToolArg(description = "Optional module sub-kind to filter by (CLASS/INTERFACE/PROGRAM/SUBPROGRAM)", required = false) @Nullable String moduleKind,
@ToolArg(description = "Optional base class name; lists only its direct EXTENDS subclasses", required = false) @Nullable String extendsName) {
return withProject(project, () -> graphRepository.listModules(project, sourceFile, moduleKind, extendsName).map(support::ok));
}
@Tool(name = "project_loc", description = "Per-language LoC/SLoC rollup for a project: a breakdown by language (fileCount, loc, sloc) plus the project-wide total. loc = physical lines, sloc = source (non-blank, non-comment) lines computed per language. Each source file is counted once. Optionally narrow to one language ('natural'/'java') and/or one source file.")
@Blocking
public Uni<ToolResponse> projectLoc(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Optional language to filter by ('natural'/'java')", required = false) @Nullable String language,
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile) {
return withProject(project, () -> graphRepository.projectLoc(project, language, sourceFile).map(support::ok));
}
@Tool(name = "module_digest", description = "Small triage bundle for a module: description, function count, caller/callee names, DB table names, data-structure names + field counts.")
@Blocking
public Uni<ToolResponse> moduleDigest(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name (not a file path)") String name) {
return withProject(project, () -> graphRepository.moduleDigest(project, name).map(support::ok));
}
@Tool(name = "module_context", description = "Full context bundle for a module. Heavy sub-arrays (variableAccesses, large sqlStatements) are summarized unless requested via 'include'.")
@Blocking
public Uni<ToolResponse> moduleContext(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Comma-separated sections to fully expand, e.g. 'variableAccesses,sqlStatements'", required = false) @Nullable String include,
@ToolArg(description = "Max items for expanded sub-arrays (0 = unbounded)", required = false) @Nullable Integer limit,
@ToolArg(description = "Offset into expanded sub-arrays", required = false) @Nullable Integer offset) {
int effLimit = limit != null ? Math.max(limit, 0) : 0;
int effOffset = offset != null ? Math.max(offset, 0) : 0;
return withProject(project, () -> graphRepository.moduleContext(project, name, include, effLimit, effOffset).map(support::ok));
}
// -------------------------------------------------------------------------
// Call graph
// -------------------------------------------------------------------------
@Tool(name = "module_functions", description = "List functions (Natural subroutines / Java methods) declared in a module; set includeInherited to include inherited Java methods. 'kind' filters Java methods by modifier: abstract/final/overridable.")
@Blocking
public Uni<ToolResponse> moduleFunctions(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Include inherited (Java) methods", required = false) @Nullable Boolean includeInherited,
@ToolArg(description = "Optional Java modifier filter: abstract/final/overridable", required = false) @Nullable String kind) {
boolean inherited = includeInherited != null && includeInherited;
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(support::ok));
}
@Tool(name = "function_overrides", description = "Concrete subclass overrides of a base-class method (Java virtual/template-method dispatch). Pass 'function' for one hook method, or omit it to get overrides for every abstract method of the base class at once, grouped by hook method.")
@Blocking
public Uni<ToolResponse> functionOverrides(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module (base class) name") String name,
@ToolArg(description = "Base method name; omit for the bulk (all-abstract-methods) variant", required = false) @Nullable String function) {
return withProject(project, () -> function == null || function.isBlank()
? graphRepository.functionOverrides(project, name).map(support::ok)
: graphRepository.functionOverrides(project, name, function).map(support::ok));
}
@Tool(name = "module_data_structures", description = "Data structures (Natural DEFINE DATA blocks / Java DTOs) referenced by a module.")
@Blocking
public Uni<ToolResponse> moduleDataStructures(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.moduleDataStructures(project, name).map(support::ok));
}
@Tool(name = "module_dispatch_table", description = "Dispatch table for a module: dynamic-call lookup arrays and the module names their literals resolve to.")
@Blocking
public Uni<ToolResponse> moduleDispatchTable(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.dispatchTable(project, name).map(support::ok));
}
// -------------------------------------------------------------------------
// DB access
// -------------------------------------------------------------------------
@Tool(name = "module_columns", description = "Entity/table columns exposed by a module that maps to a DB table (e.g. a JPA @Entity).")
@Blocking
public Uni<ToolResponse> moduleColumns(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.entityColumns(project, name).map(support::ok));
}
private static Collection<String> stepModules(List<DataflowStep> steps) {
return steps.stream().map(DataflowStep::module).filter(Objects::nonNull).distinct().toList();
}
@Tool(name = "callees", description = "Modules/functions the given module calls (plus Java INJECTS/REFERENCES wiring). 'scope' filters by edge kind; resolveInterfaces hops interface callees to their implementations.")
@Blocking
public Uni<ToolResponse> callees(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset,
@ToolArg(description = "Hop interface callees to their implementation(s) (Java)", required = false) @Nullable Boolean resolveInterfaces) {
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
resolveInterfaces != null && resolveInterfaces).map(support::ok));
}
private static Collection<String> fieldFlowModules(List<FieldFlow> flows) {
Set<String> names = new LinkedHashSet<>();
for (FieldFlow flow : flows) {
names.add(flow.producer());
names.add(flow.consumer());
}
return names;
}
// -------------------------------------------------------------------------
// Search
// -------------------------------------------------------------------------
@Tool(name = "db_accesses", description = "DB tables a module reads/writes and the access mode. With 'depth' > 0, includes accesses of transitively-called modules.")
@Blocking
public Uni<ToolResponse> dbAccesses(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
int effLimit = effectiveLimit(limit);
int effOffset = effectiveOffset(offset);
return withProject(project, () -> depth != null && depth > 0
? graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(support::ok)
: graphRepository.dbAccesses(project, name, effLimit, effOffset).map(support::ok));
}
@Tool(name = "sql_statements", description = "SQL/ADABAS statements issued by a module. With 'depth' > 0, includes statements of transitively-called modules.")
@Blocking
public Uni<ToolResponse> sqlStatements(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth) {
return withProject(project, () -> depth != null && depth > 0
? graphRepository.sqlStatementsTransitive(project, name, depth).map(support::ok)
: graphRepository.sqlStatements(project, name).map(support::ok));
}
// -------------------------------------------------------------------------
// Variable access & dataflow
// -------------------------------------------------------------------------
@Tool(name = "data_structure_fields", description = "Fields of a data structure (Natural DEFINE DATA / Java DTO): name, type, level, redefines.")
@Blocking
public Uni<ToolResponse> dataStructureFields(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Data-structure name") String name) {
return withProject(project, () -> graphRepository.dataStructureFields(project, name).map(support::ok));
}
@Tool(name = "db_table_columns", description = "Columns of a DB table (ADABAS view / SQL table).")
@Blocking
public Uni<ToolResponse> dbTableColumns(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "DB table name") String name) {
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(support::ok));
}
@Tool(name = "callers", description = "Modules/functions that call the given module. 'scope' filters by edge kind.")
@Blocking
public Uni<ToolResponse> callers(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
return withFanoutWarm(project,
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
CallRefResponse::sourceFiles);
}
@Tool(name = "search_value", description = "Find nodes whose literal/assigned value matches the given string across a project.")
@Blocking
public Uni<ToolResponse> searchValue(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Value to search for") String value,
@ToolArg(description = "If true, case-insensitive substring match instead of exact", required = false) @Nullable Boolean contains,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (value.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_VALUE", "Argument 'value' is required"));
}
return withProject(project, () -> graphRepository.searchByValue(project, value, Boolean.TRUE.equals(contains),
effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "search_annotation", description = "Find nodes (Java only) carrying an annotation whose name matches (substring, case-insensitive), optionally filtered by node type.")
@Blocking
public Uni<ToolResponse> searchAnnotation(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Annotation name / substring") String name,
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (name.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_NAME", "Argument 'name' is required"));
}
if (type != null) {
try {
NodeType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
}
}
return withProject(project, () -> graphRepository.searchAnnotation(project, name,
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
}
@Tool(name = "inspect_node", description = "Every property of a single node by id (roadmap item 27). Node ids are regenerated on re-ingest, so only valid until the next one.")
@Blocking
public Uni<ToolResponse> inspectNode(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Node id") String id) {
return withProject(project, () -> graphRepository.nodeById(project, id).map(node -> node == null
? support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)")
: support.ok(node)));
}
@Tool(name = "call_tree", description = "Transitive call tree rooted at a module, up to 'depth' hops (clamped to the configured maximum). resolveInterfaces drops dead-end interface nodes whose implementations are already reached. followWiring also traverses Java INJECTS/REFERENCES edges (DI/class-literal wiring), not just CALLS.")
@Blocking
public Uni<ToolResponse> callTree(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
@ToolArg(description = "Drop interface nodes that have a known implementation (Java)", required = false) @Nullable Boolean resolveInterfaces,
@ToolArg(description = "Also traverse INJECTS/REFERENCES edges (Java DI/class-literal wiring), not just CALLS", required = false) @Nullable Boolean followWiring) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withFanoutWarm(project,
() -> graphRepository.callTree(project, name, effectiveDepth,
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring),
CallTreeResponse::sourceFiles);
}
@Tool(name = "search_identifier", description = "Find identifiers across a project by name (substring) and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE).")
@Blocking
public Uni<ToolResponse> searchIdentifier(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Identifier name / substring", required = false) @Nullable String name,
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
if (type != null) {
try {
NodeType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
}
}
return withFanoutWarm(project,
() -> graphRepository.searchIdentifier(project, name,
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)),
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
}
@Tool(name = "node_source", description = "Source-text snippet for a single node by id (roadmap item 28), read from the project root on disk.")
@Blocking
public Uni<ToolResponse> nodeSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Node id") String id) {
return withProject(project, () -> graphRepository.nodeById(project, id).flatMap(node -> {
if (node == null) {
return Uni.createFrom().item(support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)"));
}
String sourceFile = (String) node.get("sourceFile");
if (sourceFile == null || sourceFile.isEmpty()) {
return Uni.createFrom().item(support.error("NO_SOURCE_FILE", "Node '" + id + "' is an unresolved placeholder with no source file"));
}
int startLine = ((Number) Objects.requireNonNull(node.get("startLine"))).intValue();
int endLine = ((Number) Objects.requireNonNull(node.get("endLine"))).intValue();
return sourceSnippet(project, sourceFile, startLine, endLine);
}));
}
@Tool(name = "module_source", description = "Source-text snippet for a module by name and line range (roadmap item 28), read from the project root on disk.")
@Blocking
public Uni<ToolResponse> moduleSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Start line (1-indexed)") int startLine,
@ToolArg(description = "End line (1-indexed, inclusive)") int endLine) {
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
? Uni.createFrom().item(support.error("MODULE_NOT_FOUND", "No module '" + name + "' in project '" + project + "'"))
: sourceSnippet(project, sourceFile, startLine, endLine)));
}
@Tool(name = "variable_reads", description = "Functions that read a given variable, following the call graph up to 'depth' hops.")
@Blocking
public Uni<ToolResponse> variableReads(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withProject(project, () -> graphRepository.variableReads(project, name, module, effectiveDepth).map(support::ok));
}
// -------------------------------------------------------------------------
// Helpers (mirror AnalysisResource)
// -------------------------------------------------------------------------
@Tool(name = "variable_writes", description = "Functions that write a given variable, following the call graph up to 'depth' hops.")
@Blocking
public Uni<ToolResponse> variableWrites(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
return withProject(project, () -> graphRepository.variableWrites(project, name, module, effectiveDepth).map(support::ok));
}
/**
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
* endLine]} slice with a stale check (see {@link #readSourceSnippet}).
*/
private Uni<ToolResponse> sourceSnippet(String project, String sourceFile, int startLine, int endLine) {
return graphRepository.sourceHash(project, sourceFile)
.map(expectedHash -> readSourceSnippet(project, sourceFile, startLine, endLine, expectedHash));
}
/**
* Resolves the project root and reads {@code [startLine, endLine]} of {@code sourceFile},
* mapping root-resolution/IO failures to MCP tool errors. When {@code expectedHash} is non-null
* and the file on disk no longer matches it (item 41), returns a {@code STALE_SOURCE} error
* telling the caller to re-ingest, rather than slicing changed text.
*/
private ToolResponse readSourceSnippet(String project, String sourceFile, int startLine, int endLine,
@Nullable String expectedHash) {
String root;
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> root = r.project().root();
case ProjectRootResolver.Failed failed -> {
return support.error(failed.code(), failed.message());
}
}
try {
SourceSnippet snippet = sourceSnippetService.read(root, sourceFile, startLine, endLine, expectedHash);
return support.ok(snippet);
} catch (StaleSourceException e) {
return support.error("STALE_SOURCE", e.detail());
} catch (IOException e) {
return support.error("SOURCE_READ_FAILED", "Failed to read '" + sourceFile + "': " + e.getMessage());
}
}
@Tool(name = "flow_forward", description = "Forward field-level dataflow from a variable (where its value flows to). Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> flowForward(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.flowForward(project, name, module, effectiveDepth),
McpQueryTools::stepModules);
}
/**
* Runs {@code action} if the project exists, otherwise a {@code PROJECT_NOT_FOUND} tool error.
*/
private Uni<ToolResponse> withProject(String project, Supplier<Uni<ToolResponse>> action) {
return graphRepository.projectExists(project).flatMap(exists -> exists
? action.get()
: Uni.createFrom().item(support.error("PROJECT_NOT_FOUND", "Project '" + project + "' does not exist")));
}
@Tool(name = "flow_backward", description = "Backward field-level dataflow into a variable (where its value comes from). Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> flowBackward(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.flowBackward(project, name, module, effectiveDepth),
McpQueryTools::stepModules);
}
@Tool(name = "field_flow", description = "Producer/consumer field-flow pairs for a variable within its module. Requires the module to be deep-ingested.")
@Blocking
public Uni<ToolResponse> fieldFlow(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Variable name") String name,
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
return withFlowPathWarm(project, module,
() -> graphRepository.fieldFlow(project, name, module, effectiveDepth),
McpQueryTools::fieldFlowModules);
}
/**
* Fan-out / traversal deep-ingest warm (item 37, blocking-then-rerun): runs {@code query} against
* the graph as-is, deep-ingests the modules the result surfaced ({@code surfaced}) via
* {@link DeepIngestCoordinator#ensureDeepMany}, and — only if that warm actually deepened
* something — re-runs {@code query} against the now-deeper graph so the response reflects
* newly-resolved dynamic dispatch. When nothing changed (already deep, empty set, or the warm
* failed) the first result is returned directly, so the query never pays for a redundant re-run.
*
* @param surfaced maps a result to the {@code sourceFile} paths it surfaced (the warm target)
*/
private <T> Uni<ToolResponse> withFanoutWarm(String project, Supplier<Uni<T>> query,
Function<T, Collection<String>> surfaced) {
return withProject(project, () -> query.get().flatMap(first ->
deepIngestCoordinator.ensureDeepMany(project, surfaced.apply(first)).flatMap(changed ->
changed ? query.get().map(support::ok) : Uni.createFrom().item(support.ok(first)))));
}
/**
* Cross-module {@code flow_*} path-ingest (item 37a), the MCP counterpart of
* {@code AnalysisResource.withFlowPathWarm}: deep-ingests the named start {@code module} (falling
* back to the agent-actionable deep-ingest hint if it does not resolve to {@code FULL}), then runs
* {@code query} and drives a bounded ingest-and-re-traverse fixpoint via {@link #flowFixpoint} —
* each round deep-ingests the frontier the current result surfaced
* ({@link DeepIngestCoordinator#ensureFlowFrontier}) and re-runs the trace, so the flow crosses
* into callees (notably dynamically-dispatched ones) as they become deeply ingested. With no
* {@code module} it is a plain project-wide query.
*/
private <T> Uni<ToolResponse> withFlowPathWarm(String project, @Nullable String module,
Supplier<Uni<List<T>>> query,
Function<List<T>, Collection<String>> surfaced) {
if (module == null || module.isBlank()) {
return withProject(project, () -> query.get().map(support::ok));
}
String startModule = module;
return withProject(project, () -> deepIngestCoordinator.ensureDeep(project, startModule)
.flatMap(ignored -> graphRepository.moduleIngestState(project, startModule).flatMap(state ->
state.isFull()
? flowFixpoint(project, startModule, query, surfaced, deepIngestCoordinator.flowRounds())
: Uni.createFrom().item(support.errorBody(deepIngestHint(startModule, state))))));
}
/**
* One iteration of the flow path-ingest fixpoint: runs {@code query}, and while rounds remain and
* {@link DeepIngestCoordinator#ensureFlowFrontier} reports it deepened the frontier, re-traverses;
* otherwise returns the current result. The frontier seed always includes the start {@code module}
* (not just what the trace surfaced) so the first round pulls in the start module's not-yet-deep —
* notably dynamically-dispatched — callees even when the trace is still empty. Bounded by
* {@code roundsLeft} (configured {@code flow-rounds}) so it terminates even if the graph keeps
* surfacing new callees.
*/
private <T> Uni<ToolResponse> flowFixpoint(String project, String module, Supplier<Uni<List<T>>> query,
Function<List<T>, Collection<String>> surfaced, int roundsLeft) {
return query.get().flatMap(result -> {
if (roundsLeft <= 0) {
return Uni.createFrom().item(support.ok(result));
}
Set<String> frontier = new LinkedHashSet<>();
frontier.add(module);
frontier.addAll(surfaced.apply(result));
return deepIngestCoordinator.ensureFlowFrontier(project, frontier).flatMap(changed ->
changed
? flowFixpoint(project, module, query, surfaced, roundsLeft - 1)
: Uni.createFrom().item(support.ok(result)));
});
}
/**
* Agent-actionable hint: a deep query needs the module (deep-)ingested first via the named MCP tool.
*/
public record DeepIngestRequired(String status, String module, String detail, String nextTool) {
}
}

View File

@@ -1,57 +0,0 @@
package com.agenticcode.codeserver.mcp;
import com.agenticcode.codeserver.api.ErrorResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkiverse.mcp.server.ToolResponse;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Map;
/**
* Serializes MCP tool results to the same concise JSON the REST API emits, so an agent gets an
* identical payload shape whether it calls a tool or an endpoint.
*
* <p>Success results become a JSON text content; error results reuse the REST
* {@link ErrorResponse} shape ({@code {"error","code","details"}}) and are flagged as MCP tool
* errors ({@code isError=true}).
*/
@ApplicationScoped
class McpSupport {
private final ObjectMapper mapper;
McpSupport(ObjectMapper mapper) {
this.mapper = mapper;
}
/**
* Success response carrying {@code value} serialized as JSON text.
*/
ToolResponse ok(Object value) {
return ToolResponse.success(toJson(value));
}
/**
* Error response with the REST {@link ErrorResponse} JSON shape and {@code isError=true}.
*/
ToolResponse error(String code, String message) {
return ToolResponse.error(toJson(new ErrorResponse(message, code, Map.of())));
}
/**
* Error response carrying an already-built structured body (e.g. a deep-ingest hint).
*/
ToolResponse errorBody(Object body) {
return ToolResponse.error(toJson(body));
}
private String toJson(Object value) {
try {
return mapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
// Serialization of our own DTOs should never fail; surface loudly if it ever does.
throw new IllegalStateException("MCP JSON serialization failed for " + value.getClass(), e);
}
}
}

View File

@@ -1,5 +0,0 @@
/**
* MCP tool endpoints for AI agent integration.
*/
@org.jspecify.annotations.NullMarked
package com.agenticcode.codeserver.mcp;

View File

@@ -1,9 +1,8 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.IngestDepth;
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
import com.agenticcode.neo4jstore.graph.*;
import com.agenticcode.parsercore.ast.model.AstEdge;
import com.agenticcode.parsercore.ast.model.AstNode;
import com.agenticcode.parsercore.ast.model.LocMetrics;
import com.agenticcode.parsercore.ast.model.NodeType;
import com.agenticcode.parsercore.ast.spi.CoarseScanner;
@@ -12,14 +11,22 @@ import com.agenticcode.parsercore.ast.spi.LineCounter;
import com.agenticcode.parserjava.JavaCoarseScanner;
import com.agenticcode.parserjava.JavaLineCounter;
import com.agenticcode.parserjava.JavaParser;
import com.agenticcode.parsernatural.CopycodeResolver;
import com.agenticcode.parsernatural.NaturalCoarseScanner;
import com.agenticcode.parsernatural.NaturalLineCounter;
import com.agenticcode.parsernatural.NaturalParser;
import com.agenticcode.parsertypescript.CssLineCounter;
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner;
import com.agenticcode.parsertypescript.TypeScriptLineCounter;
import com.agenticcode.parsertypescript.TypeScriptParser;
import com.agenticcode.parsertypescript.TypeScriptProject;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jspecify.annotations.Nullable;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
/**
* Parses a source file with the language-specific parser and persists the resulting
@@ -32,14 +39,58 @@ public class AstIngestService {
private final GraphRepository graphRepository;
private final LanguageParser javaParser = new JavaParser();
private final LanguageParser naturalParser = new NaturalParser();
private final NaturalParser naturalParser = new NaturalParser();
private final CoarseScanner javaScanner = new JavaCoarseScanner();
private final CoarseScanner naturalScanner = new NaturalCoarseScanner();
private final NaturalCoarseScanner naturalScanner = new NaturalCoarseScanner();
private final LineCounter javaLineCounter = new JavaLineCounter();
private final LineCounter naturalLineCounter = new NaturalLineCounter();
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
private final LineCounter cssLineCounter = new CssLineCounter();
public AstIngestService(GraphRepository graphRepository) {
/**
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
* edges are dropped just before persist. Comments are 45.8 % of the `upms` node population and
* 60.4 % of everything {@code merge-nodes} processes, and this switch exists to measure what
* that actually costs. It is deliberately a config property and not a query parameter: it is an
* experiment, not a feature, so it gets no REST or CLI surface.
*
* <p>Turning it off makes a full parse emit no comments, so a reconciling run <em>deletes</em>
* the existing ones — the first run after a flip therefore pays a one-time sweep and must not be
* used as a measurement. Compare steady-state runs only.
*/
private final boolean commentsEnabled;
public AstIngestService(GraphRepository graphRepository,
@ConfigProperty(name = "agenticcode.ingest.comments.enabled",
defaultValue = "true") boolean commentsEnabled) {
this.graphRepository = graphRepository;
this.commentsEnabled = commentsEnabled;
}
/**
* Item 179: strips comment nodes and the edges touching them. Applied at the persist seam rather
* than in the parsers, so the parse cost stays in both arms of the A/B and the delta isolates
* persist — which is the whole question, since finalize never touches a comment node.
*/
private LanguageParser.ParseResult stripComments(LanguageParser.ParseResult result) {
Set<UUID> commentIds = result.nodes().stream()
.filter(n -> n.type() == NodeType.COMMENT)
.map(AstNode::id)
.collect(Collectors.toSet());
if (commentIds.isEmpty()) {
return result;
}
List<AstNode> nodes = result.nodes().stream()
.filter(n -> n.type() != NodeType.COMMENT)
.toList();
List<AstEdge> edges = result.edges().stream()
.filter(e -> !commentIds.contains(e.sourceId()) && !commentIds.contains(e.targetId()))
.toList();
return new LanguageParser.ParseResult(nodes, edges);
}
/**
@@ -60,8 +111,31 @@ public class AstIngestService {
* Parses {@code content} with the parser for {@code language}. Does not persist anything.
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content) {
LanguageParser parser = language == SourceFiles.Language.JAVA ? javaParser : naturalParser;
return parser.parse(sourceFile, content);
return parse(language, sourceFile, content, CopycodeResolver.NONE);
}
/**
* Parses {@code content}, expanding Natural copycode {@code INCLUDE}s via {@code copycodes}
* (item 46a) so a copycode's calls/DB access/dataflow surface on the including module. The Java
* parser has no copycode concept and ignores the resolver.
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
return parse(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
}
/**
* Parses {@code content} with the parser for {@code language}; {@code copycodes} serves Natural
* (item 46a), {@code typescript} serves TypeScript/CSS (item 192). The switch is exhaustive on
* purpose: a new {@link SourceFiles.Language} must be routed here, not fall through to a default.
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaParser.parse(sourceFile, content);
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
};
}
/**
@@ -70,8 +144,25 @@ public class AstIngestService {
* deep bodies. Persisted like a {@link #parse} result; the deep detail is filled in on demand.
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content) {
CoarseScanner scanner = language == SourceFiles.Language.JAVA ? javaScanner : naturalScanner;
return scanner.scan(sourceFile, content);
return coarseScan(language, sourceFile, content, CopycodeResolver.NONE);
}
/**
* Tier-1 coarse scan with Natural copycode expansion (item 46a) for call/DB visibility; the Java
* scanner ignores the resolver.
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
return coarseScan(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
}
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaScanner.scan(sourceFile, content);
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
};
}
/**
@@ -79,7 +170,12 @@ public class AstIngestService {
* the Tier-1 coarse scanners use, so a module's metrics are identical at any ingest depth.
*/
public LocMetrics count(SourceFiles.Language language, String content) {
LineCounter counter = language == SourceFiles.Language.JAVA ? javaLineCounter : naturalLineCounter;
LineCounter counter = switch (language) {
case JAVA -> javaLineCounter;
case NATURAL -> naturalLineCounter;
case TYPESCRIPT -> typeScriptLineCounter;
case CSS -> cssLineCounter;
};
return counter.count(content);
}
@@ -88,7 +184,17 @@ public class AstIngestService {
* {@link #finalizeProject} once after persisting a batch of files.
*/
public Uni<Void> persist(String project, LanguageParser.ParseResult result) {
return graphRepository.persist(project, result).replaceWithVoid();
return persist(project, result, false);
}
/**
* As {@link #persist(String, LanguageParser.ParseResult)}, but when {@code reconcile} is true also
* deletes the file's stale nodes this (full) parse no longer produces (item 58). Pass {@code false}
* for a coarse Tier-1 scan.
*/
public Uni<Void> persist(String project, LanguageParser.ParseResult result, boolean reconcile) {
return graphRepository.persist(project, commentsEnabled ? result : stripComments(result),
reconcile).replaceWithVoid();
}
/**
@@ -96,7 +202,67 @@ public class AstIngestService {
* must run {@link #finalizeProject} once after persisting all batches.
*/
public Uni<Void> persistBatch(String project, List<LanguageParser.ParseResult> results) {
return graphRepository.persistBatch(project, results).replaceWithVoid();
return persistBatch(project, results, false);
}
/**
* As {@link #persistBatch(String, List)}, but when {@code reconcile} is true also deletes each
* file's stale nodes this (full) parse no longer produces (item 58). Pass {@code false} for a
* coarse Tier-1 scan.
*/
public Uni<Void> persistBatch(String project, List<LanguageParser.ParseResult> results, boolean reconcile) {
List<LanguageParser.ParseResult> effective = commentsEnabled
? results
: results.stream().map(this::stripComments).toList();
return graphRepository.persistBatch(project, effective, reconcile).replaceWithVoid();
}
/**
* Item 62 (summary surface): which of {@code names} are real data fields of {@code project} —
* asked project-wide, because a data literal is routinely declared outside the ingested tree.
*/
public Uni<Set<String>> dataFieldNames(String project, Collection<String> names) {
return graphRepository.dataFieldNames(project, names);
}
/**
* Item 43: every distinct real source file that currently has a node in {@code project} — the
* caller checks these against the filesystem to find files deleted on disk whose nodes linger.
*/
public Uni<Set<String>> distinctSourceFiles(String project) {
return graphRepository.distinctSourceFiles(project);
}
/**
* Item 126: records what the last <b>whole-root</b> ingest of {@code project} did, so an agent can
* date an answer and see how complete the graph is without crawling the file system.
*/
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
return graphRepository.recordProjectIngest(project, ingest);
}
/**
* Item 129: marks a whole-root ingest as in flight, so one that never finishes leaves the graph
* visibly half-updated rather than looking clean.
*/
public Uni<Void> markProjectIngestStarted(String project, String mode, String startedAt) {
return graphRepository.markProjectIngestStarted(project, mode, startedAt);
}
/**
* Item 129: every ingested file's stored content hash, the input to a {@code changedOnly} refresh's
* skip decision.
*/
public Uni<java.util.Map<String, String>> sourceHashes(String project) {
return graphRepository.sourceHashes(project);
}
/**
* Item 43: deletes every node of {@code project} belonging to one of {@code sourceFiles} — the
* deleted-file orphan sweep run after a whole-project refresh.
*/
public Uni<Void> deleteNodesForSourceFiles(String project, Collection<String> sourceFiles) {
return graphRepository.deleteNodesForSourceFiles(project, sourceFiles);
}
/**
@@ -111,7 +277,14 @@ public class AstIngestService {
* (field-level resolution runs only for {@link EnrichmentLevel#FULL}).
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level) {
return graphRepository.finalizeProject(project, level).replaceWithVoid();
return finalizeProject(project, level, false);
}
/**
* @param profile diagnostic: profile the slow enrichment steps (see {@code GraphRepository}).
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
return graphRepository.finalizeProject(project, level, profile).replaceWithVoid();
}
/**
@@ -131,6 +304,14 @@ public class AstIngestService {
return graphRepository.markIngestDepth(project, depth, names).replaceWithVoid();
}
/**
* Item 114: persists the identities a whole-project walk skipped as duplicates. Whole-root walks
* only — see {@link GraphRepository#markDuplicateIdentities}.
*/
public Uni<Void> markDuplicateIdentities(String project, List<Map<String, Object>> duplicates) {
return graphRepository.markDuplicateIdentities(project, duplicates).replaceWithVoid();
}
/**
* @return the ingest state of a module (exists? deeply ingested?).
*/

View File

@@ -0,0 +1,78 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.parsernatural.CopycodeResolver;
import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
/**
* A project's Natural copycode ({@code .cpy}) library, backing {@link CopycodeResolver} for
* statement-level {@code INCLUDE} expansion (roadmap item 46a). Built once per ingest from a shallow
* scan of the project root (name → path index; members are read + cached on first use), so the deep
* and coarse parsers can splice a copycode's body into the modules that include it.
*
* <p>Copycodes are deliberately <em>not</em> {@link SourceFiles#classify ingestible} as standalone
* modules — they only resolve in an including module's scope — so this side index is how they enter
* the pipeline.
*/
final class CopycodeLibrary implements CopycodeResolver {
private static final Logger LOG = Logger.getLogger(CopycodeLibrary.class);
private final Path root;
private final Map<String, Path> byName;
private final Map<String, Copycode> cache = new ConcurrentHashMap<>();
private CopycodeLibrary(Path root, Map<String, Path> byName) {
this.root = root;
this.byName = byName;
}
/**
* Scans {@code root} for {@code .cpy} members (honoring {@code excludeDirs}), indexing them by
* uppercased stem. Returns an empty (but functional) library on I/O error or when none exist.
*/
static CopycodeLibrary scan(Path root, List<String> excludeDirs) {
Map<String, Path> index = new HashMap<>();
try (Stream<Path> stream = Files.walk(root)) {
stream.filter(Files::isRegularFile)
.filter(f -> f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".cpy"))
.filter(f -> !SourceFiles.isExcluded(root, f, excludeDirs))
.forEach(f -> index.putIfAbsent(SourceFiles.stem(f), f));
} catch (IOException e) {
LOG.warnf("Could not scan copycodes under '%s': %s", root, e.toString());
}
return new CopycodeLibrary(root, index);
}
@Override
public @Nullable Copycode resolve(String name) {
String key = name.toUpperCase(Locale.ROOT);
Copycode cached = cache.get(key);
if (cached != null) {
return cached;
}
Path path = byName.get(key);
if (path == null) {
return null;
}
try {
String sourceFile = root.relativize(path).toString().replace('\\', '/');
Copycode member = new Copycode(sourceFile, SourceFiles.read(path));
cache.put(key, member);
return member;
} catch (IOException e) {
LOG.warnf("Could not read copycode '%s' at '%s': %s", name, path, e.toString());
return null;
}
}
}

View File

@@ -3,13 +3,17 @@ package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.IngestStatus;
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
import com.agenticcode.parsercore.ast.model.SourceHash;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
@@ -74,6 +78,7 @@ public class DeepIngestCoordinator {
private final long warmAcquireMillis;
private final long ingestingTtlMillis;
private final long claimWaitMillis;
private final boolean autoInvalidateEnabled;
private final Semaphore warmPermits;
private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
@@ -84,7 +89,8 @@ public class DeepIngestCoordinator {
@ConfigProperty(name = "agenticcode.deep-ingest.max-concurrent-warms", defaultValue = "2") int maxConcurrentWarms,
@ConfigProperty(name = "agenticcode.deep-ingest.warm-acquire-timeout-seconds", defaultValue = "10") int warmAcquireSeconds,
@ConfigProperty(name = "agenticcode.deep-ingest.ingesting-ttl-seconds", defaultValue = "1800") int ingestingTtlSeconds,
@ConfigProperty(name = "agenticcode.deep-ingest.claim-wait-seconds", defaultValue = "120") int claimWaitSeconds) {
@ConfigProperty(name = "agenticcode.deep-ingest.claim-wait-seconds", defaultValue = "120") int claimWaitSeconds,
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
this.graphRepository = graphRepository;
this.ingestService = ingestService;
this.rootResolver = rootResolver;
@@ -94,6 +100,7 @@ public class DeepIngestCoordinator {
this.warmAcquireMillis = Math.max(0, warmAcquireSeconds) * 1000L;
this.ingestingTtlMillis = Math.max(1, ingestingTtlSeconds) * 1000L;
this.claimWaitMillis = Math.max(0, claimWaitSeconds) * 1000L;
this.autoInvalidateEnabled = autoInvalidateEnabled;
this.warmPermits = new Semaphore(this.maxConcurrentWarms, true);
}
@@ -126,10 +133,20 @@ public class DeepIngestCoordinator {
*/
public Uni<Void> ensureDeep(String project, String module) {
return graphRepository.moduleIngestState(project, module).flatMap(state -> {
if (state.isFull()) {
// Fast path: already deep and auto-invalidation off — nothing to check or do.
if (state.isFull() && !autoInvalidateEnabled) {
return Uni.createFrom().voidItem();
}
// The staleness check reads the file from disk (blocking), so run it — and any re-ingest —
// on a worker thread. A FULL module whose source changed on disk is invalidated (item 43)
// so the coalesce loop re-ingests it instead of short-circuiting on its stale FULL status.
return Uni.createFrom().<Void>item(() -> {
if (state.isFull()) {
if (!isModuleStale(project, module)) {
return null;
}
graphRepository.invalidateModule(project, module).await().indefinitely();
}
ingestIfNeeded(project, module);
return null;
})
@@ -138,6 +155,79 @@ public class DeepIngestCoordinator {
});
}
/**
* Item 43: whether module {@code module}'s source file has changed on disk since it was ingested —
* i.e. its current content hash no longer matches the stored {@code sourceHash}. {@code false} when
* auto-invalidation is off, the module has no source file, no hash was stored (legacy node), or the
* file can't be read (a transient IO error must never thrash a FULL module; a deleted file is the
* deleted-file sweep's job, not this path's).
*/
private boolean isModuleStale(String project, String module) {
if (!autoInvalidateEnabled) {
return false;
}
String sourceFile = graphRepository.moduleSourceFile(project, module, GraphRepository.ANY_SOURCE_FILE).await().indefinitely();
if (sourceFile == null || sourceFile.isEmpty()) {
return false;
}
return isSourceFileStale(project, sourceFile);
}
/**
* Item 43: whether {@code sourceFile}'s current content hash differs from the stored {@code
* sourceHash} (same comparison as the item-41 {@code /source} stale check). See {@link
* #isModuleStale} for the conservative {@code false} cases.
*/
private boolean isSourceFileStale(String project, String sourceFile) {
if (!autoInvalidateEnabled) {
return false;
}
String expected = graphRepository.sourceHash(project, sourceFile).await().indefinitely();
if (expected == null) {
return false;
}
String root = resolveRoot(project);
if (root == null) {
return false;
}
try {
String actual = SourceHash.of(SourceFiles.read(Path.of(root).resolve(sourceFile)));
return !expected.equals(actual);
} catch (IOException e) {
return false;
}
}
private @Nullable String resolveRoot(String project) {
return switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> r.project().root();
case ProjectRootResolver.Failed f -> null;
};
}
/**
* Item 105: whether a fan-out-surfaced file is a deep-ingest candidate at all.
*
* <p>A Natural data area ({@code .lda}/{@code .pda}/{@code .gda}) has <b>no deep tier</b> — both
* ingest tiers run the same {@code parseDataArea}, and the file yields {@code DATA_STRUCTURE} nodes
* only, never a {@code MODULE}. But {@code FULLY_INGESTED_SOURCE_FILES} asks for a {@code MODULE}
* with {@code ingestDepth = 'FULL'}, so a data area can never be reported as fully ingested and was
* re-warmed on <em>every single call</em> that surfaced it. The warm itself is trivial (~40 ms), but
* each one triggers a whole-project 45-step finalize — ~60 s on {@code upms} — and then reports
* "changed", making the caller re-run its query as well.
*
* <p>Measured: {@code search/identifier?name=ZFRAMBL0} (1 hit, a {@code .lda}) took <b>62 s</b>,
* while the same call with no hits took <b>0.9 s</b> — the query is not the cost, the pointless warm
* is. Repeating the call re-ingested the same file again, confirming it never converges.
*
* <p>Excluding these files loses nothing: a deep ingest of a data area produces exactly what the
* coarse one already produced.
*/
private static boolean isWarmable(String sourceFile) {
String lower = sourceFile.toLowerCase(Locale.ROOT);
return !lower.endsWith(".lda") && !lower.endsWith(".pda") && !lower.endsWith(".gda");
}
/**
* Fan-out warm (item 37): deep-ingests the result-set {@code sourceFiles} a fan-out/traversal
* query surfaced ({@code callers}, {@code search_identifier}, {@code call_tree}), bounded by the
@@ -150,15 +240,33 @@ public class DeepIngestCoordinator {
* its Tier-1 answer.
*/
public Uni<Boolean> ensureDeepMany(String project, Collection<String> sourceFiles) {
if (sourceFiles.isEmpty()) {
List<String> warmable = sourceFiles.stream().filter(DeepIngestCoordinator::isWarmable).toList();
if (warmable.isEmpty()) {
return Uni.createFrom().item(false);
}
return graphRepository.fullyIngestedSourceFiles(project, sourceFiles).flatMap(full -> {
List<String> pending = sourceFiles.stream().distinct().filter(sf -> !full.contains(sf)).toList();
if (pending.isEmpty()) {
return Uni.createFrom().item(false);
return graphRepository.fullyIngestedSourceFiles(project, warmable).flatMap(full -> {
List<String> distinct = warmable.stream().distinct().toList();
List<String> notFull = distinct.stream().filter(sf -> !full.contains(sf)).toList();
// Fast path: auto-invalidation off — re-warm only the not-yet-FULL files (item 37 behaviour).
if (!autoInvalidateEnabled) {
if (notFull.isEmpty()) {
return Uni.createFrom().item(false);
}
return Uni.createFrom().item(() -> warmFiles(project, notFull))
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.onFailure().recoverWithItem(false);
}
return Uni.createFrom().item(() -> warmFiles(project, pending))
// Item 43: also re-warm FULL files whose source changed on disk. The per-file hash check
// reads from disk (blocking), so build the pending set and warm on a worker thread.
return Uni.createFrom().item(() -> {
List<String> pending = new ArrayList<>(notFull);
for (String sf : distinct) {
if (full.contains(sf) && isSourceFileStale(project, sf)) {
pending.add(sf);
}
}
return pending.isEmpty() ? Boolean.FALSE : warmFiles(project, pending);
})
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
.onFailure().recoverWithItem(false);
});
@@ -223,6 +331,8 @@ public class DeepIngestCoordinator {
try {
LOG.infof("Fan-out warm: deep-ingesting %d result-set file(s) in project '%s'",
sourceFiles.size(), project);
// The completion line comes from bfsIngest, the shared worker behind both
// ingestFiles and ingestModules — logging here too would only duplicate it.
yield ingestService.ingestFiles(resolved.project(), sourceFiles, fanoutNodes).ingested() > 0;
} catch (IOException | RuntimeException e) {
LOG.errorf(e, "Fan-out warm of %d file(s) in project '%s' failed", sourceFiles.size(), project);
@@ -267,7 +377,7 @@ public class DeepIngestCoordinator {
if (state.isFull()) {
return;
}
if (!state.exists()) {
if (!state.ingested()) {
// No coarse node to carry a durable status; the monitor still prevents an intra-JVM
// duplicate. Ingest best-effort and stop.
ingestClaimed(project, module);
@@ -314,6 +424,8 @@ public class DeepIngestCoordinator {
}
case ProjectRootResolver.Resolved resolved -> {
LOG.infof("Auto deep-ingesting module '%s' in project '%s' (query trigger)", module, project);
// Completion is logged by bfsIngest (the shared deep-ingest worker), so this path
// needs no line of its own.
ingestService.ingestModule(resolved.project(), module);
// ingestModule marks the ingested tree FULL/INGESTED. If the entry module did not
// become FULL (e.g. its name did not resolve to a file), roll the claim back so it

View File

@@ -1,52 +1,15 @@
package com.agenticcode.codeserver.service;
import java.util.Set;
import com.agenticcode.parsercore.ast.model.ExternalTypeNames;
/**
* Allowlist-by-exclusion of JDK/stdlib and common-framework type names (item J6). A by-name ingest
* follows every referenced type; JDK/framework types (e.g. {@code List}, {@code String},
* {@code Optional}, {@code EntityManager}) never resolve to a file in the project, so without this
* filter they dominate the {@code unresolved} list and needlessly inflate the dependency fan-out.
*
* <p>Matched by uppercased simple name (dependency refs are uppercased). This is a deliberate
* heuristic: a project class deliberately named like a JDK type would also be skipped — acceptable
* and vanishingly rare, especially for Natural modules (8-char codes).
* Item J6 filter for the by-name ingest fan-out. The name list itself lives in
* {@link ExternalTypeNames} (item 128) so the parsers apply the identical exclusion when emitting
* reference edges — two copies of this list would drift, and the drift would show up as placeholder
* nodes appearing and disappearing between ingests.
*/
final class ExternalTypes {
private static final Set<String> NAMES = Set.of(
// java.lang
"OBJECT", "STRING", "CHARSEQUENCE", "INTEGER", "LONG", "DOUBLE", "FLOAT", "BOOLEAN", "BYTE",
"SHORT", "CHARACTER", "NUMBER", "STRINGBUILDER", "STRINGBUFFER", "THREAD", "RUNNABLE",
"EXCEPTION", "RUNTIMEEXCEPTION", "ILLEGALARGUMENTEXCEPTION", "ILLEGALSTATEEXCEPTION",
"THROWABLE", "ERROR", "CLASS", "ENUM", "ITERABLE", "COMPARABLE", "CLONEABLE", "VOID", "MATH",
"SYSTEM", "AUTOCLOSEABLE",
// java.util
"LIST", "ARRAYLIST", "LINKEDLIST", "MAP", "HASHMAP", "LINKEDHASHMAP", "TREEMAP",
"CONCURRENTHASHMAP", "SORTEDMAP", "NAVIGABLEMAP", "SET", "HASHSET", "LINKEDHASHSET", "TREESET",
"SORTEDSET", "COLLECTION", "COLLECTIONS", "OPTIONAL", "OPTIONALINT", "OPTIONALLONG", "ITERATOR",
"QUEUE", "DEQUE", "ARRAYDEQUE", "STACK", "VECTOR", "COMPARATOR", "ARRAYS", "OBJECTS", "UUID",
"DATE", "CALENDAR", "LOCALE", "RANDOM", "SCANNER", "PROPERTIES", "ENUMSET", "ENUMMAP", "BITSET",
// java.util.stream / function
"STREAM", "INTSTREAM", "LONGSTREAM", "DOUBLESTREAM", "COLLECTORS", "FUNCTION", "BIFUNCTION",
"CONSUMER", "BICONSUMER", "SUPPLIER", "PREDICATE", "BIPREDICATE", "UNARYOPERATOR", "BINARYOPERATOR",
// java.time
"LOCALDATE", "LOCALDATETIME", "LOCALTIME", "INSTANT", "DURATION", "PERIOD", "ZONEDDATETIME",
"OFFSETDATETIME", "ZONEID", "DAYOFWEEK", "MONTH", "YEAR", "CHRONOUNIT",
// java.io / nio
"FILE", "PATH", "PATHS", "FILES", "INPUTSTREAM", "OUTPUTSTREAM", "READER", "WRITER",
"BUFFEREDREADER", "IOEXCEPTION", "UNCHECKEDIOEXCEPTION",
// java.math
"BIGDECIMAL", "BIGINTEGER",
// java.util.concurrent / atomic
"ATOMICINTEGER", "ATOMICLONG", "ATOMICBOOLEAN", "ATOMICREFERENCE", "COMPLETABLEFUTURE", "FUTURE",
"EXECUTOR", "EXECUTORSERVICE", "EXECUTORS", "TIMEUNIT", "COUNTDOWNLATCH",
// logging
"LOGGER", "LOGGERFACTORY", "LOG",
// common frameworks: CDI / JPA / Quarkus / JAX-RS reactive
"ENTITYMANAGER", "SESSION", "STATELESSSESSION", "INSTANCE", "EVENT", "PROVIDER", "TYPELITERAL",
"UNI", "MULTI", "RESPONSE", "PANACHEQUERY", "PANACHEENTITY", "PANACHEENTITYBASE");
private ExternalTypes() {
}
@@ -54,6 +17,6 @@ final class ExternalTypes {
* True if {@code upperName} (an uppercased simple type name) is a JDK/stdlib/framework type.
*/
static boolean isExternal(String upperName) {
return NAMES.contains(upperName);
return ExternalTypeNames.isExternal(upperName);
}
}

View File

@@ -3,8 +3,10 @@ package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.EnrichmentLevel;
import com.agenticcode.neo4jstore.graph.IngestDepth;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import com.agenticcode.parsertypescript.TypeScriptProject;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
@@ -13,7 +15,11 @@ import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
@@ -40,17 +46,34 @@ public class ProjectIngestService {
private final int defaultDeepDepth;
private final int maxDeepDepth;
private final int defaultDeepNodes;
private final boolean autoInvalidateEnabled;
// Item 126: stamped onto the project shell with every whole-root ingest, so a graph can be
// attributed to the server release that wrote it.
private final VersionInfo versionInfo;
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
// report "clean" about a graph whose refresh has just started.
private final ProjectMetadataCache projectMetadata;
// Item 192: builds the per-ingest TypeScript context (workspaces, packages, sidecar facts).
private final TypeScriptSidecarService typescript;
public ProjectIngestService(AstIngestService astIngestService,
VersionInfo versionInfo,
ProjectMetadataCache projectMetadata,
TypeScriptSidecarService typescript,
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes) {
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes,
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
this.astIngestService = astIngestService;
this.versionInfo = versionInfo;
this.projectMetadata = projectMetadata;
this.typescript = typescript;
this.batchSize = Math.max(1, batchSize);
this.maxDeepDepth = Math.max(1, maxDeepDepth);
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
this.defaultDeepNodes = Math.max(1, defaultDeepNodes);
this.autoInvalidateEnabled = autoInvalidateEnabled;
}
/**
@@ -108,6 +131,53 @@ public class ProjectIngestService {
return new ParseResult(nodes, result.edges());
}
/**
* The path-component names skipped when walking a project's root: its {@code excludeDirs} plus its
* {@code userExitDir} (item 47) — user-exit files are annotated onto the generated twin, not
* ingested as standalone modules (they would collide by name), so the walk skips them.
*/
private static List<String> ingestExcludeDirs(ProjectInfo project) {
if (project.userExitDir() == null || project.userExitDir().isBlank()) {
return project.excludeDirs();
}
List<String> dirs = new ArrayList<>(project.excludeDirs());
dirs.add(project.userExitDir());
return dirs;
}
/**
* Item 47: annotates a generated module/data-structure node with its user-exit twin's LoC/SLoC
* ({@code userExitLoc}/{@code userExitSloc}). A node qualifies when the project has a
* {@code generatedDir}, the node's {@code sourceFile} lies under it, and a same-named file was
* scanned under the {@code userExitDir} ({@code userExitByStem}). Applied to both the coarse
* (Tier-1) and deep parse results, so the split is correct at any ingest depth.
*/
private static ParseResult withUserExitMetrics(ParseResult result, @Nullable String generatedDir,
Map<String, LocMetrics> userExitByStem) {
if (generatedDir == null || generatedDir.isBlank() || userExitByStem.isEmpty()) {
return result;
}
List<AstNode> nodes = result.nodes().stream().map(node -> {
if (!isRealModuleOrStructure(node)
|| !UserExitMetrics.pathHasComponent(node.sourceFile(), generatedDir)) {
return node;
}
LocMetrics twin = userExitByStem.get(node.name().toUpperCase(Locale.ROOT));
if (twin == null) {
return node;
}
Map<String, String> props = new HashMap<>();
if (node.properties() != null) {
props.putAll(node.properties());
}
props.put("userExitLoc", Integer.toString(twin.loc()));
props.put("userExitSloc", Integer.toString(twin.sloc()));
return new AstNode(node.id(), node.type(), node.name(), node.sourceFile(), node.language(),
node.startLine(), node.endLine(), node.dataType(), node.value(), props);
}).toList();
return new ParseResult(nodes, result.edges());
}
/**
* The identities a file contributes to cross-file duplicate detection — the file's own
* identity, never the nodes nested inside it:
@@ -133,6 +203,23 @@ public class ProjectIngestService {
.toList();
}
/**
* Item 24: reads and parses one candidate file, applying the shell/user-exit metric enrichment.
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
*/
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
CopycodeLibrary copycodes, TypeScriptProject ts, Map<String, LocMetrics> userExit,
ProjectInfo project) throws IOException {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
ParseResult result = coarse
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ts)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
return new Parsed(candidate, result);
}
/**
* Identity used for cross-file duplicate detection: the fully-qualified name for Java modules
* (so {@code com.a.Foo} and {@code com.b.Foo} are distinct), the bare name otherwise.
@@ -153,9 +240,12 @@ public class ProjectIngestService {
* Build-output directories always skipped when scanning a root (J6: their generated sources
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
*/
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
private static List<Candidate> walk(Path root, List<String> excludeDirs) throws IOException {
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
throws IOException {
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
for (String dir : DEFAULT_EXCLUDE_DIRS) {
if (!effectiveExcludes.contains(dir)) {
@@ -172,7 +262,7 @@ public class ProjectIngestService {
continue;
}
SourceFiles.Kind kind = SourceFiles.classify(file);
if (kind != null) {
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
candidates.add(new Candidate(file, kind));
}
}
@@ -212,6 +302,18 @@ public class ProjectIngestService {
return new IngestSummary.Truncation(reason, depthLimit, nodeLimit, hint);
}
/**
* @return {@code file}'s content hash, or {@code ""} when it cannot be read — which never equals a
* stored hash, so an unreadable file is re-parsed (and fails loudly there) rather than skipped.
*/
private static String hashOf(Path file) {
try {
return SourceHash.of(SourceFiles.read(file));
} catch (IOException e) {
return "";
}
}
/**
* Walks the entire project root and ingests every ingestible file. {@code deep} selects the
* enrichment level: {@code deep=false} (default) runs the fast call-graph pass (placeholder
@@ -222,7 +324,7 @@ public class ProjectIngestService {
* {@link IngestDepth#FULL}).
*/
public IngestSummary ingestAll(ProjectInfo project, boolean deep) throws IOException {
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false);
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false, false);
}
/**
@@ -235,7 +337,7 @@ public class ProjectIngestService {
* resolve; field-level dataflow is deferred to the on-demand deep ingest.
*/
public IngestSummary scanTier1(ProjectInfo project) throws IOException {
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true);
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true, false);
}
/**
@@ -245,7 +347,7 @@ public class ProjectIngestService {
* caller to deep-ingest a program first. Intended as the fast first pass after project creation.
*/
public IngestSummary ingestCallGraph(ProjectInfo project) throws IOException {
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false);
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false, false);
}
/**
@@ -258,7 +360,70 @@ public class ProjectIngestService {
* wipe.)
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep) throws IOException {
return deep ? ingestAll(project, true) : ingestCallGraph(project);
return refreshProject(project, deep, false);
}
/**
* Item 129: {@code changedOnly} re-parses only files whose content hash differs from the graph's
* (plus files with no stored hash). <b>Opt-in, and deliberately not the default</b> — three
* whole-walk behaviours are reduced in this mode:
*
* <ul>
* <li><b>Natural copycodes are inlined at parse time</b>, so a module whose {@code .cpy} changed
* parses differently while its own hash is unchanged. A changed copycode therefore
* <b>disables skipping for the whole run</b> (logged) rather than silently keeping stale
* expansions — the one case where "incremental" would corrupt the graph outright.</li>
* <li><b>Duplicate identity detection</b> groups the files it parsed; with a subset it can only
* confirm duplicates among changed files. Existing markers are never cleared (the query only
* MERGEs), so this loses discovery, not recorded facts.</li>
* <li><b>User-exit LoC annotation</b> (item 47) is re-stamped only on files that were re-parsed:
* a changed user-exit twin does not refresh an unchanged generated module's metrics.</li>
* </ul>
*
* <p>Enrichment is project-wide and still runs in full, so this cuts parse+persist time only.
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly) throws IOException {
return refreshProject(project, deep, changedOnly, false);
}
/**
* @param profile diagnostic (2026-09-05): profile the slow enrichment steps of this run. Costs
* 10-30% on its own, so it is opt-in per run and never a default.
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly,
boolean profile) throws IOException {
long startedAt = System.nanoTime();
IngestSummary summary = ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH,
false, changedOnly, profile);
sweepDeletedFileOrphans(project);
LOG.infof("Refresh finished: project='%s', mode=%s, files=%d, modules=%d, failed=%d, %d s",
project.name(), deep ? "deep" : "call-graph", summary.examinedFiles().size(),
summary.ingested(), summary.failed().size(),
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
return summary;
}
/**
* Item 43: after a whole-project refresh has re-ingested the current files, removes nodes for files
* that were <em>deleted</em> from disk (a refresh MERGEs current files and per-file reconciles them,
* but never touches files it no longer walks). Reconciles against the filesystem rather than the
* walked-candidate list, so on-demand dependency files (PDAs/LDAs/copybooks) that exist on disk but
* aren't top-level candidates are never wrongly swept.
*/
private void sweepDeletedFileOrphans(ProjectInfo project) {
if (!autoInvalidateEnabled) {
return;
}
Path root = Path.of(project.root());
Set<String> tracked = astIngestService.distinctSourceFiles(project.name()).await().indefinitely();
List<String> gone = tracked.stream()
.filter(sf -> !Files.exists(root.resolve(sf)))
.toList();
if (!gone.isEmpty()) {
LOG.infof("Refresh of '%s': removing nodes for %d deleted file(s): %s",
project.name(), gone.size(), gone);
astIngestService.deleteNodesForSourceFiles(project.name(), gone).await().indefinitely();
}
}
/**
@@ -273,31 +438,105 @@ public class ProjectIngestService {
return ingestModule(project, moduleName, maxDepth, maxNodes);
}
/**
* Item 129: re-ingests <b>only the named files</b> (relative paths), for the common case of a code
* change touching a handful of files where a whole-root refresh re-examines thousands.
*
* <p>Deep by construction: it runs the same BFS deep ingest the fan-out warm uses, so the named
* files and their dependencies land {@code FULL}. Two things it deliberately does <b>not</b> do,
* because they are only meaningful for a whole-root walk: the deleted-file sweep (item 43 —
* nothing here says which files disappeared) and the project-shell ingest stamp (item 126 — this
* walks a fraction of the tree, and moving {@code ingestedAt} would advertise the project as
* freshly walked).
*
* <p>Paths that match no file under the root are returned in the summary's {@code unresolved} list
* rather than dropped: "I ingested 2 of your 3 files" must be visible, or a typo'd path reads as a
* successful refresh.
*/
public IngestSummary refreshPaths(ProjectInfo project, Collection<String> sourceFiles) throws IOException {
Path root = Path.of(project.root()).toAbsolutePath().normalize();
List<String> requested = sourceFiles.stream()
.map(String::trim)
.filter(sf -> !sf.isEmpty())
.distinct()
.toList();
// Same guard as the source endpoints: these paths are client-supplied, so a '..' or an
// absolute path must not reach outside the project root.
List<String> unresolved = requested.stream()
.filter(sf -> !root.resolve(sf).normalize().startsWith(root)
|| !Files.isRegularFile(root.resolve(sf).normalize())
// A path that exists but is not an ingestible source file (pom.xml, a README)
// must be reported, not accepted: it was silently listed as examined while
// nothing about it could ever be ingested, which is precisely the "I ingested
// 2 of your 3 files" invisibility this list exists to prevent.
|| SourceFiles.classify(root.resolve(sf).normalize()) == null)
.toList();
List<String> ingestable = requested.stream().filter(sf -> !unresolved.contains(sf)).toList();
if (ingestable.isEmpty()) {
return new IngestSummary(0, unresolved, List.of(), List.of(), List.of(), null);
}
IngestSummary summary = ingestFiles(project, ingestable, null);
LOG.infof("Targeted refresh of '%s': %d file(s) requested, %d ingested, %d unresolved",
project.name(), requested.size(), summary.ingested(), unresolved.size());
return new IngestSummary(summary.ingested(), unresolved, summary.duplicates(), summary.failed(),
ingestable, summary.truncation());
}
/**
* Shared whole-root ingest. {@code level} selects how far enrichment goes, and the
* {@link IngestDepth} the ingested modules are tagged with ({@code FULL} only when field-level
* resolution ran, else {@code CALL_GRAPH}).
*/
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse) throws IOException {
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
boolean changedOnly) throws IOException {
return ingestRoot(project, level, coarse, changedOnly, false);
}
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
boolean changedOnly, boolean profile) throws IOException {
long startedAt = System.nanoTime();
// Item 129: mark the pass in flight before touching anything. If it never reaches the record
// call at the end — crash, container stop, an aborted deep refresh — the marker stays set and
// every later answer can say the graph is half-updated instead of looking clean.
markIngestStarted(project, coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT));
Path root = Path.of(project.root());
List<Candidate> candidates = walk(root, project.excludeDirs());
List<String> excludeDirs = ingestExcludeDirs(project);
List<Candidate> allCandidates = walk(root, excludeDirs, project.language());
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, excludeDirs);
// Item 129: opt-in skip of files whose content is byte-identical to what the graph holds.
List<Candidate> candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates)
: allCandidates;
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
// Tier-1, so the refresh completes and the response says what is missing.
List<IngestSummary.Failure> failed = new ArrayList<>();
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
List<String> examinedFiles = candidates.stream()
.map(c -> relativeSourceFile(root, c.file()))
.toList();
// Item 24: parse+read each file on its own virtual thread. Reading source is IO-bound and
// parsing is CPU-bound but per-file independent (the JavaParser/NaturalParser instances hold no
// mutable state; copycodes/userExit are read-only), so this scales the parse phase across cores.
// Results are collected in candidate order (futures list is parallel to candidates), so the
// downstream duplicate detection and persist order stay deterministic.
List<Parsed> parsed = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
for (Candidate candidate : candidates) {
try {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
ParseResult result = coarse
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content),
content, candidate.kind().language());
parsed.add(new Parsed(candidate, result));
} catch (RuntimeException | IOException e) {
failed.add(new IngestSummary.Failure(candidate.file().toString(), e.toString()));
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Parsed>> futures = candidates.stream()
.map(candidate -> parseExecutor.submit(
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project)))
.toList();
for (int i = 0; i < futures.size(); i++) {
try {
parsed.add(futures.get(i).get());
} catch (ExecutionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), cause.toString()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), e.toString()));
}
}
}
@@ -316,7 +555,15 @@ public class ProjectIngestService {
keyToIdentity.forEach((key, id) -> {
Set<String> paths = keyToPaths.get(key);
if (paths != null && paths.size() > 1) {
duplicates.add(new IngestSummary.Duplicate(id.displayName(), id.kind(), List.copyOf(paths)));
// Item 114: reported relative to the project root, like every other path the API hands
// out (node sourceFile, candidates, call sites). They used to be absolute server paths —
// inconsistent with all of those, unusable as a key against them, and a needless
// disclosure of where the root happens to be mounted.
List<String> relative = paths.stream()
.map(p -> relativeSourceFile(Path.of(project.root()), Path.of(p)))
.sorted()
.toList();
duplicates.add(new IngestSummary.Duplicate(id.displayName(), id.kind(), relative));
conflictingPaths.addAll(paths);
}
});
@@ -324,6 +571,14 @@ public class ProjectIngestService {
List<Parsed> toPersist = parsed.stream()
.filter(p -> !conflictingPaths.contains(p.candidate().file().toString()))
.toList();
// Item 114: persist what was skipped. The list was already computed here and reported in the
// ingest response, then lost — after a refresh that takes minutes nobody still holds that body,
// and the endpoints answered 404 for an identity that exists twice. Written from the whole-root
// walk only; a scoped ingest sees a fraction of the tree and must not clear what it never saw.
astIngestService.markDuplicateIdentities(project.name(), duplicates.stream()
.map(d -> Map.of(
"name", d.name(), "type", d.kind().name(), "paths", d.paths()))
.toList()).await().indefinitely();
// Persist in chunked transactions (each aggregates many files' nodes/edges into batched
// UNWIND writes) rather than one transaction per file, collapsing thousands of round trips
// into a handful. Enrichment still runs once at the end over the fully-persisted graph.
@@ -333,7 +588,9 @@ public class ProjectIngestService {
List<ParseResult> results = chunk.stream().map(Parsed::result).toList();
LOG.infof("Ingesting files %d-%d of %d into project '%s'",
from + 1, from + chunk.size(), toPersist.size(), project.name());
astIngestService.persistBatch(project.name(), results).await().indefinitely();
// Reconcile (delete stale nodes) only for a full parse — a coarse Tier-1 scan emits a
// subset of nodes and must not delete deep detail (item 58).
astIngestService.persistBatch(project.name(), results, !coarse).await().indefinitely();
ingested += chunk.size();
}
// Project-wide enrichment (placeholder resolution + dataflow + polymorphic fan-out) runs
@@ -342,16 +599,136 @@ public class ProjectIngestService {
if (ingested > 0) {
LOG.infof("Finalizing project '%s' (%d files persisted, %s)", project.name(), ingested,
level.name().toLowerCase(Locale.ROOT));
astIngestService.finalizeProject(project.name(), level).await().indefinitely();
astIngestService.finalizeProject(project.name(), level, profile).await().indefinitely();
// Tag ingest depth: only a field-resolving (FULL) pass marks modules FULL; the fast
// whole-root passes mark CALL_GRAPH (so field queries still hint a deep ingest), without
// downgrading any already-FULL module.
astIngestService.markIngestDepth(project.name(),
level.resolveFields() ? IngestDepth.FULL : IngestDepth.CALL_GRAPH, null).await().indefinitely();
}
String mode = coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT);
long durationSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt);
LOG.infof("Project ingest finished: project='%s', mode=%s, files=%d, persisted=%d, failed=%d, "
+ "duplicates=%d, %d s",
project.name(), mode, examinedFiles.size(),
ingested, failed.size(), duplicates.size(), durationSeconds);
// Item 126: this is the only place that stamps the project shell, and it is reached only by the
// three whole-root passes (Tier-1 scan, call-graph refresh, deep refresh). By-name and fan-out
// ingests deliberately do not come through here — they walk a fraction of the tree, and moving
// ingestedAt for them would report the project as freshly walked when one module was deepened.
// Item 129: filesExamined counts what this pass actually walked. In changedOnly mode that is
// the changed subset, and the log line above says so — a smaller number here is the point, not
// a sign of a short walk.
recordIngest(project, mode, examinedFiles.size(), ingested, failed, durationSeconds);
return new IngestSummary(ingested, List.of(), duplicates, failed, examinedFiles, null);
}
/**
* Item 129: the subset of {@code candidates} whose on-disk content differs from the hash stored in
* the graph (or that has no stored hash at all — a new file, or one ingested before hashes existed).
*
* <p>Returns <b>every</b> candidate — i.e. skips nothing — when a Natural copycode has changed.
* Copycode text is inlined into the including module at parse time, so those modules parse
* differently while their own hashes are unchanged; skipping them would leave stale expansions in
* the graph with nothing to indicate it. Reading the copycodes' own hashes is not enough to know
* <em>which</em> modules include them at this point in the walk, and guessing wrong is silent
* corruption, so the whole optimisation stands down for that run.
*/
private List<Candidate> changedCandidates(ProjectInfo project, Path root, List<String> excludeDirs,
List<Candidate> candidates) {
Map<String, String> stored = astIngestService.sourceHashes(project.name()).await().indefinitely();
// Only Natural inlines copycodes at parse time, so only Natural needs the stand-down. Running
// the check for a Java project was actively harmful: `ac` carries .cpy files as Natural *test
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
// (a legacy project) keeps the conservative behaviour.
String declared = project.language() == null ? "" : project.language();
boolean inlinesCopycodes = declared.isEmpty() || "natural".equalsIgnoreCase(declared);
if (inlinesCopycodes && copycodeChanged(root, excludeDirs, stored)) {
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
project.name());
return candidates;
}
List<Candidate> changed = new ArrayList<>();
for (Candidate candidate : candidates) {
String relative = relativeSourceFile(root, candidate.file());
@Nullable String known = stored.get(relative);
if (known == null || !known.equals(hashOf(candidate.file()))) {
changed.add(candidate);
}
}
LOG.infof("changedOnly refresh of '%s': %d of %d files changed since the last ingest",
project.name(), changed.size(), candidates.size());
return changed;
}
/**
* @return whether any {@code .cpy} member's content differs from the hash the graph holds for it.
* A copycode with no stored hash counts as changed: it may never have been ingested, and assuming
* otherwise is the unsafe direction.
*/
private boolean copycodeChanged(Path root, List<String> excludeDirs, Map<String, String> stored) {
try (Stream<Path> stream = Files.walk(root)) {
return stream.filter(Files::isRegularFile)
.filter(f -> f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".cpy"))
.filter(f -> !SourceFiles.isExcluded(root, f, excludeDirs))
.anyMatch(f -> {
@Nullable String known = stored.get(relativeSourceFile(root, f));
return known == null || !known.equals(hashOf(f));
});
} catch (IOException e) {
LOG.warnf("Could not scan copycodes under '%s' for changes (%s); re-parsing everything",
root, e.toString());
return true;
}
}
/**
* Item 129: stamps the in-flight marker. Never fatal — a project whose bookkeeping cannot be written
* must still be ingestable; the cost of failing here is only that an interruption would go unmarked.
*/
private void markIngestStarted(ProjectInfo project, String mode) {
try {
astIngestService.markProjectIngestStarted(project.name(), mode,
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()).await().indefinitely();
projectMetadata.invalidate(project.name());
} catch (RuntimeException e) {
LOG.warnf(e, "Could not mark ingest start for project '%s'; an interrupted run will not be flagged",
project.name());
}
}
/**
* Item 126: writes the whole-root ingest's outcome onto the {@code (:Project)} shell. Failure
* <em>paths</em> are capped at {@link ProjectIngestInfo#MAX_FAILURES} while the count stays exact,
* so a truncated list can never be read as "these were all of them".
*
* <p>Never fatal: the graph is already written, and losing the bookkeeping must not turn a
* successful ingest into a failed request. A warning is logged instead — the missing metadata then
* shows up as {@code ingest: null}, which reads as "not recorded" rather than as a false fact.
*/
private void recordIngest(ProjectInfo project, String mode, int filesExamined, int filesPersisted,
List<IngestSummary.Failure> failed, long durationSeconds) {
List<String> failurePaths = failed.stream()
.map(f -> relativeSourceFile(Path.of(project.root()), Path.of(f.path())))
.limit(ProjectIngestInfo.MAX_FAILURES)
.toList();
ProjectIngestInfo ingest = new ProjectIngestInfo(
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), mode,
filesExamined, filesPersisted, failed.size(), failurePaths,
failed.size() > failurePaths.size(), durationSeconds, versionInfo.version(),
// Reaching here means the pass completed; the write clears the in-flight marker.
false, null);
try {
astIngestService.recordProjectIngest(project.name(), ingest).await().indefinitely();
projectMetadata.invalidate(project.name());
} catch (RuntimeException e) {
LOG.warnf(e, "Could not record ingest metadata for project '%s'; it will report ingest=null",
project.name());
}
}
/**
* Ingests {@code moduleName} and its transitive dependencies using the configured default depth
* and node budget. See {@link #ingestModule(ProjectInfo, String, Integer, Integer)}.
@@ -373,18 +750,37 @@ public class ProjectIngestService {
*/
public IngestSummary ingestModule(ProjectInfo project, String moduleName,
@Nullable Integer maxDepth, @Nullable Integer maxNodes) throws IOException {
return ingestModules(project, List.of(moduleName), maxDepth, maxNodes);
}
/**
* Multi-seed variant of {@link #ingestModule}: deep-ingests every module in {@code moduleNames}
* (and each one's transitive dependency tree) in a <b>single</b> bounded BFS over one filesystem
* walk. Used by the neighbourhood refresh (item 51 M3 follow-up), which seeds the walk with a
* module together with its transitive callers and callees so the whole call-graph neighbourhood —
* and, via each module's own USING/INCLUDE fan-out, its data structures — is deep-ingested at once.
* The {@code maxNodes} budget bounds the union across all seeds.
*/
public IngestSummary ingestModules(ProjectInfo project, Collection<String> moduleNames,
@Nullable Integer maxDepth, @Nullable Integer maxNodes) throws IOException {
int depthLimit = Math.min(Math.max(1, maxDepth != null ? maxDepth : defaultDeepDepth), maxDeepDepth);
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
Path root = Path.of(project.root());
List<Candidate> candidates = walk(root, project.excludeDirs());
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
// Reverse index (Java): interface/base simple name -> files that implement/extend it, so a
// targeted ingest also pulls in the implementations/subclasses of any interface it reaches.
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
List<QueuedRef> seeds = List.of(
new QueuedRef(new DependencyRef(moduleName.toUpperCase(Locale.ROOT), NodeType.MODULE), 0, null));
return bfsIngest(project, root, index, implementorsByBase, seeds, depthLimit, nodeLimit,
"'" + moduleName + "'");
List<QueuedRef> seeds = moduleNames.stream()
.filter(n -> n != null && !n.isBlank())
.map(n -> n.toUpperCase(Locale.ROOT))
.distinct()
.map(n -> new QueuedRef(new DependencyRef(n, NodeType.MODULE), 0, null))
.toList();
String label = seeds.size() == 1
? "'" + moduleNames.iterator().next() + "'"
: seeds.size() + " modules";
return bfsIngest(project, root, index, implementorsByBase, seeds, depthLimit, nodeLimit, label);
}
/**
@@ -404,7 +800,7 @@ public class ProjectIngestService {
@Nullable Integer maxNodes) throws IOException {
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
Path root = Path.of(project.root());
List<Candidate> candidates = walk(root, project.excludeDirs());
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
Map<String, Candidate> byRelPath = new HashMap<>();
@@ -427,6 +823,48 @@ public class ProjectIngestService {
seeds.size() + " module(s)");
}
/**
* Item 62 (summary surface): records, from one parsed file, how each placeholder call target is
* reached — an inferred {@code CALLNAT_DYNAMIC}/{@code INCLUDE_MACRO} edge vs a trusted static
* {@code CALLNAT 'X'} — which is gate 4 of the data-literal test below. Names are upper-cased
* because the BFS upper-cases its refs.
*/
private static void collectCallProvenance(ParseResult result,
Set<String> inferredCallTargets,
Set<String> staticCallTargets) {
Map<UUID, String> placeholderModules = new HashMap<>();
for (AstNode node : result.nodes()) {
if (node.sourceFile().isEmpty() && node.type() == NodeType.MODULE) {
placeholderModules.put(node.id(), node.name().toUpperCase(Locale.ROOT));
}
}
for (AstEdge edge : result.edges()) {
if (edge.type() != EdgeType.CALLS) {
continue;
}
String target = placeholderModules.get(edge.targetId());
if (target == null) {
continue;
}
Map<String, String> props = edge.properties();
String callKind = props == null ? null : props.get("callKind");
if (CallKind.CALLNAT_DYNAMIC.name().equals(callKind) || CallKind.INCLUDE_MACRO.name().equals(callKind)) {
inferredCallTargets.add(target);
} else {
staticCallTargets.add(target);
}
}
}
/**
* @return whether {@code name} carries a Natural sigil — {@code #} (user variable), {@code &} (AIV)
* or {@code +} (GDA). A genuine dynamic-dispatch target always does, so a sigil'd name is never
* treated as a data literal.
*/
private static boolean isSigilled(String name) {
return !name.isEmpty() && (name.charAt(0) == '#' || name.charAt(0) == '&' || name.charAt(0) == '+');
}
/**
* Bounded breadth-first deep ingest shared by the by-name walk ({@link #ingestModule}) and the
* by-path fan-out warm ({@link #ingestFiles}). Ingests {@code seeds} and, up to {@code depthLimit}
@@ -439,10 +877,20 @@ public class ProjectIngestService {
Map<NameKey, List<Candidate>> index,
Map<String, List<Candidate>> implementorsByBase,
List<QueuedRef> seeds, int depthLimit, int nodeLimit, String label) {
int ingested = 0;
List<String> unresolved = new ArrayList<>();
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
long startedAt = System.nanoTime();
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
List<IngestSummary.Failure> failed = new ArrayList<>();
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
// workspace, run once for this call).
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
int ingested = 0;
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
List<DependencyRef> unresolvedRefs = new ArrayList<>();
Set<String> inferredCallTargets = new HashSet<>();
Set<String> staticCallTargets = new HashSet<>();
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
List<String> examinedFiles = new ArrayList<>();
Set<String> ingestedModuleNames = new HashSet<>();
@@ -476,7 +924,7 @@ public class ProjectIngestService {
} else {
List<Candidate> matches = index.getOrDefault(new NameKey(ref.type(), ref.name()), List.of());
if (matches.isEmpty()) {
unresolved.add(ref.type() + " " + ref.name());
unresolvedRefs.add(ref);
continue;
}
if (matches.size() > 1) {
@@ -490,16 +938,19 @@ public class ProjectIngestService {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
examinedFiles.add(sourceFile);
ParseResult result = withShellMetrics(
astIngestService.parse(candidate.kind().language(), sourceFile, content),
content, candidate.kind().language());
ParseResult result = withUserExitMetrics(withShellMetrics(
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
content, candidate.kind().language()), project.generatedDir(), userExit);
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);
astIngestService.persist(project.name(), result).await().indefinitely();
// Full deep parse of this module — reconcile so a by-name refresh purges its stale
// nodes (renamed/removed fields) rather than shadowing them (item 58).
astIngestService.persist(project.name(), result, true).await().indefinitely();
ingested++;
result.nodes().stream()
.filter(ProjectIngestService::isRealModuleOrStructure)
.filter(node -> node.type() == NodeType.MODULE)
.forEach(node -> ingestedModuleNames.add(node.name()));
collectCallProvenance(result, inferredCallTargets, staticCallTargets);
List<DependencyRef> deps = AstIngestService.dependencies(result);
// If the just-ingested module is an interface/base type, also ingest its
// implementations/subclasses (keyed by the uppercased simple name).
@@ -536,8 +987,60 @@ public class ProjectIngestService {
// (and others stay CALL_GRAPH / unresolved with a deep-ingest hint).
astIngestService.markIngestDepth(project.name(), IngestDepth.FULL, names).await().indefinitely();
}
return new IngestSummary(ingested, unresolved, duplicates, failed, examinedFiles,
truncation(label, depthLimit, nodeLimit, depthTruncated, nodeTruncated));
List<String> unresolved = filterDataLiteralRefs(project.name(), unresolvedRefs,
inferredCallTargets, staticCallTargets);
IngestSummary.@Nullable Truncation truncated =
truncation(label, depthLimit, nodeLimit, depthTruncated, nodeTruncated);
// One line per operation, not per file: the walk logs "Ingesting <module> …" for each file it
// reaches, so `files` is what those N start lines add up to.
LOG.infof("Deep ingest finished: project='%s', seed=%s, files=%d, failed=%d, unresolved=%d, "
+ "truncated=%s, %d s",
project.name(), label, ingested, failed.size(), unresolved.size(), truncated != null,
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
return new IngestSummary(ingested, unresolved, duplicates, failed, examinedFiles, truncated);
}
/**
* Item 62 (summary surface): drops the unresolved refs that are really <em>data values</em> — a
* browse key reaching a {@code CALLNAT} through a copycode/macro argument — so the summary agrees
* with the graph, where {@code DELETE_DATA_LITERAL_CALL_PLACEHOLDERS} reaps the same nodes. Without
* this, a deep ingest reports e.g. {@code MODULE CO-TABLA} as a missing dependency although
* {@code CO-TABLA} is an {@code (A8)} field and no such module exists anywhere.
*
* <p>Mirrors the graph reap's four gates: the ref is a {@code MODULE}, carries no Natural sigil
* ({@code #}/{@code &}/{@code +} — a genuine dispatch variable always does), names a real data field,
* and is reached <em>only</em> by inferred edges. That last gate is what keeps the real external
* modules {@code RPC-CNTX}/{@code USIX081X} reported: their names collide with field names, but a
* static {@code CALLNAT 'X'} is trusted. (The "no real MODULE" gate is implicit — a ref that matched
* a file never reaches this list.) Applied after the whole tree is walked, so the verdict can't
* depend on BFS visit order.
*
* <p>{@code dataFieldNames} is asked of the <em>graph</em>, not of this tree: a browse key reaching a
* {@code CALLNAT} through a macro argument is often declared elsewhere in the project (e.g.
* {@code NAME-DESC-SP}, reported by a {@code WGEAGB0S} ingest but declared in {@code BMTABBN2.nat}),
* so a tree-local check silently misses it.
*/
private List<String> filterDataLiteralRefs(String project, List<DependencyRef> refs,
Set<String> inferredCallTargets,
Set<String> staticCallTargets) {
List<DependencyRef> candidates = refs.stream()
.filter(ref -> ref.type() == NodeType.MODULE)
.filter(ref -> !isSigilled(ref.name()))
.filter(ref -> inferredCallTargets.contains(ref.name().toUpperCase(Locale.ROOT)))
.filter(ref -> !staticCallTargets.contains(ref.name().toUpperCase(Locale.ROOT)))
.toList();
Set<String> dataFields = candidates.isEmpty()
? Set.of()
: astIngestService.dataFieldNames(project,
candidates.stream().map(DependencyRef::name).collect(Collectors.toSet()))
.await().indefinitely();
Set<DependencyRef> dataLiterals = candidates.stream()
.filter(ref -> dataFields.contains(ref.name()))
.collect(Collectors.toSet());
return refs.stream()
.filter(ref -> !dataLiterals.contains(ref))
.map(ref -> ref.type() + " " + ref.name())
.toList();
}
/**

View File

@@ -0,0 +1,72 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Item 130: a short-lived cache of the {@code (:Project)} shell, so the scope/staleness response
* headers cost no database round trip per request.
*
* <p>Without it the header filter would add a Neo4j read to <em>every</em> call — unacceptable for
* endpoints that answer in tens of milliseconds. The TTL is short, and the ingest path
* {@link #invalidate(String) invalidates} explicitly rather than waiting it out: a stale
* {@code ingestedAt} is a cosmetic lag, but a stale {@code incomplete=false} while a refresh is
* running would point exactly the wrong way — it would say "clean" about a half-updated graph.
*
* <p>A read failure yields {@code null} (no headers) rather than an error: this is decoration on
* someone else's answer, and it must never turn a good response into a failed one.
*/
@ApplicationScoped
public class ProjectMetadataCache {
private static final Logger LOG = Logger.getLogger(ProjectMetadataCache.class);
/**
* How long a cached shell may be reused. Short enough that a missed invalidation self-corrects
* within one human-noticeable moment.
*/
private static final long TTL_MILLIS = 10_000;
private final GraphRepository graphRepository;
private final Map<String, Entry> cache = new ConcurrentHashMap<>();
public ProjectMetadataCache(GraphRepository graphRepository) {
this.graphRepository = graphRepository;
}
/**
* @return the project's shell, or {@code null} if it does not exist or could not be read
*/
public @Nullable ProjectInfo get(String project) {
Entry cached = cache.get(project);
long now = System.currentTimeMillis();
if (cached != null && now - cached.readAt() < TTL_MILLIS) {
return cached.info();
}
try {
@Nullable ProjectInfo info = graphRepository.getProject(project).await().indefinitely();
cache.put(project, new Entry(info, now));
return info;
} catch (RuntimeException e) {
LOG.debugf(e, "Could not read project '%s' for response headers", project);
return null;
}
}
/**
* Drops the cached shell — called when an ingest changes it, so the in-flight marker is visible
* immediately rather than up to {@link #TTL_MILLIS} late.
*/
public void invalidate(String project) {
cache.remove(project);
}
private record Entry(@Nullable ProjectInfo info, long readAt) {
}
}

View File

@@ -10,10 +10,10 @@ import java.nio.file.Path;
/**
* Loads a project and validates that its root folder is usable for ingest.
*
* <p>Shared by the REST {@code AnalysisResource} and the MCP ingest tools so the guard logic
* <p>Shared by the REST resources so the guard logic
* (project exists, root configured, root is a directory on the server) has a single source of
* truth and cannot drift between the two entry points. The result carries a transport-neutral
* {@code code}; each caller maps it to its own error shape (HTTP status / MCP tool error).
* {@code code}; each caller maps it to its own error shape (HTTP status).
*/
@ApplicationScoped
public class ProjectRootResolver {

View File

@@ -17,8 +17,16 @@ import java.util.Locale;
* the file's primary node carries, and provides the filename-stem used as a module/data-area key.
*
* <p>Java classes carry {@link NodeType#MODULE}; Natural {@code .nat}/{@code .nsn} programs are
* {@code MODULE}s and {@code .lda}/{@code .pda} data areas are {@link NodeType#DATA_STRUCTURE}s.
* Files with any other extension are not ingestible and classify to {@code null}.
* {@code MODULE}s and {@code .lda}/{@code .pda}/{@code .gda} data areas (local/parameter/global) are
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
* ingestible and classify to {@code null}.
*
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
* project of a given declared language picks a file up. Java and Natural files are ingested by every
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
*/
final class SourceFiles {
@@ -37,12 +45,30 @@ final class SourceFiles {
if (name.endsWith(".nat") || name.endsWith(".nsn")) {
return new Kind(Language.NATURAL, NodeType.MODULE);
}
if (name.endsWith(".lda") || name.endsWith(".pda")) {
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
}
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
}
if (name.endsWith(".css")) {
return new Kind(Language.CSS, NodeType.MODULE);
}
return null;
}
/**
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
* — see the class comment.
*/
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
return switch (language) {
case JAVA, NATURAL -> true;
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
};
}
/**
* @return the uppercased filename stem (name without extension), used as the case-insensitive
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
@@ -83,7 +109,10 @@ final class SourceFiles {
return false;
}
enum Language {JAVA, NATURAL}
/**
* The parser a file goes to. {@code CSS} shares the TypeScript parser but has its own line counter.
*/
enum Language {JAVA, NATURAL, TYPESCRIPT, CSS}
/**
* Classification of an ingestible source file.

View File

@@ -0,0 +1,11 @@
package com.agenticcode.codeserver.service;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
/**
* One regex hit in a module's source text (item 54): the module, its source file, the 1-based line
* number, and the matching line's text (trimmed of trailing whitespace).
*/
@Schema(description = "A regex match in a module's source: module, file, line number, line text.")
public record SourceMatch(String module, String sourceFile, int lineNo, String line) {
}

View File

@@ -0,0 +1,13 @@
package com.agenticcode.codeserver.service;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import java.util.List;
/**
* Result of a source-text regex search (item 54): the (echoed) pattern, whether the match {@code
* limit} was hit (so more matches exist), and the matches found.
*/
@Schema(description = "Regex search over module source text: matches with file+line, plus a truncated flag.")
public record SourceSearchResponse(String regex, boolean truncated, List<SourceMatch> matches) {
}

View File

@@ -0,0 +1,68 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.ModuleInfo;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Regex search over module source text (item 54), read from the project root on disk with the same
* UTF-8/ISO-8859-1 fallback decoding the parsers use ({@link SourceFiles#read}). No index: files are
* grepped on demand, bounded by a match {@code limit} so a common pattern stops early. Each module's
* source file is read once (deduped), so inner classes / inline data areas don't re-scan it.
*/
@ApplicationScoped
public class SourceSearchService {
private static String stripTrailing(String s) {
int end = s.length();
while (end > 0 && (s.charAt(end - 1) == ' ' || s.charAt(end - 1) == '\t' || s.charAt(end - 1) == '\r')) {
end--;
}
return s.substring(0, end);
}
/**
* @param root the project's absolute root folder
* @param modules the modules to search (their {@code sourceFile}s, deduped)
* @param pattern the compiled regex (already carries CASE_INSENSITIVE if requested)
* @param limit maximum number of matches to return; when hit, {@code truncated} is set
*/
public SourceSearchResponse search(String root, List<ModuleInfo> modules, Pattern pattern, int limit) {
Path rootPath = Path.of(root);
List<SourceMatch> matches = new ArrayList<>();
Set<String> seenFiles = new HashSet<>();
boolean truncated = false;
outer:
for (ModuleInfo module : modules) {
String sourceFile = module.sourceFile();
if (sourceFile == null || sourceFile.isBlank() || !seenFiles.add(sourceFile)) {
continue; // unresolved placeholder, or already scanned via another node
}
String content;
try {
content = SourceFiles.read(rootPath.resolve(sourceFile));
} catch (IOException | RuntimeException e) {
continue; // unreadable file — skip rather than fail the whole search
}
String[] lines = content.split("\n", -1);
for (int i = 0; i < lines.length; i++) {
if (pattern.matcher(lines[i]).find()) {
if (matches.size() >= limit) {
truncated = true;
break outer;
}
matches.add(new SourceMatch(module.name(), sourceFile, i + 1, stripTrailing(lines[i])));
}
}
}
return new SourceSearchResponse(pattern.pattern(), truncated, matches);
}
}

View File

@@ -32,6 +32,30 @@ public class SourceSnippetService {
*/
public SourceSnippet read(String root, String sourceFile, int startLine, int endLine,
@Nullable String expectedHash) throws IOException, StaleSourceException {
List<String> allLines = readAndVerify(root, sourceFile, expectedHash).lines().toList();
int from = Math.max(1, startLine);
int to = Math.min(allLines.size(), endLine);
List<String> slice = from > to ? List.of() : allLines.subList(from - 1, to);
return new SourceSnippet(sourceFile, startLine, endLine, slice);
}
/**
* Reads the <b>whole file</b> (M1: the source-viewer needs the full module, not a line range),
* with the same stale-hash check as {@link #read}. The returned {@code startLine}/{@code endLine}
* report the file's true bounds ({@code 1..lineCount}), so no sentinel leaks to the caller.
*/
public SourceSnippet readWholeFile(String root, String sourceFile, @Nullable String expectedHash)
throws IOException, StaleSourceException {
List<String> allLines = readAndVerify(root, sourceFile, expectedHash).lines().toList();
return new SourceSnippet(sourceFile, allLines.isEmpty() ? 0 : 1, allLines.size(), allLines);
}
/**
* Reads a source file with the parsers' UTF-8/ISO-8859-1 fallback decoding and enforces the
* item-41 stale-source check when {@code expectedHash} is non-null.
*/
private String readAndVerify(String root, String sourceFile, @Nullable String expectedHash)
throws IOException, StaleSourceException {
Path file = Path.of(root).resolve(sourceFile);
String content = SourceFiles.read(file);
if (expectedHash != null) {
@@ -40,10 +64,6 @@ public class SourceSnippetService {
throw new StaleSourceException(sourceFile, expectedHash, actual);
}
}
List<String> allLines = content.lines().toList();
int from = Math.max(1, startLine);
int to = Math.min(allLines.size(), endLine);
List<String> slice = from > to ? List.of() : allLines.subList(from - 1, to);
return new SourceSnippet(sourceFile, startLine, endLine, slice);
return content;
}
}

View File

@@ -0,0 +1,124 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import com.agenticcode.parsertypescript.TypeScriptFacts;
import com.agenticcode.parsertypescript.TypeScriptProject;
import com.agenticcode.parsertypescript.TypeScriptSidecar;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Item 192: builds the per-ingest {@link TypeScriptProject} for a {@code typescript} project. Tier-1
* (coarse) reads only the {@code package.json} files; a deep pass also runs the Node sidecar once per
* npm workspace, one after another (measured ~0.5 GB heap and 3–5 s each on the pur frontend), and
* merges the facts. A workspace whose sidecar run fails is reported as a
* {@link IngestSummary.Failure} with the pseudo-path {@code sidecar:<workspace>}; its files are then
* parsed at Tier-1, so the ingest completes and the response says what is missing.
*
* <p>Configuration ({@code application.properties}): {@code agenticcode.typescript.node} (binary, a
* bare name is looked up on {@code PATH}), {@code agenticcode.typescript.sidecar-script}
* ({@code extract.mjs}; {@code %prod} points into the image), {@code agenticcode.typescript.max-heap-mb}
* and {@code agenticcode.typescript.timeout-seconds}.
*/
@ApplicationScoped
public class TypeScriptSidecarService {
private static final Logger LOG = Logger.getLogger(TypeScriptSidecarService.class);
private final TypeScriptSidecar sidecar;
public TypeScriptSidecarService(
@ConfigProperty(name = "agenticcode.typescript.node", defaultValue = "node") String node,
@ConfigProperty(name = "agenticcode.typescript.sidecar-script",
defaultValue = "../ac-parser-typescript/sidecar/extract.mjs") String script,
@ConfigProperty(name = "agenticcode.typescript.max-heap-mb", defaultValue = "1024") int maxHeapMb,
@ConfigProperty(name = "agenticcode.typescript.timeout-seconds", defaultValue = "600") int timeoutSeconds) {
this.sidecar = new TypeScriptSidecar(resolveOnPath(node), Path.of(script).toAbsolutePath().normalize(),
Math.max(128, maxHeapMb), Duration.ofSeconds(Math.max(10, timeoutSeconds)));
}
/**
* A bare program name is searched on {@code PATH}; anything with a separator is taken as given.
*/
static Path resolveOnPath(String program) {
Path given = Path.of(program);
if (given.getNameCount() > 1 || given.isAbsolute()) {
return given;
}
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
Path candidate = Path.of(dir, program);
if (Files.isExecutable(candidate)) {
return candidate;
}
}
}
return given;
}
/**
* @param project the project being ingested; anything but {@code typescript} yields {@link TypeScriptProject#NONE}
* @param root the project root
* @param coarse Tier-1: no sidecar
* @param failures receives one entry per workspace whose sidecar run failed
*/
public TypeScriptProject prepare(ProjectInfo project, Path root, boolean coarse, List<IngestSummary.Failure> failures) {
return prepare(project, root, project.excludeDirs(), coarse, failures);
}
/**
* @param excludeDirs directory names the walk skips; a workspace whose directory is excluded is
* not loaded by the sidecar either (the pur frontend registers only
* {@code pur-ui} + {@code pur-ui-common} and excludes the other two workspaces)
*/
public TypeScriptProject prepare(ProjectInfo project, Path root, List<String> excludeDirs, boolean coarse,
List<IngestSummary.Failure> failures) {
if (!"typescript".equalsIgnoreCase(project.language() == null ? "" : project.language())) {
return TypeScriptProject.NONE;
}
TypeScriptProject context;
try {
context = TypeScriptProject.scan(root);
} catch (IOException e) {
LOG.warnf("Could not read package.json under '%s' (%s); using built-in package list", root, e.toString());
context = TypeScriptProject.NONE;
}
if (coarse) {
return context;
}
if (!sidecar.available()) {
String why = "TypeScript sidecar unavailable (node binary, extract.mjs or its node_modules/typescript missing); "
+ "TypeScript files ingested at Tier-1 only";
LOG.warn(why);
failures.add(new IngestSummary.Failure("sidecar", why));
return context;
}
List<String> workspaces = context.workspaces().isEmpty() ? List.of(".") : context.workspaces().stream()
.filter(ws -> excludeDirs.stream().noneMatch(x -> x.equalsIgnoreCase(ws) || x.equalsIgnoreCase(Path.of(ws).getFileName().toString())))
.sorted()
.toList();
List<TypeScriptFacts> parts = new ArrayList<>();
for (String workspace : workspaces) {
long started = System.nanoTime();
try {
TypeScriptFacts facts = sidecar.extract(root, workspace, null);
parts.add(facts);
LOG.infof("TypeScript sidecar: workspace '%s' of '%s' -> %d files in %d ms", workspace, project.name(),
facts.byFile().size(), (System.nanoTime() - started) / 1_000_000);
} catch (TypeScriptSidecar.SidecarException e) {
LOG.warnf("TypeScript sidecar failed for workspace '%s' of '%s': %s", workspace, project.name(), e.getMessage());
failures.add(new IngestSummary.Failure("sidecar:" + workspace, String.valueOf(e.getMessage())));
}
}
return context.withFacts(TypeScriptFacts.merge(parts));
}
}

View File

@@ -0,0 +1,102 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.parsercore.ast.model.LocMetrics;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
/**
* Builds the item-47 user-exit LoC/SLoC index: for a project configured with a {@code userExitDir},
* scans the files under that directory name (a hand-written user exit) and computes each one's
* {@link LocMetrics} with the <em>same</em> per-language line counter the rest of ingest uses, keyed
* by uppercased module stem.
*
* <p>A generated module already contains its user-exit twin, so at ingest a generated module whose
* name has an entry here is annotated with that twin's LoC/SLoC ({@code userExitLoc}/{@code
* userExitSloc}), letting {@code /loc} report total / user-exit / generated-exclusive. User-exit files
* are otherwise <em>not</em> ingested as standalone modules (they would collide by name with the
* generated twin), which the ingest walk enforces by excluding {@code userExitDir}.
*/
final class UserExitMetrics {
private static final Logger LOG = Logger.getLogger(UserExitMetrics.class);
private UserExitMetrics() {
}
/**
* @return module stem (upper-case) → the user-exit file's LoC/SLoC, for every ingestible file
* under a {@code userExitDir} path component. Empty when {@code userExitDir} is {@code null} or the
* root cannot be walked. When two user-exit trees hold the same stem, the first in sorted walk
* order wins (deterministic; narrow the scope via {@code excludeDirs} if undesired).
*/
static Map<String, LocMetrics> scan(Path root, @org.jspecify.annotations.Nullable String userExitDir,
List<String> excludeDirs, AstIngestService astIngestService) {
Map<String, LocMetrics> byStem = new HashMap<>();
if (userExitDir == null || userExitDir.isBlank()) {
return byStem;
}
// Honor other excludes (e.g. target) but never the userExitDir itself — a user who also lists it
// in excludeDirs (to keep it out of module ingest the old way) must still have it scanned here.
List<String> otherExcludes = excludeDirs.stream()
.filter(d -> !d.equalsIgnoreCase(userExitDir)).toList();
try (Stream<Path> stream = Files.walk(root)) {
List<Path> files = stream.filter(Files::isRegularFile).sorted().toList();
for (Path file : files) {
if (!hasComponent(root, file, userExitDir) || SourceFiles.isExcluded(root, file, otherExcludes)) {
continue;
}
SourceFiles.Kind kind = SourceFiles.classify(file);
if (kind == null) {
continue;
}
String stem = SourceFiles.stem(file);
if (byStem.containsKey(stem)) {
continue;
}
try {
LocMetrics metrics = astIngestService.count(kind.language(), SourceFiles.read(file));
byStem.put(stem, metrics);
} catch (IOException | RuntimeException e) {
LOG.warnf("Could not count user-exit file '%s': %s", file, e.toString());
}
}
} catch (IOException e) {
LOG.warnf("Could not scan user-exit dir '%s' under '%s': %s", userExitDir, root, e.toString());
}
return byStem;
}
/**
* @return whether any component of {@code file}'s path relative to {@code root} equals
* {@code component} (case-insensitive) — the same matching {@link SourceFiles#isExcluded} uses.
*/
static boolean hasComponent(Path root, Path file, String component) {
for (Path part : root.relativize(file)) {
if (part.toString().equalsIgnoreCase(component)) {
return true;
}
}
return false;
}
/**
* @return whether {@code relativeSourceFile} (a {@code /}-separated relative path) has a path
* component equal to {@code component} (case-insensitive).
*/
static boolean pathHasComponent(String relativeSourceFile, String component) {
for (String part : relativeSourceFile.split("/")) {
if (part.equalsIgnoreCase(component)) {
return true;
}
}
return false;
}
}

View File

@@ -8,8 +8,8 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
* Single source of truth for the server's product name/version — {@code version} is
* {@code agenticcode.version} (a manually-bumped release counter in {@code application.properties},
* deliberately independent of the Maven project version). Shared by the startup log line
* ({@link VersionLogger}), the REST {@code GET /api/version} endpoint, and the MCP {@code version}
* tool/server-info (all reference {@code agenticcode.version} rather than duplicating it).
* ({@link VersionLogger}) and the REST {@code GET /api/version} endpoint (both reference
* {@code agenticcode.version} rather than duplicating it).
*/
@Singleton
public record VersionInfo(String name, String version) {

View File

@@ -1,17 +1,29 @@
# HTTP server port
quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the MCP
# 'version' tool/server-info (referenced below via property expression, not duplicated).
agenticcode.version=11
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode
quarkus.mcp.server.server-info.version=${agenticcode.version}
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=334
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API
mp.openapi.extensions.smallrye.info.version=${agenticcode.version}
mp.openapi.extensions.smallrye.info.description=Agent-optimized REST API for querying the AgenticCode unified AST graph (Natural / Java).
quarkus.smallrye-openapi.path=/q/openapi
quarkus.swagger-ui.always-include=false
# CORS (item 50) — the web UI is served from a separate dev origin (Vite) and, later, its own
# host. Restrict to explicit origins; never ship a wildcard. Extend the list per deployment.
quarkus.http.cors.enabled=true
quarkus.http.cors.origins=http://localhost:5173,http://localhost:4173
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
quarkus.http.cors.headers=accept,content-type
# HTTP access log (INFO) for every API call
quarkus.http.access-log.enabled=true
quarkus.http.access-log.pattern=%{METHOD} %{REQUEST_URL} -> %{RESPONSE_CODE} (%{RESPONSE_TIME}ms)
# %{RESPONSE_TIME} renders as '-' unless the start time is recorded (off by default), which is why
# every access-log line used to end in '(-ms)'. Runtime property, so it needs no test-side repeat.
quarkus.http.record-request-start-time=true
# Number of files whose nodes/edges are persisted per transaction during `ingest all`
# (larger = fewer round trips but bigger transactions)
agenticcode.ingest.batch-size=200
@@ -28,3 +40,18 @@ agenticcode.ingest.batch-size=200
%prod.quarkus.neo4j.uri=${NEO4J_URI:bolt://neo4j:7687}
%prod.quarkus.neo4j.authentication.username=${NEO4J_USER:neo4j}
%prod.quarkus.neo4j.authentication.password=${NEO4J_PASSWORD}
# Item 179 (DIAGNOSTIC): set to false to drop COMMENT nodes and their DOCUMENTS edges just before
# persist. Comments are 45.8 % of the upms node population; this exists to measure what they cost.
# Not a feature — no REST parameter and no CLI flag. Leave true for normal operation.
agenticcode.ingest.comments.enabled=true
# Item 192: the TypeScript Tier-2 sidecar (ac-parser-typescript/sidecar/extract.mjs). A `typescript`
# project's deep pass runs `node --max-old-space-size=<max-heap-mb> extract.mjs` once per npm
# workspace, sequentially (measured ~0.5 GB heap, 3-5 s per workspace on the pur frontend). A bare
# `node` is looked up on PATH; dev mode runs from ac-code-server/, hence the relative script path.
# The image copies node and the sidecar to /usr/local/bin/node and /work/sidecar (Dockerfile.jvm).
agenticcode.typescript.node=node
agenticcode.typescript.sidecar-script=../ac-parser-typescript/sidecar/extract.mjs
agenticcode.typescript.max-heap-mb=1024
agenticcode.typescript.timeout-seconds=600
%prod.agenticcode.typescript.node=/usr/local/bin/node
%prod.agenticcode.typescript.sidecar-script=/work/sidecar/extract.mjs

View File

@@ -0,0 +1,143 @@
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 org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 115: a Java simple name can identify several modules, and the module endpoints must say so
* instead of unioning them.
*
* <p>Measured on a real codebase before the fix: {@code /modules/BrokerHistoryTests/functions} returned
* 627 functions for a class that has 107 — the sum across five same-named {@code @Nested} classes in
* five files. 163 names / 385 modules (~8%) were affected there, production code included.
*
* <p>Both halves are asserted, because either alone is a regression: refusing an ambiguous name is
* useless if {@code ?sourceFile=} cannot then reach the module, and selecting is unsafe if the
* unqualified request keeps answering with a merge.
*/
@QuarkusTest
class AmbiguousModuleIT {
private static final String PROJECT = "item115-ambiguous";
@TempDir
static Path root;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
// Same simple name, different packages -> two distinct real modules, both named "Shared".
write("a/Shared.java", """
package a;
public class Shared {
public void onlyInA() {
}
}
""");
write("b/Shared.java", """
package b;
public class Shared {
public void onlyInB() {
}
public void alsoOnlyInB() {
}
}
""");
write("a/Unique.java", """
package a;
public class Unique {
public void solo() {
}
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
}
private static void write(String relative, String content) {
try {
Path file = root.resolve(relative);
Files.createDirectories(file.getParent());
Files.write(file, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void ambiguousNameIsRefusedWithItsCandidates() {
given().when().get("/api/projects/" + PROJECT + "/modules/Shared/digest")
.then().statusCode(409)
.body("code", equalTo("AMBIGUOUS_NAME"))
.body("details.candidates", hasSize(2))
.body("details.candidates", hasItem("a/Shared.java"))
.body("details.candidates", hasItem("b/Shared.java"));
}
/**
* The refusal has to reach the endpoints that actually produced wrong answers, not just the one
* that happened to be tested first.
*/
@Test
void everyModuleEndpointRefusesAnAmbiguousName() {
for (String path : List.of("digest", "functions", "callees", "db-accesses", "context",
"data-structures", "sql-statements", "workfile-accesses", "call-tree")) {
given().when().get("/api/projects/" + PROJECT + "/modules/Shared/" + path)
.then().statusCode(409)
.body("code", equalTo("AMBIGUOUS_NAME"));
}
}
/**
* The selector must actually select — not merely be accepted. Each candidate has a different
* method count, so a merged answer is distinguishable from a correct one.
*/
@Test
void sourceFileSelectsOneCandidate() {
given().queryParam("sourceFile", "a/Shared.java")
.when().get("/api/projects/" + PROJECT + "/modules/Shared/functions")
.then().statusCode(200)
.body("name", hasItem("onlyInA"))
.body("size()", equalTo(1));
given().queryParam("sourceFile", "b/Shared.java")
.when().get("/api/projects/" + PROJECT + "/modules/Shared/functions")
.then().statusCode(200)
.body("name", hasItem("onlyInB"))
.body("size()", equalTo(2));
}
/**
* An unambiguous name must not need the selector — otherwise the fix would break every existing
* caller, and Natural (whose module names are unique by construction) with it.
*/
@Test
void unambiguousNameStillAnswersWithoutSelector() {
given().when().get("/api/projects/" + PROJECT + "/modules/Unique/functions")
.then().statusCode(200)
.body("name", hasItem("solo"));
}
@Test
void unknownNameStillAnswers404() {
given().when().get("/api/projects/" + PROJECT + "/modules/NoSuchClass/digest")
.then().statusCode(404)
.body("code", equalTo("MODULE_NOT_FOUND"));
}
}

View File

@@ -84,7 +84,7 @@ class AnalysisResourceIT {
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then()
.statusCode(201);
@@ -218,6 +218,63 @@ class AnalysisResourceIT {
.body("sourceFile", everyItem(equalTo("BAREPARM.lda")));
}
@Test
void searchIdentifierIsLeadingSigilInsensitive() {
// The declared name is "#P-CLIENT"; searching without the '#' sigil must still find it.
given()
.queryParam("name", "P-CLIENT").queryParam("type", "VARIABLE")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem("#P-CLIENT"));
// And searching with the sigil still works (both sides are normalised).
given()
.queryParam("name", "#P-CLIENT").queryParam("type", "VARIABLE")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem("#P-CLIENT"));
}
@Test
void functionCallersListsPerformSites() {
// In DYNAIDX the DISPATCH subroutine PERFORMs INIT-TBL — so INIT-TBL's function-caller is DISPATCH.
given()
.when().get("/api/projects/" + PROJECT + "/modules/DYNAIDX/functions/INIT-TBL/callers")
.then()
.statusCode(200)
.body("items.name", hasItem("DISPATCH"))
.body("items.find { it.name == 'DISPATCH' }.edgeKind", equalTo("PERFORM"));
// A subroutine nobody PERFORMs has no function-callers.
given()
.when().get("/api/projects/" + PROJECT + "/modules/DYNAIDX/functions/DISPATCH/callers")
.then()
.statusCode(200)
.body("items", hasSize(0));
}
@Test
void searchIdentifierCanBeScopedToAModule() {
// #P-CLIENT is declared in DF_CALLEE. module= scopes the search to that module's file...
given()
.queryParam("name", "#P-CLIENT").queryParam("module", "DF_CALLEE")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem("#P-CLIENT"))
.body("sourceFile", everyItem(equalTo("DF_CALLEE.nat")));
// ...and scoping to a module that doesn't declare it yields nothing.
given()
.queryParam("name", "#P-CLIENT").queryParam("module", "DF_CALLER")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(0));
}
@Test
void dataflowTracesVariableAcrossCallnatBoundary() {
// Forward: #CLIENT in DF_CALLER flows into #P-CLIENT in DF_CALLEE (position 0).
@@ -239,21 +296,12 @@ class AnalysisResourceIT {
.body("find { it.variable == '#CLIENT' }.depth", equalTo(1));
}
@Test
void javaDataflowTracesArgumentToParameter() {
// placeOrder(quantity) calls register(quantity); quantity flows into register's 'count'.
given()
.when().get("/api/projects/" + PROJECT + "/variables/quantity/flow-forward?module=OrderService")
.then()
.statusCode(200)
.body("find { it.variable == 'count' }.module", equalTo("OrderService"))
.body("find { it.variable == 'count' }.depth", equalTo(1));
given()
.when().get("/api/projects/" + PROJECT + "/variables/count/flow-backward?module=OrderService")
.then()
.statusCode(200)
.body("variable", hasItem("quantity"));
private static String sampleLegacyEntityNodeId() {
return given()
.queryParam("name", "Entity").queryParam("type", "MODULE")
.when().get("/api/projects/" + PROJECT + "/search/annotation")
.then().statusCode(200).extract()
.path("find { it.name == 'com.example.sample.SampleLegacyEntity' }.id");
}
@Test
@@ -271,6 +319,23 @@ class AnalysisResourceIT {
.body("function", hasItem("placeOrder"));
}
@Test
void javaDataflowTracesArgumentToParameter() {
// placeOrder(quantity) calls register(quantity); quantity flows into register's 'count'.
given()
.when().get("/api/projects/" + PROJECT + "/variables/quantity/flow-forward?module=OrderService")
.then()
.statusCode(200)
.body("find { it.variable == 'count' }.module", equalTo("com.example.sample.OrderService"))
.body("find { it.variable == 'count' }.depth", equalTo(1));
given()
.when().get("/api/projects/" + PROJECT + "/variables/count/flow-backward?module=OrderService")
.then()
.statusCode(200)
.body("variable", hasItem("quantity"));
}
@Test
void javaCrossClassCallGraphSpansFiles() {
// OrderController.handle() calls service.placeOrder() (typed field) -> CALLS to OrderService.
@@ -278,9 +343,9 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/callees?scope=external")
.then()
.statusCode(200)
.body("items.name", hasItems("OrderService", "AuditEntry", "AuditLog"))
.body("items.name", hasItems("com.example.sample.OrderService", "AuditEntry", "AuditLog"))
// edgeKind is language-aware: Java method calls vs constructor invocations.
.body("items.find { it.name == 'OrderService' }.edgeKind", equalTo("METHOD_CALL"))
.body("items.find { it.name == 'com.example.sample.OrderService' }.edgeKind", equalTo("METHOD_CALL"))
.body("items.find { it.name == 'AuditLog' }.edgeKind", equalTo("METHOD_CALL"))
.body("items.find { it.name == 'AuditEntry' }.edgeKind", equalTo("CONSTRUCTOR"));
@@ -289,14 +354,14 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules/OrderService/callers")
.then()
.statusCode(200)
.body("items.name", hasItem("OrderController"));
.body("items.name", hasItem("com.example.sample.OrderController"));
// call-tree reaches OrderService transitively from OrderController.
given()
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/call-tree?depth=2")
.then()
.statusCode(200)
.body("items.name", hasItem("OrderService"));
.body("items.name", hasItem("com.example.sample.OrderService"));
}
@Test
@@ -308,56 +373,26 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules/CheckoutService/callees?scope=external")
.then()
.statusCode(200)
.body("items.name", hasItems("PaymentGateway", "CardGateway", "WireGateway"));
.body("items.name", hasItems("com.example.pay.PaymentGateway", "com.example.pay.CardGateway", "com.example.pay.WireGateway"));
// Each implementation now lists CheckoutService among its callers (resolved via the interface).
given()
.when().get("/api/projects/" + PROJECT + "/modules/CardGateway/callers")
.then()
.statusCode(200)
.body("items.name", hasItem("CheckoutService"));
.body("items.name", hasItem("com.example.pay.CheckoutService"));
given()
.when().get("/api/projects/" + PROJECT + "/modules/WireGateway/callers")
.then()
.statusCode(200)
.body("items.name", hasItem("CheckoutService"));
.body("items.name", hasItem("com.example.pay.CheckoutService"));
// call-tree from CheckoutService reaches the implementations transitively.
given()
.when().get("/api/projects/" + PROJECT + "/modules/CheckoutService/call-tree?depth=2")
.then()
.statusCode(200)
.body("items.name", hasItems("CardGateway", "WireGateway"));
}
@Test
void callersSurfaceIncomingInheritanceEdges() {
// "Who extends this class?" — OrderService extends BaseService, so BaseService's callers
// must include OrderService tagged edgeKind=EXTENDS (previously /callers matched CALLS only
// and silently returned no subtypes).
given()
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers")
.then()
.statusCode(200)
.body("items.name", hasItem("OrderService"))
.body("items.find { it.name == 'OrderService' }.edgeKind", equalTo("EXTENDS"));
// "Who implements this interface?" — CardGateway/WireGateway implement PaymentGateway, so
// they appear among PaymentGateway's callers tagged edgeKind=IMPLEMENTS.
given()
.when().get("/api/projects/" + PROJECT + "/modules/PaymentGateway/callers")
.then()
.statusCode(200)
.body("items.name", hasItems("CardGateway", "WireGateway"))
.body("items.find { it.name == 'CardGateway' }.edgeKind", equalTo("IMPLEMENTS"))
.body("items.find { it.name == 'WireGateway' }.edgeKind", equalTo("IMPLEMENTS"));
// scope=internal (PERFORM into own subroutines) must NOT include inheritance edges.
given()
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers?scope=internal")
.then()
.statusCode(200)
.body("items.findAll { it.name == 'OrderService' }", empty());
.body("items.name", hasItems("com.example.pay.CardGateway", "com.example.pay.WireGateway"));
}
@Test
@@ -418,15 +453,33 @@ class AnalysisResourceIT {
}
@Test
void searchAnnotationFindsAnnotatedClassAndField() {
// SampleLegacyEntity is @Entity(...); its 'vid' field is @Id + @Column.
void callersSurfaceIncomingInheritanceEdges() {
// "Who extends this class?" — OrderService extends BaseService, so BaseService's callers
// must include OrderService tagged edgeKind=EXTENDS (previously /callers matched CALLS only
// and silently returned no subtypes).
given()
.queryParam("name", "Entity").queryParam("type", "MODULE")
.when().get("/api/projects/" + PROJECT + "/search/annotation")
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers")
.then()
.statusCode(200)
.body("name", hasItem("SampleLegacyEntity"))
.body("find { it.name == 'SampleLegacyEntity' }.annotations", equalTo("Entity"));
.body("items.name", hasItem("com.example.sample.OrderService"))
.body("items.find { it.name == 'com.example.sample.OrderService' }.edgeKind", equalTo("EXTENDS"));
// "Who implements this interface?" — CardGateway/WireGateway implement PaymentGateway, so
// they appear among PaymentGateway's callers tagged edgeKind=IMPLEMENTS.
given()
.when().get("/api/projects/" + PROJECT + "/modules/PaymentGateway/callers")
.then()
.statusCode(200)
.body("items.name", hasItems("com.example.pay.CardGateway", "com.example.pay.WireGateway"))
.body("items.find { it.name == 'com.example.pay.CardGateway' }.edgeKind", equalTo("IMPLEMENTS"))
.body("items.find { it.name == 'com.example.pay.WireGateway' }.edgeKind", equalTo("IMPLEMENTS"));
// scope=internal (PERFORM into own subroutines) must NOT include inheritance edges.
given()
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers?scope=internal")
.then()
.statusCode(200)
.body("items.findAll { it.name == 'com.example.sample.OrderService' }", empty());
}
@Test
@@ -459,12 +512,16 @@ class AnalysisResourceIT {
.body("code", equalTo("INVALID_TYPE"));
}
private static String sampleLegacyEntityNodeId() {
return given()
@Test
void searchAnnotationFindsAnnotatedClassAndField() {
// SampleLegacyEntity is @Entity(...); its 'vid' field is @Id + @Column.
given()
.queryParam("name", "Entity").queryParam("type", "MODULE")
.when().get("/api/projects/" + PROJECT + "/search/annotation")
.then().statusCode(200).extract()
.path("find { it.name == 'SampleLegacyEntity' }.id");
.then()
.statusCode(200)
.body("name", hasItem("com.example.sample.SampleLegacyEntity"))
.body("find { it.name == 'com.example.sample.SampleLegacyEntity' }.annotations", equalTo("Entity"));
}
@Test
@@ -474,7 +531,7 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/nodes/{id}", id)
.then()
.statusCode(200)
.body("name", equalTo("SampleLegacyEntity"))
.body("name", equalTo("com.example.sample.SampleLegacyEntity"))
.body("type", equalTo("MODULE"))
.body("annotations", equalTo("Entity"));
}
@@ -511,8 +568,25 @@ class AnalysisResourceIT {
}
@Test
void moduleSourceRequiresLineRange() {
void moduleSourceWithoutRangeReturnsWholeFile() {
// M1: omitting both bounds returns the entire file, with true 1..lineCount bounds (no sentinel).
given()
.when().get("/api/projects/" + PROJECT + "/modules/SampleLegacyEntity/source")
.then()
.statusCode(200)
.body("sourceFile", equalTo("SampleLegacyEntity.java"))
.body("startLine", equalTo(1))
.body("endLine", greaterThan(1))
.body("lines.size()", greaterThan(1))
// spans from the package/class declaration through the file
.body("lines", hasItem(containsString("class SampleLegacyEntity")));
}
@Test
void moduleSourceWithHalfOpenRangeIsRejected() {
// Exactly one bound is ambiguous — must 400 (whole file requires neither, a range requires both).
given()
.queryParam("startLine", 5)
.when().get("/api/projects/" + PROJECT + "/modules/SampleLegacyEntity/source")
.then()
.statusCode(400)
@@ -604,7 +678,7 @@ class AnalysisResourceIT {
String p2 = PROJECT + "-fast";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + p2)
.then().statusCode(201);
given().when().post("/api/projects/" + p2 + "/refresh").then().statusCode(200);
@@ -643,7 +717,7 @@ class AnalysisResourceIT {
String pw = PROJECT + "-warm";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + pw)
.then().statusCode(201);
given().when().post("/api/projects/" + pw + "/refresh").then().statusCode(200);
@@ -678,7 +752,7 @@ class AnalysisResourceIT {
String pf = PROJECT + "-flowpath";
given()
.contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + pf)
.then().statusCode(201);
given().when().post("/api/projects/" + pf + "/refresh").then().statusCode(200);
@@ -771,6 +845,82 @@ class AnalysisResourceIT {
.body("items.size()", equalTo(0));
}
@Test
void egoGraphOutReturnsModuleNeighbourhoodWithEdges() {
// Item 49: the ego graph is module-granularity. OrderController calls OrderService (a real
// module) — both must appear as nodes, the root at distance 0, with the CALLS edge between them.
given()
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/graph?direction=out&depth=1")
.then()
.statusCode(200)
.body("root", equalTo("com.example.sample.OrderController"))
.body("direction", equalTo("out"))
.body("nodes.name", hasItems("com.example.sample.OrderController", "com.example.sample.OrderService"))
.body("nodes.find { it.name == 'com.example.sample.OrderController' }.distance", equalTo(0))
.body("nodes.find { it.name == 'com.example.sample.OrderService' }.distance", equalTo(1))
.body("edges.find { it.from == 'com.example.sample.OrderController' && it.to == 'com.example.sample.OrderService' }.edgeKind",
equalTo("METHOD_CALL"))
// module-granularity: internal subroutines/methods are not nodes.
.body("nodes.name", not(hasItem("placeOrder")));
}
@Test
void egoGraphInReturnsCallers() {
// direction=in is the reverse: OrderService's neighbourhood contains its caller OrderController,
// with the edge still directed caller -> callee.
given()
.when().get("/api/projects/" + PROJECT + "/modules/OrderService/graph?direction=in&depth=1")
.then()
.statusCode(200)
.body("nodes.name", hasItems("com.example.sample.OrderService", "com.example.sample.OrderController"))
.body("edges.find { it.to == 'com.example.sample.OrderService' }.from", equalTo("com.example.sample.OrderController"));
}
@Test
void egoGraphMarksUnresolvedTargets() {
// YADDRBN0_SAMPLE CALLNATs CDRANGE, whose source file is not under the root, so it stays an
// unresolved placeholder (empty sourceFile, unresolved=true) — surfaced for dispatch styling.
given()
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/graph?direction=out&depth=1")
.then()
.statusCode(200)
.body("nodes.name", hasItem("CDRANGE"))
.body("nodes.find { it.name == 'CDRANGE' }.unresolved", equalTo(true))
.body("nodes.find { it.name == 'CDRANGE' }.sourceFile", equalTo(""));
}
@Test
void egoGraphLimitCapsNodesAndReportsTruncation() {
// limit=1 admits only the root; any neighbour is dropped and truncated flips to true.
given()
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/graph?direction=out&depth=2&limit=1")
.then()
.statusCode(200)
.body("nodes.size()", equalTo(1))
.body("nodes[0].name", equalTo("com.example.sample.OrderController"))
.body("truncated", equalTo(true));
}
@Test
void egoGraphUnknownProjectReturns404() {
given()
.when().get("/api/projects/no-such-project/modules/OrderController/graph")
.then()
.statusCode(404)
.body("code", equalTo("PROJECT_NOT_FOUND"));
}
@Test
void egoGraphModuleListCarriesIngestStatus() {
// Item 50: the modules list surfaces ingestStatus so the UI can badge rows without a per-module
// round trip. After a deep whole-root refresh, real modules are INGESTED.
given()
.when().get("/api/projects/" + PROJECT + "/modules?sourceFile=OrderController.java")
.then()
.statusCode(200)
.body("find { it.name == 'com.example.sample.OrderController' }.ingestStatus", equalTo("INGESTED"));
}
@Test
void naturalCallTreeIsQueryable() {
given()
@@ -783,6 +933,14 @@ class AnalysisResourceIT {
.body("items.find { it.name == 'CDRANGE' }.edgeKind", equalTo("CALLNAT"));
}
/**
* Item 67: {@code depth} counts module hops, so this test's expectations changed on purpose.
*
* <p>{@code INITIALIZATIONS} and {@code R-ADDRESS_SP} are both {@code DEFINE SUBROUTINE}s of
* {@code YADDRBN0_SAMPLE.nat} itself; {@code R-ADDRESS_SP} merely happens to be {@code PERFORM}ed
* from inside {@code INITIALIZATIONS}. Neither crosses a module boundary, so both are depth 0 —
* where the raw-hop count called them 1 and 2. Only {@code CALLNAT 'CDRANGE'} leaves the module.
*/
@Test
void naturalCallTreeIsTransitive() {
given()
@@ -790,15 +948,17 @@ class AnalysisResourceIT {
.then()
.statusCode(200)
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE", "R-ADDRESS_SP"))
.body("items.find { it.name == 'INITIALIZATIONS' }.depth", equalTo(1))
.body("items.find { it.name == 'R-ADDRESS_SP' }.depth", equalTo(2));
.body("items.find { it.name == 'INITIALIZATIONS' }.depth", equalTo(0))
.body("items.find { it.name == 'R-ADDRESS_SP' }.depth", equalTo(0))
.body("items.find { it.name == 'CDRANGE' }.depth", equalTo(1));
given()
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/call-tree?depth=1")
.then()
.statusCode(200)
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE"))
.body("items.name", not(hasItem("R-ADDRESS_SP")));
// R-ADDRESS_SP used to be absent here — asking for depth=1 hid a subroutine of the very
// module being asked about, because it sat two raw CALLS edges away.
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE", "R-ADDRESS_SP"));
}
@Test
@@ -1159,6 +1319,39 @@ class AnalysisResourceIT {
.body("find { it.function == 'R-ADDRESS_SP' }.sourceFile", equalTo("YADDRBN0_SAMPLE.nat"));
}
/**
* Item 109: a write reports what was written, not only where. Without it, answering "which values
* does this module put into field X" — the dispatcher question — meant opening the file at the line
* numbers this very endpoint had just returned.
*
* <p>The value is the right-hand side as written, not a normalized literal: {@code #MAX-ATTEMPTS}
* here, {@code 'WREQUD0S'} for a literal, {@code *PROGRAM} for a system variable. Filtering to
* literals would drop the majority of real write sites.
*/
@Test
void variableWritesReportTheAssignedValue() {
given()
.pathParam("name", "#COUNTER")
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes?module=YADDRBN0_SAMPLE")
.then()
.statusCode(200)
// MOVE #MAX-ATTEMPTS TO #COUNTER
.body("assignedValue", hasItem("#MAX-ATTEMPTS"));
}
/**
* A read has no assigned value — the field must be null there, not absent or borrowed.
*/
@Test
void variableReadsCarryNoAssignedValue() {
given()
.pathParam("name", "#MAX-ATTEMPTS")
.when().get("/api/projects/" + PROJECT + "/variables/{name}/reads")
.then()
.statusCode(200)
.body("assignedValue", everyItem(nullValue()));
}
@Test
void variableReadsCanBeScopedToAModuleAndItsCallTree() {
given()
@@ -1231,7 +1424,7 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules")
.then()
.statusCode(200)
.body("name", hasItems("SampleRequest", "YADDRBN0_SAMPLE"))
.body("name", hasItems("com.example.sample.SampleRequest", "YADDRBN0_SAMPLE"))
.body("find { it.name == 'YADDRBN0_SAMPLE' }.sourceFile", equalTo("YADDRBN0_SAMPLE.nat"));
}
@@ -1243,7 +1436,7 @@ class AnalysisResourceIT {
.then()
.statusCode(200)
.body("name", hasItem("YADDRBN0_SAMPLE"))
.body("name", not(hasItem("SampleRequest")));
.body("name", not(hasItem("com.example.sample.SampleRequest")));
given()
.queryParam("sourceFile", "NO_SUCH_FILE.nat")
@@ -1260,8 +1453,8 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules")
.then()
.statusCode(200)
.body("find { it.name == 'PaymentGateway' }.moduleKind", equalTo("INTERFACE"))
.body("find { it.name == 'OrderService' }.moduleKind", equalTo("CLASS"));
.body("find { it.name == 'com.example.pay.PaymentGateway' }.moduleKind", equalTo("INTERFACE"))
.body("find { it.name == 'com.example.sample.OrderService' }.moduleKind", equalTo("CLASS"));
}
@Test
@@ -1283,8 +1476,8 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules")
.then()
.statusCode(200)
.body("name", hasItem("PaymentGateway"))
.body("name", not(hasItem("OrderService")));
.body("name", hasItem("com.example.pay.PaymentGateway"))
.body("name", not(hasItem("com.example.sample.OrderService")));
}
@Test

View File

@@ -0,0 +1,52 @@
package com.agenticcode.codeserver.api;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Item 136: an unexpected failure must still leave the server as structured JSON, and a deliberate
* one must pass through untouched.
*
* <p>The pass-through half is the one worth testing hardest: {@code Throwable} is the least specific
* exception type there is, so without it this mapper would convert every {@code 404}/{@code 400} the
* API answers today into a {@code 500}.
*/
class ApiExceptionMapperTest {
private final ApiExceptionMapper mapper = new ApiExceptionMapper();
@Test
void anUnexpectedFailureBecomesAStructuredInternalError() {
Response response = mapper.toResponse(new IllegalStateException("boom"));
assertEquals(500, response.getStatus());
ErrorResponse body = (ErrorResponse) response.getEntity();
assertEquals("INTERNAL_ERROR", body.code());
assertTrue(body.details().containsKey("errorId"), "the correlation id belongs in details");
assertFalse(body.error().contains("boom"),
"the exception message may name internals and must not be echoed to the client");
}
@Test
void aDeliberateNotFoundIsHandedBackUnchanged() {
Response built = ProjectResource.error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND", "no such project");
Response response = mapper.toResponse(new WebApplicationException(built));
assertEquals(404, response.getStatus());
assertEquals("PROJECT_NOT_FOUND", ((ErrorResponse) response.getEntity()).code());
}
/**
* The runtime's own routing failures are {@code WebApplicationException}s too and must keep their
* status — an unknown path stays a 404, not a 500.
*/
@Test
void aRoutingFailureKeepsItsStatus() {
assertEquals(404, mapper.toResponse(new NotFoundException()).getStatus());
}
}

View File

@@ -0,0 +1,88 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import jakarta.inject.Inject;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 43 (change-invalidation): a module that is already deeply ({@code FULL}) ingested but whose
* source file changed on disk is auto-invalidated and re-ingested by the next field-level query — no
* manual {@code refresh} needed. Contrast with {@link StaleSourceIT}, where a manual {@code refresh}
* is what clears the staleness.
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class AutoInvalidationIT {
private static final String PROJECT = "item43-autoinvalidate";
private static final String MODULE = "AUTOINV";
@TempDir
static Path root;
@Inject
GraphRepository graphRepository;
private static void write(String content) {
try {
Files.writeString(root.resolve(MODULE + ".nat"), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
@Order(1)
void deepIngestMakesModuleFull() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("DEFINE DATA\nLOCAL\n1 #X (A4)\nEND-DEFINE\n*\nEND\n");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
// Deep-ingest the module so it is FULL (the case item 43 must invalidate).
given().when().post("/api/projects/" + PROJECT + "/refresh/" + MODULE).then().statusCode(200);
assertTrue(graphRepository.moduleIngestState(PROJECT, MODULE).await().indefinitely().isFull(),
"module should be FULL after a per-module refresh");
}
@Test
@Order(2)
void editedFullModuleIsStaleUntilQueried() {
// Edit the file on disk without a manual refresh: the stored hash no longer matches.
write("DEFINE DATA\nLOCAL\n1 #X (A4)\n1 #Y (A4)\nEND-DEFINE\n*\nEND\n");
given().when().get("/api/projects/" + PROJECT + "/modules/" + MODULE + "/source?startLine=1&endLine=2")
.then().statusCode(409)
.body("code", equalTo("STALE_SOURCE"));
}
@Test
@Order(3)
void fieldLevelQueryAutoReingestsTheChangedModule() {
// A field-level query routed through DeepIngestCoordinator.ensureDeep detects the changed hash,
// invalidates the FULL module and re-ingests it (re-stamping the hash) — without a manual refresh.
given().when()
.get("/api/projects/" + PROJECT + "/variables/X/flow-forward?module=" + MODULE)
.then().statusCode(200);
// The auto re-ingest re-hashed the file, so the source read no longer reports STALE_SOURCE.
given().when().get("/api/projects/" + PROJECT + "/modules/" + MODULE + "/source?startLine=1&endLine=2")
.then().statusCode(200);
}
}

View File

@@ -0,0 +1,133 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 77: bare-field resolution must reason about <em>one module's</em> includes.
*
* <p>{@code resolveBareIncludedFieldTargets} promises to redirect a bare reference onto a real field
* "only when exactly one field of that name is reachable through <em>the module's</em> resolved
* {@code INCLUDES}". It did not: an unresolved bare field is a single node per {@code (name, project)}
* (item 76 — {@code MERGE_NODES} keys on {@code sourceFile}, which is {@code ""} here), so the query
* matched it once per owning module, then aggregated with {@code WITH ph, collect(...)} — dropping
* {@code m} — and left {@code src} bound to nothing. The uniqueness test ran across all owners at once,
* and every owner's edges were redirected onto whatever single field the others agreed on.
*
* <p>Both fixtures below are built so the defect shows up as a <b>fabricated dataflow</b>: {@code
* field-flow} pairs a producer with a consumer only when both touch the <em>same</em> node, so wrongly
* sharing a placeholder makes it claim a field travels between two modules that share nothing but a name.
* That is the dangerous direction — an agent reading the dossier sees a data dependency that does not
* exist. Measured in {@code upms} before the fix: 38 placeholders where owners disagreed, and 28 (199
* module-field pairs) where a module was credited with a data area it does not include — e.g.
* {@code BMTABBP0} writing {@code ##MSG-NR} of {@code CDPDA-M.pda}.
*
* <p>Note {@code variables/{name}/reads|writes} cannot see any of this: it matches every node with the
* name and reports only the accessing side, so it collapses all targets into one answer either way.
*/
@QuarkusTest
class BareFieldModuleScopeIT {
private static final String PROJECT = "nat-bare-field-scope";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String f : List.of("BARESH1.lda", "BARESH2.lda", "BAREONE.lda",
"BAREWRIT.nat", "BAREREAD.nat", "BAREHAS.nat", "BARELACK.nat")) {
copyFixture("fixtures/natural/barefield/" + f);
}
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then().statusCode(200);
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = BareFieldModuleScopeIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static JsonPath fieldFlow(String field) {
return given().pathParam("name", field)
.when().get("/api/projects/" + PROJECT + "/variables/{name}/field-flow")
.then().statusCode(200)
.extract().jsonPath();
}
/**
* Two modules include <em>different</em> data areas that happen to declare the same field name.
* They are unrelated fields, so no value flows from one to the other.
*
* <p>Before the fix the two candidates were collected under one shared placeholder, {@code
* size(matches) = 1} failed, and <em>neither</em> module resolved — leaving both pointing at the
* shared node, which is precisely what makes {@code field-flow} pair them up.
*/
@Test
void twoModulesIncludingDifferentAreasWithTheSameFieldNameDoNotShareAField() {
List<String> producers = fieldFlow("SHARED-FLD").getList("producer");
assertTrue(producers.isEmpty(),
"BAREWRIT writes BARESH1.SHARED-FLD and BAREREAD reads BARESH2.SHARED-FLD — different "
+ "data areas, no flow. Reported: " + fieldFlow("SHARED-FLD").getList("$"));
}
/**
* A module whose {@code USING} include is unresolvable ({@code BARENOPE} does not exist) keeps its
* bare field unresolved — it must not be credited with a data area it never includes just because
* another module resolved the same name. This is the {@code BMTABBP0} shape from {@code upms}.
*/
@Test
void aModuleWithoutTheIncludeIsNotGivenAnotherModulesField() {
List<String> producers = fieldFlow("LONE-FLD").getList("producer");
assertTrue(producers.isEmpty(),
"BAREHAS includes BAREONE and resolves; BARELACK's include is missing, so its LONE-FLD "
+ "stays unresolved and the two do not share a node. Reported: "
+ fieldFlow("LONE-FLD").getList("$"));
}
/**
* The fix must not stop resolution working where it always did: a module with an unambiguous include
* still resolves, and does so to <em>its own</em> data area. Without this the two tests above would
* pass for the wrong reason — resolution failing everywhere also yields no flow.
*/
@Test
void anUnambiguousBareFieldStillResolvesToItsOwnDataArea() {
JsonPath body = given().pathParam("name", "BAREONE")
.when().get("/api/projects/" + PROJECT + "/data-structures/{name}/fields")
.then().statusCode(200)
.extract().jsonPath();
assertEquals(List.of("LONE-FLD"), body.getList("name"),
"BAREONE must still expose its field; full response: " + body.getList("$"));
}
}

View File

@@ -0,0 +1,215 @@
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 org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 195: a frontend binding DTO fields through generated {@code Fields} path objects
* ({@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}, a prop-rooted prefix, a table's
* list prefix) and the Java backend owning the DTOs. After a deep refresh {@code bindings} lists every
* site with the field's counterpart in the backend, the reads/writes resolve across files onto the
* generated interface's fields, and {@code data-structures/{dto}/fields} carries the bound counts.
* Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
*/
@QuarkusTest
class BindingsIT {
private static final String BACKEND = "bindings-backend";
private static final String FRONTEND = "bindings-frontend";
@TempDir
static Path backendRoot;
@TempDir
static Path frontendRoot;
@BeforeAll
static void ingestFixtures() {
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = backendRoot.resolve("src/main/java/com/example");
write(pkg, "AgstammUseCase.java", """
package com.example;
public class AgstammUseCase {
private String brokerName;
private Broker broker;
private java.util.List<Broker> brokerList;
}
""");
write(pkg, "Broker.java", """
package com.example;
public class Broker {
private Long vermnr;
private String ebene;
}
""");
write(frontendRoot, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = frontendRoot.resolve("app/src");
write(src.resolve("generated"), "api-interfaces.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
export interface Broker {
vermnr: number;
ebene: string;
}
export interface AgstammUseCase {
brokerName: string;
broker: Broker;
brokerList: Broker[];
}
export class Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) {}
get(): string { return '' }
}
export class BrokerFields<TRoot, TSelf extends Broker> extends Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
vermnr = new Fields<TRoot, never>(this, "vermnr");
ebene = new Fields<TRoot, never>(this, "ebene");
}
export class AgstammUseCaseFields<TRoot, TSelf extends AgstammUseCase> extends Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
brokerName = new Fields<TRoot, never>(this, "brokerName");
broker = new BrokerFields<TRoot, Broker>(this, "broker");
brokerList = (index?: number) => new BrokerFields<TRoot, Broker>(this, "brokerList", index);
}
export const AgstammUseCaseField: AgstammUseCaseFields<AgstammUseCase, never> = new AgstammUseCaseFields<AgstammUseCase, never>();
""");
write(src.resolve("components"), "Smart.tsx", """
import { Fields } from 'generated/api-interfaces'
export function SmartInput<T>(props: { field: Fields<T, unknown> }) { return <input id={props.field.get()} /> }
export function SmartOutput<T>(props: { field: Fields<T, unknown> }) { return <span id={props.field.get()} /> }
export function HealthTable<T>(props: { fieldTermForRowData: Fields<T, unknown>; columns: { field: Fields<T, unknown> }[] }) { return <table /> }
""");
write(src.resolve("components"), "BrokerDialog.tsx", """
import { BrokerFields, AgstammUseCase, Broker } from 'generated/api-interfaces'
import { SmartInput, SmartOutput } from './Smart'
export function BrokerDialog(props: { prefix: BrokerFields<AgstammUseCase, Broker> }) {
return (
<div>
<SmartInput field={props.prefix.ebene} />
<SmartOutput field={props.prefix.vermnr} />
</div>
)
}
""");
write(src.resolve("components"), "AgstammPage.tsx", """
import { AgstammUseCaseField } from 'generated/api-interfaces'
import { SmartInput, SmartOutput, HealthTable } from './Smart'
import { BrokerDialog } from './BrokerDialog'
export function AgstammPage() {
return (
<div>
<SmartInput field={AgstammUseCaseField.broker.ebene} />
<SmartOutput field={AgstammUseCaseField.brokerName} />
<HealthTable fieldTermForRowData={AgstammUseCaseField.brokerList()} columns={[{ field: AgstammUseCaseField.brokerList().vermnr }]} />
<BrokerDialog prefix={AgstammUseCaseField.broker} />
</div>
)
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
List.of(BACKEND)))
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response bindings(String query) {
return given().when().get("/api/projects/" + FRONTEND + "/bindings" + query);
}
@Test
void everyBindingSiteIsListedWithItsBackendCounterpart() {
bindings("").then()
.statusCode(200)
// AgstammPage: ebene (R+W), brokerName (R), brokerList prefix (R), brokerList[].vermnr (R), broker prefix (R)
// BrokerDialog: ebene (R+W, partial), vermnr (R, partial)
.body("size()", equalTo(9))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.dto", equalTo("Broker"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("broker.ebene"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.rootDto", equalTo("AgstammUseCase"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.component", equalTo("SmartInput"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.kind", equalTo("field"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(false))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.lineNo", equalTo(8))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartProject", equalTo(BACKEND))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartField", equalTo("ebene"))
.body("find { it.field == 'brokerName' }.mode", equalTo("READS"))
.body("find { it.field == 'brokerName' }.component", equalTo("SmartOutput"))
.body("find { it.field == 'brokerList' }.kind", equalTo("prefix"))
.body("find { it.field == 'brokerList' }.path", equalTo("brokerList[]"))
.body("find { it.field == 'brokerList' }.attribute", equalTo("fieldTermForRowData"))
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.path", equalTo("brokerList[].vermnr"))
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.component", equalTo("HealthTable"))
.body("find { it.field == 'broker' }.kind", equalTo("prefix"))
.body("find { it.field == 'broker' }.dto", equalTo("AgstammUseCase"))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(true))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("ebene"))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' }.rootDto", equalTo("AgstammUseCase"));
}
@Test
void filtersAnswerTheBoundaryQuestion() {
// which page edits Java Broker.ebene?
bindings("?dto=Broker&field=ebene&mode=writes").then().statusCode(200)
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"))
.body("counterpartModule", everyItem(equalTo("com.example.Broker")));
bindings("?dto=Broker&field=ebene&mode=writes&partial=false").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].function", equalTo("AgstammPage"));
bindings("?module=BrokerDialog&countOnly=true").then().statusCode(200).body("count", equalTo(3));
bindings("?mode=bogus").then().statusCode(400).body("code", equalTo("MODE_UNSUPPORTED"));
bindings("?dto=Nope").then().statusCode(200).body("size()", equalTo(0));
}
@Test
void dtoFieldsCarryBoundCountsAndNoPlaceholderSurvives() {
given().when().get("/api/projects/" + FRONTEND + "/data-structures/Broker/fields").then()
.statusCode(200)
.body("find { it.name == 'ebene' }.type", equalTo("FIELD"))
.body("find { it.name == 'ebene' }.boundReads", equalTo(2))
.body("find { it.name == 'ebene' }.boundWrites", equalTo(2))
.body("find { it.name == 'vermnr' }.boundReads", equalTo(2))
.body("find { it.name == 'vermnr' }.boundWrites", equalTo(0));
given().when().get("/api/projects/" + FRONTEND + "/search/identifier?name=Broker.ebene").then().statusCode(200)
.body("findAll { it.sourceFile == '' }.size()", equalTo(0));
// the generic variable endpoint sees the binding writes too
given().when().get("/api/projects/" + FRONTEND + "/variables/Broker.ebene/writes").then().statusCode(200)
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"));
}
}

View File

@@ -0,0 +1,83 @@
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 org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
/**
* Item 181: {@code callees}/{@code callers} page at 50 by default. A module calling 60 others used to
* get 50 rows and nothing saying so; now the headers and the body's {@code total}/{@code truncated}
* tell a cut fan-out from a complete one.
*/
@QuarkusTest
class CallRefTruncationIT {
private static final String PROJECT = "item181-truncation";
private static final int FANOUT = 60;
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
StringBuilder hub = new StringBuilder("DEFINE DATA\n LOCAL\n 01 #A (A8)\nEND-DEFINE\n");
for (int i = 0; i < FANOUT; i++) {
String callee = "CALLEE%02d".formatted(i);
hub.append("CALLNAT '").append(callee).append("' #A\n");
write(callee + ".nat", "DEFINE DATA\n PARAMETER\n 01 #P (A8)\nEND-DEFINE\nEND\n");
}
hub.append("END\n");
write("HUB.nat", hub.toString());
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static void write(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void theDefaultPageSaysItIsCut() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("true"))
.body("items.size()", equalTo(50))
.body("total", equalTo(FANOUT))
.body("truncated", equalTo(true));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?fields=name").then().statusCode(200)
.header("X-AC-Truncated", equalTo("true"));
}
@Test
void aCompletePageSaysItIsComplete() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?limit=1000").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(FANOUT))
.body("truncated", equalTo(false));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?offset=50").then().statusCode(200)
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(10));
given().when().get("/api/projects/" + PROJECT + "/modules/CALLEE07/callers").then().statusCode(200)
.header("X-AC-Total-Count", equalTo("1"))
.header("X-AC-Truncated", equalTo("false"));
}
}

View File

@@ -0,0 +1,124 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 67: when the call-tree traversal stops at its raw-hop budget, the response must say so.
*
* <p>{@code depth} counts module hops, but the traversal still walks raw {@code CALLS} edges, so it
* carries a budget of raw hops per module hop as a safety cap. Whenever a cap can cut a result, the
* question is what the caller is told: items 65 and 68 were bugs precisely because a bounded traversal
* returned a short list that read as a complete answer ("no DB access", "nothing consumes this field").
* A cap that announces itself is a documented limit; a silent one is the same bug again.
*
* <p>The budget is driven down to 2 rather than nesting the fixture absurdly deep, because real Natural
* nesting does not reach the default of 20 (measured on {@code upms}: at most 9 internal hops for a
* single module hop). {@code DEPTHLEAF} sits 5 raw edges behind {@code DEEPCHAIN}'s {@code PERFORM} chain.
*
* <p><b>Item 94 changed what the budget can cut.</b> The budget used to bound the whole traversal, so an
* internal {@code PERFORM} chain longer than it also hid the <em>module</em> at the end of that chain —
* {@code DEPTHLEAF} was dropped although it is one module hop away, and {@code db-accesses?depth=1},
* which has always taken its module set from the same BFS, reported it. The two endpoints contradicted
* each other. Module rows now come from that BFS and are exact, so the budget bounds only the
* intra-module subroutine walk. What gets cut here is therefore {@code S3}/{@code S4}, not
* {@code DEPTHLEAF} — and that cut still has to announce itself, which is the point the test protects.
*/
@QuarkusTest
@TestProfile(CallTreeTruncationIT.TinyBudgetProfile.class)
class CallTreeTruncationIT {
private static final String PROJECT = "nat-call-tree-truncation";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
copyFixture("fixtures/natural/moduledepth/DEEPCHAIN.nat");
copyFixture("fixtures/natural/moduledepth/DEPTHLEAF.nat");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then().statusCode(200);
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = CallTreeTruncationIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void aTraversalThatHitsItsBudgetSaysSoInsteadOfReturningAShortList() {
JsonPath body = given().pathParam("name", "DEEPCHAIN")
.queryParam("depth", 1)
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree")
.then().statusCode(200)
.extract().jsonPath();
// The near end of the chain is still reported, so the response is a plausible-looking partial
// answer rather than an obvious failure — which is exactly why it has to flag itself.
assertEquals(0, body.getInt("items.find { it.name == 'S1' }.depth"),
"a subroutine of the root module crosses no module boundary");
body.setRootPath("");
assertTrue(body.getBoolean("truncated"),
"the intra-module walk stopped at its budget, so the caller must be told the list may be "
+ "incomplete. Full response was: " + body.getList("items.name"));
org.hamcrest.MatcherAssert.assertThat(
"S3/S4 sit past the budget of 2 — the subroutine chain is what got cut",
body.getList("items.name"), not(hasItem("S3")));
org.hamcrest.MatcherAssert.assertThat(
body.getList("items.name"), not(hasItem("S4")));
}
/**
* Item 94: the budget bounds the intra-module walk only. A module one hop away stays in the tree
* however deep inside the caller its call site sits — otherwise {@code call-tree} would keep
* disagreeing with {@code db-accesses}/{@code sql-statements}, which derive their module set from the
* same BFS and have always reported it.
*/
@Test
void aModuleOneHopAwayIsReportedEvenWhenItsCallSiteSitsPastTheBudget() {
given().pathParam("name", "DEEPCHAIN")
.queryParam("depth", 1)
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree")
.then().statusCode(200)
.body("items.find { it.name == 'DEPTHLEAF' }.depth", org.hamcrest.Matchers.is(1));
}
public static class TinyBudgetProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of("agenticcode.call-tree.internal-budget", "2");
}
}
}

View File

@@ -0,0 +1,129 @@
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 org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Audit Bug A — the module-granularity default (external) {@code /callers} view must roll every caller
* up to its owning {@code MODULE}, exactly as {@code /callees} anchors its source side.
*
* <p>The pre-existing {@link ModuleCallersSelfLoopIT} only exercises a caller that {@code CALLNAT}s from
* its <em>main body</em> — there the {@code CALLS} edge already originates at the {@code MODULE} node, so
* the buggy query happened to return a module. This test exercises the case that actually leaked: a
* caller that invokes from <em>inside a subroutine</em>. Before the fix in
* {@code CypherQueries.callers(scope)} the default view returned the calling {@code FUNCTION}
* ({@code DO-CALL}) instead of its module ({@code SUBCALLER}), and emitted one row per call site with no
* dedup. After the fix the caller is the rolled-up {@code MODULE}, once, with both sites aggregated.
*
* <p>Fixtures: {@code SUBCALLER}'s subroutine {@code DO-CALL} does {@code CALLNAT 'TARGETMOD'} twice
* (two sites, one caller); {@code BODYCALLER} does a single main-body {@code CALLNAT 'TARGETMOD'} (the
* roll-up must be identity for it).
*/
@QuarkusTest
class CallersRollupIT {
private static final String PROJECT = "nat-callers-rollup";
/**
* Calls TARGETMOD from inside a subroutine, twice — caller edge originates at the FUNCTION node.
*/
private static final String SUBCALLER = """
* Calls TARGETMOD from inside a subroutine (two sites).
DEFINE DATA
LOCAL
01 #X (A8)
END-DEFINE
*
PERFORM DO-CALL
*
DEFINE SUBROUTINE DO-CALL
CALLNAT 'TARGETMOD'
CALLNAT 'TARGETMOD'
END-SUBROUTINE
*
END
""";
/**
* Single main-body caller: the roll-up must be identity (module returned once).
*/
private static final String BODYCALLER = """
* Calls TARGETMOD from the main body.
DEFINE DATA
LOCAL
01 #Y (A8)
END-DEFINE
*
CALLNAT 'TARGETMOD'
*
END
""";
private static final String TARGETMOD = """
DEFINE DATA
LOCAL
01 #A (A8)
END-DEFINE
*
END
""";
@TempDir
static Path root;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("SUBCALLER.nat", SUBCALLER);
write("BODYCALLER.nat", BODYCALLER);
write("TARGETMOD.nat", TARGETMOD);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
}
private static void write(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void subroutineCallerIsRolledUpToItsModule() {
// Default (external) callers of TARGETMOD: the calling MODULEs, never the calling subroutine.
given().pathParam("name", "TARGETMOD")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callers")
.then().statusCode(200)
.body("items.name", hasItem("SUBCALLER"))
.body("items.name", hasItem("BODYCALLER"))
.body("items.name", not(hasItem("DO-CALL")))
.body("items.type", not(hasItem("FUNCTION")));
}
@Test
void multipleCallSitesFromOneCallerCollapseToOneRowWithAllSites() {
// SUBCALLER calls twice from DO-CALL -> exactly one caller row, two aggregated sites.
given().pathParam("name", "TARGETMOD")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callers")
.then().statusCode(200)
.body("items.findAll { it.name == 'SUBCALLER' }.size()", equalTo(1))
.body("items.find { it.name == 'SUBCALLER' }.sites.size()", equalTo(2));
}
}

View File

@@ -55,7 +55,7 @@ class CoalescingIT {
copyFixture("fixtures/natural/DF_CALLER.nat");
copyFixture("fixtures/natural/DF_CALLEE.nat");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
}
@@ -95,7 +95,7 @@ class CoalescingIT {
assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests");
assertEquals(IngestStatus.INGESTED.name(), state.status());
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", 50, 0)
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, false, 50, 0)
.await().indefinitely().stream()
.filter(m -> !m.sourceFile().isEmpty())
.toList();

View File

@@ -0,0 +1,149 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 141: comment blocks are graph nodes, reachable per module and — on request — through
* {@code /search/value}.
*
* <p>The case this item was filed for: a reengineered Java service records its Natural origin in a
* Javadoc block, and the documented Natural→Java lookup searches the program name in the Java
* project. Before this, comments were stripped at parse time, so that search answered {@code []} —
* read by the consuming project as "not yet reengineered" for a service reengineered months ago.
* That is a wrong answer, not a missing one, which is why the default is still comment-free (a
* comment hit is not the same evidence as a literal in code) but is now <em>reachable</em> and
* labelled {@code kind=COMMENT}.
*/
@QuarkusTest
class CommentIndexIT {
private static final String PROJECT = "comment-index";
@TempDir
static Path root;
private static void write(String name, String content) {
try {
Path path = root.resolve(name);
Files.createDirectories(path.getParent() == null ? root : path.getParent());
Files.writeString(path, content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void commentsAreQueryablePerModuleAndOptInOnValueSearch() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("PartnerController.java", """
package p;
/**
* ServiceEndpoint: partner.update.partnercs.Update
* UpmsObject: PartnerCs, UpmsAdapter: Update
*/
public class PartnerController {
// plain note above the method
public void update() {
}
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().queryParam("deep", true)
.when().post("/api/projects/" + PROJECT + "/refresh").then().statusCode(200);
// 1. The module's comments, each bound to the declaration it documents.
given().when().get("/api/projects/" + PROJECT + "/modules/p.PartnerController/comments")
.then().statusCode(200)
.body("kind", hasItems("JAVADOC", "LINE"))
.body("find { it.kind == 'JAVADOC' }.text", containsString("UpmsObject: PartnerCs"))
.body("find { it.kind == 'JAVADOC' }.target", equalTo("p.PartnerController"))
.body("find { it.kind == 'JAVADOC' }.targetType", equalTo("MODULE"))
.body("find { it.kind == 'LINE' }.target", equalTo("update"))
.body("find { it.kind == 'LINE' }.targetType", equalTo("FUNCTION"));
// 2. kind filter.
given().queryParam("kind", "javadoc")
.when().get("/api/projects/" + PROJECT + "/modules/p.PartnerController/comments")
.then().statusCode(200)
.body("kind", everyItem(equalTo("JAVADOC")));
// 3. The default value search stays comment-free — no existing caller's counts move...
given().queryParam("value", "UpmsObject").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/value")
.then().statusCode(200).body("$", empty());
// ...and the opt-in finds the Javadoc, labelled as a comment rather than as code.
given().queryParam("value", "UpmsObject").queryParam("contains", true)
.queryParam("includeComments", true)
.when().get("/api/projects/" + PROJECT + "/search/value")
.then().statusCode(200)
.body("kind", hasItem("COMMENT"))
.body("find { it.kind == 'COMMENT' }.sourceFile", equalTo("PartnerController.java"));
// 4. A comment node's name is synthetic, so the identifier search can never return prose.
given().queryParam("name", "UpmsObject").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then().statusCode(200).body("$", empty());
given().queryParam("name", "comment@").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then().statusCode(200).body("$", empty());
}
@Test
void naturalBannerAndInlineCommentsAreSeparateBlocks() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
String project = PROJECT + "-nat";
Path natRoot = root.resolve("nat");
try {
Files.createDirectories(natRoot);
Files.writeString(natRoot.resolve("WAGNTX0S.nat"), """
**SAG TITLE: AGENT UPDATE
* #01 09.05.07 VOVBJ03 Bug 266
DEFINE DATA LOCAL
1 #AGENT-NO (N8) /* agent number
END-DEFINE
END
""");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, natRoot.toString(), null, "natural", null, null))
.when().post("/api/projects/" + project)
.then().statusCode(201);
given().queryParam("deep", true)
.when().post("/api/projects/" + project + "/refresh").then().statusCode(200);
// The change-marker banner is findable — "Bug 266" lives only in a comment.
List<String> kinds = given().when().get("/api/projects/" + project + "/modules/WAGNTX0S/comments")
.then().statusCode(200)
.body("find { it.kind == 'NATURAL_BANNER' }.text", containsString("Bug 266"))
.body("find { it.kind == 'NATURAL_INLINE' }.text", equalTo("agent number"))
.extract().jsonPath().getList("kind");
// The **SAG generator directive is machine-written metadata: excluded unless asked for.
org.junit.jupiter.api.Assertions.assertFalse(kinds.contains("SAG"), "SAG directives are opt-in: " + kinds);
given().queryParam("kind", "SAG")
.when().get("/api/projects/" + project + "/modules/WAGNTX0S/comments")
.then().statusCode(200)
.body("kind", everyItem(equalTo("SAG")))
.body("[0].text", containsString("**SAG TITLE"));
}
}

View File

@@ -0,0 +1,242 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Acceptance test for general copycode ({@code .cpy}) expansion (roadmap item 46a): a host module
* ({@code COPYHOST}) whose only DB access and call live inside a copycode member
* ({@code MYTABLECOPY.cpy}) it pulls in with a statement-level {@code INCLUDE}. Without expansion the
* host's {@code db-accesses}/{@code callees} are empty; with it, the copycode's {@code READ MYTABLE}
* and {@code CALLNAT 'COPYSUB'} surface on the host. {@code .cpy} is <em>not</em> an ingestible module
* (see {@code SourceFiles}); it enters the graph only via the copycode library the ingester builds.
*/
@QuarkusTest
class CopycodeExpansionIT {
private static final String PROJECT = "nat-copycode";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
copyFixture("fixtures/natural/copycode/MYTABLECOPY.cpy");
copyFixture("fixtures/natural/copycode/COPYHOST.nat");
copyFixture("fixtures/natural/copycode/MYLDA.lda");
copyFixture("fixtures/natural/copycode/QUALCOPY.cpy");
copyFixture("fixtures/natural/copycode/QUALHOST.nat");
copyFixture("fixtures/natural/copycode/BROWSECPY.cpy");
copyFixture("fixtures/natural/copycode/BROWSEHOST.nat");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then().statusCode(200);
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = CopycodeExpansionIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/** The copycode's READ MYTABLE surfaces as a direct DB access of the including module. */
@Test
void copycodeDbAccessSurfacesOnHost() {
given().pathParam("name", "COPYHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("name", hasItem("MYTABLE"))
.body("findAll { it.name == 'MYTABLE' }.mode", hasItem("READS"));
}
/** The copycode's CALLNAT surfaces as a callee of the including module. */
@Test
void copycodeCallSurfacesOnHost() {
given().pathParam("name", "COPYHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("COPYSUB"));
}
/**
* Item 66: the call site must name the file its line is really in. The {@code CALLNAT} is on line 8
* of {@code MYTABLECOPY.cpy}; line 8 of {@code COPYHOST.nat} is {@code END-DEFINE}. Before item 66 the
* response carried a bare {@code lineNos: [8]} with no way to tell the two apart.
*/
@Test
void copycodeCallSiteNamesTheCopycodeFileNotTheHost() {
JsonPath body = given().pathParam("name", "COPYHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.extract().jsonPath();
Map<String, Object> site = body.getMap("items.find { it.name == 'COPYSUB' }.sites[0]");
assertEquals("MYTABLECOPY", site.get("viaCopycode"));
assertEquals(11, site.get("includedAt"), "the host INCLUDE is on line 11 of COPYHOST.nat");
assertEquals(8, site.get("lineNo"), "the CALLNAT is on line 8 of the copycode");
Object fileIndex = site.get("callSiteFileIndex");
assertNotNull(fileIndex, "a call site must name the file its line is in");
assertEquals("MYTABLECOPY.cpy", body.getList("sourceFiles").get((Integer) fileIndex),
"line 8 belongs to the copycode — line 8 of COPYHOST.nat is END-DEFINE");
}
/**
* Item 66: a copycode-origin variable access must not be reported against the host file.
*
* <p>{@code #KEY} is written from two different files: by {@code MOVE 'Y' TO #KEY} on line 10 of
* {@code COPYHOST.nat}, and by the copycode's two {@code MOVE ... TO &1&} statements on lines 10 and
* 11 of {@code MYTABLECOPY.cpy}. All three used to be reported against {@code COPYHOST.nat} — and
* line 11 of the host is the {@code INCLUDE} itself, not a write. Only the file each line belongs to
* tells them apart.
*/
@Test
void copycodeVariableAccessNamesTheCopycodeFile() {
JsonPath body = given().pathParam("name", "#KEY")
.queryParam("module", "COPYHOST")
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
.then().statusCode(200)
// Assert the copycode write exists before asserting what it says — everyItem() on an
// empty list passes vacuously, which would make this test prove nothing.
.body("findAll { it.viaCopycode == 'MYTABLECOPY' }", not(empty()))
.extract().jsonPath();
assertEquals(List.of("MYTABLECOPY.cpy", "MYTABLECOPY.cpy"),
body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.sourceFile"),
"the MOVEs are written in the copycode, so their lines are the copycode's lines");
// Sorted: the endpoint promises no ordering between the two writes, so asserting one would
// pin down behaviour it does not guarantee.
assertEquals(List.of(10, 11),
body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.lineNo").stream().sorted().toList());
assertEquals(List.of(11, 11), body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.includedAt"),
"and they point back at the INCLUDE that pulled them in");
// The host's own MOVE — same line number, different file, no copycode marker.
assertEquals(List.of("COPYHOST.nat"),
body.getList("findAll { it.viaCopycode == null }.sourceFile"),
"full response was: " + body.getList("$"));
assertEquals(List.of(10), body.getList("findAll { it.viaCopycode == null }.lineNo"));
}
/**
* Item 69: two edges that differ only in the file their line belongs to must both survive.
*
* <p>{@code #KEY} is written on <b>line 10 of two different files</b> — {@code MOVE 'Y' TO #KEY} in
* {@code COPYHOST.nat} and the copycode's {@code MOVE 'Z' TO &1&} in {@code MYTABLECOPY.cpy}. Both
* writes are made by the same module to the same variable, so before item 69 they shared the entire
* edge MERGE key — {@code (source, target, type, lineNo)}, with no file — and the second
* {@code SET r += e.properties} silently overwrote the first: the endpoint returned a single row and
* one real write was gone from the graph.
*
* <p>This is the collision the item-66 fixture was originally shaped to avoid (its copycode write was
* parked on line 11 for exactly this reason). Note that corpus incidence is unmeasurable after the
* fact: the collision destroys the very evidence one would count.
*/
@Test
void twoWritesOnTheSameLineInDifferentFilesBothSurvive() {
JsonPath body = given().pathParam("name", "#KEY")
.queryParam("module", "COPYHOST")
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
.then().statusCode(200)
.extract().jsonPath();
assertEquals(List.of("COPYHOST.nat", "MYTABLECOPY.cpy"),
body.getList("findAll { it.lineNo == 10 }.sourceFile").stream().sorted().toList(),
"line 10 exists in both files and each holds a real write — collapsing them loses one. "
+ "Full response was: " + body.getList("$"));
}
/**
* Items 120/121/123 in one fixture, because in the corpus they occur in one statement.
*
* <p>{@code BROWSEHOST} pulls in a browse copycode whose {@code CALLNAT} target exists only after
* substitution. Three separate defects each broke it on their own:
* <ul>
* <li><b>120</b> — the arguments run onto line 12, and only line 11 was read, so {@code &2&}
* survived substitution verbatim and the call was dropped;</li>
* <li><b>121</b> — {@code '''AGNT-CHG-CMP-SP'''} is <em>one</em> literal, but split into three
* arguments, shifting every later position by one;</li>
* <li><b>123</b> — the target is written {@code '"YAGCHBN0"'}, and the {@code CALLNAT} pattern
* accepted only {@code '} as a string delimiter.</li>
* </ul>
*
* <p>Measured over {@code upms}: fixing 120 alone recovers <b>0</b> edges — its own motivating
* example is a 123 case. All three together recover 2672 copycode-derived call pairs (+49%), 0
* lost. That is why the fixture asserts the call surfaces rather than asserting three mechanisms.
*/
@Test
void aBrowseIncludeWithMultiLineEscapedArgumentsResolvesItsCall() {
JsonPath body = given().pathParam("name", "BROWSEHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("YAGCHBN0"))
// Item 121's phantom: the sort key bound to &2& and became a MODULE node of its own,
// so the endpoint reported a call that does not exist while hiding the one that does.
.body("items.name", not(hasItem("AGNT-CHG-CMP-SP")))
.extract().jsonPath();
Map<String, Object> site = body.getMap("items.find { it.name == 'YAGCHBN0' }.sites[0]");
assertEquals("BROWSECPY", site.get("viaCopycode"));
assertEquals(8, site.get("lineNo"), "the CALLNAT is on line 8 of BROWSECPY.cpy");
assertEquals(11, site.get("includedAt"),
"the INCLUDE is on line 11 of BROWSEHOST.nat — line 12 is its argument continuation");
}
/**
* Item 70: placeholder resolution must not throw the copycode provenance away.
*
* <p>A field reference is resolved in two different ways depending on how it is written. A
* <em>bare</em> one ({@code #W-OPTIONS}) goes through {@code resolveBareIncludedFieldTargets}, which
* copies the whole edge with {@code SET r2 += properties(r)}. A <em>qualified</em> one
* ({@code MYLDA.Q-FIELD}) goes through {@code resolvePlaceholderFieldTargets}, which used to copy
* only {@code value} and {@code lineNo} — silently dropping {@code originFile}/{@code viaCopycode}/
* {@code includedAt}, so item 66's provenance was reconstructed as the host file again.
*
* <p>That asymmetry is why the item-66 work looked complete against {@code upms}: the field it was
* verified on happened to be bare. This fixture takes the qualified path on purpose.
*/
@Test
void qualifiedFieldResolutionKeepsCopycodeProvenance() {
JsonPath body = given().pathParam("name", "Q-FIELD")
.queryParam("module", "QUALHOST")
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
.then().statusCode(200)
// Prove the write survives resolution at all before asserting what it says.
.body("$", not(empty()))
.extract().jsonPath();
assertEquals(List.of("QUALCOPY.cpy"), body.getList("sourceFile"),
"the MOVE is written in the copycode, not in QUALHOST.nat. "
+ "Full response was: " + body.getList("$"));
assertEquals(List.of("QUALCOPY"), body.getList("viaCopycode"));
assertEquals(List.of(3), body.getList("lineNo"), "the MOVE is on line 3 of QUALCOPY.cpy");
assertEquals(List.of(6), body.getList("includedAt"), "the INCLUDE is on line 6 of QUALHOST.nat");
}
}

View File

@@ -0,0 +1,214 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Session;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 75-B: a copycode-resident node belongs to the module that includes it, not to the copycode.
*
* <p>{@code CopycodePreprocessor} splices a {@code .cpy} body into every including module before
* parsing, and the nodes it produces used to be MERGEd on {@code (type, name, sourceFile)} — so all
* includers shared <em>one</em> node. Two consequences, both of which this test pins:
*
* <ul>
* <li><b>{@code CONTAINS} became cyclic (items 75/75-C).</b> A copycode may open a block it does
* not close (the {@code END-FOR} lives in the includer — {@code YFRAMBC0.cpy} in {@code upms}
* does exactly this), so one node collected the nesting context of every expansion. Measured on
* {@code upms}, every cycle pairs a copycode node with statements of a <em>single</em> host that
* includes it repeatedly ({@code JX0031N0.nat} includes {@code YFRAMBC0} 16 times), which is why
* identity has to be per <em>expansion site</em> ({@code <hostFile>#<includePath>}) and not per
* module. {@code HOSTB} in this fixture reproduces that shape with two nested sites.</li>
* <li><b>The stale-edge reaps could not run on it.</b> Items 124/86/106 key on the source node's
* file; with a shared node, reaping during one module's refresh would delete edges the other
* includers contributed and never re-create them. The fixture's second half asserts the
* opposite property now holds: refreshing one host leaves the other host's copies alone.</li>
* </ul>
*/
@QuarkusTest
class CopycodeNodeOwnershipIT {
private static final String PROJECT = "item75b-copycode-ownership";
private static final String CPY = "SHAREDBLK.cpy";
@TempDir
static Path root;
@Inject
Driver driver;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
// Opens a FOR it never closes: the END-FOR is supplied by each including module. This is the
// shape that made the shared node cyclic.
write(CPY, """
FOR #I = 1 TO 10
CALLNAT 'SHAREDSUB' #I
""");
write("HOSTA.nat", """
DEFINE DATA
LOCAL
1 #I (I2)
END-DEFINE
*
INCLUDE SHAREDBLK
END-FOR
*
END
""");
// Item 75-C: the same copycode included TWICE, at different nesting depths. Both expansions
// used to map onto one node (same file, same line), so that node was contained by the IF of
// the second site and contained the IF itself — the CONTAINS 2-cycle of item 75.
write("HOSTB.nat", """
DEFINE DATA
LOCAL
1 #I (I2)
END-DEFINE
*
INCLUDE SHAREDBLK
IF #I = 2
INCLUDE SHAREDBLK
END-FOR
END-IF
END-FOR
*
END
""");
write("SHAREDSUB.nat", """
DEFINE DATA
PARAMETER
1 #I (I2)
END-DEFINE
*
END
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then().statusCode(200);
}
private static void write(String fileName, String content) {
try {
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private long count(String cypher) {
try (Session session = driver.session()) {
return session.run(cypher, Map.of("p", PROJECT)).single().get("c").asLong();
}
}
/**
* Guards against a vacuous pass: if the copycode were not expanded at all, every assertion below
* would hold trivially.
*/
@Test
void theCopycodeIsActuallyExpandedIntoBothHosts() {
List<String> owners;
try (Session session = driver.session()) {
owners = session.run("""
MATCH (n:AstNode {project: $p})
WHERE n.sourceFile ENDS WITH '.cpy'
RETURN DISTINCT n.ownerModule AS owner ORDER BY owner
""", Map.of("p", PROJECT))
.list(r -> r.get("owner").asString());
}
assertEquals(3, owners.size(),
"one expansion site for HOSTA and two for HOSTB, each with its own identity: " + owners);
assertTrue(owners.stream().allMatch(o -> o.startsWith("HOSTA.nat#") || o.startsWith("HOSTB.nat#")),
"an owner is <hostFile>#<includePath>: " + owners);
}
/**
* The point of the item: no node is shared between the two includers.
*/
@Test
void eachIncluderGetsItsOwnCopyOfTheCopycodeNodes() {
long shared = count("""
MATCH (n:AstNode {project: $p})
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule = ''
RETURN count(n) AS c
""");
assertEquals(0, shared, "a copycode-resident node must carry the including module as its owner");
}
/**
* A MODULE declared inside a copycode must stay shared — every module lookup binds
* {@code (project, name, sourceFile)} and never {@code ownerModule}, so an owned MODULE node
* would be invisible to them. Asserted on the whole project because the fixture has no such
* module; the invariant is what matters, and it must hold for every node of that type.
*/
@Test
void moduleAndTableNodesAreNeverOwned() {
long owned = count("""
MATCH (n:AstNode {project: $p})
WHERE n.ownerModule <> '' AND n.type IN ['MODULE', 'DB_TABLE']
RETURN count(n) AS c
""");
assertEquals(0, owned, "MODULE/DB_TABLE nodes must remain shared");
}
/**
* The symptom item 75 is named after. Not vacuous any more: HOSTB includes the copycode at two
* differently-nested sites, which is exactly what makes the shared node contain the block that
* contains it.
*/
@Test
void containsIsAcyclic() {
assertEquals(0, count("""
MATCH (n:AstNode {project: $p})-[:CONTAINS]->(n)
RETURN count(n) AS c
"""), "no node may contain itself");
assertEquals(0, count("""
MATCH (a:AstNode {project: $p})-[:CONTAINS]->(b:AstNode {project: $p})-[:CONTAINS]->(a)
RETURN count(a) AS c
"""), "no two nodes may contain each other");
}
/**
* The reap hazard: {@code DELETE_STALE_FILE_NODES} and the item-86/106/124 edge reaps key on the
* source file, and the copycode file is in every includer's fresh-file set. Keyed on the file
* alone, refreshing HOSTA would delete HOSTB's copies (their {@code ingestGen} is a transaction
* old) together with their edges, and nothing would rebuild them.
*/
@Test
void refreshingOneHostLeavesTheOtherHostsCopiesIntact() {
String cypher = """
MATCH (n:AstNode {project: $p})
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule STARTS WITH 'HOSTB.nat#'
OPTIONAL MATCH (n)-[r]-()
RETURN count(DISTINCT n) + count(r) AS c
""";
long before = count(cypher);
assertTrue(before > 0, "HOSTB must own copycode nodes before the refresh");
given().when().post("/api/projects/" + PROJECT + "/refresh?paths=HOSTA.nat")
.then().statusCode(200);
assertEquals(before, count(cypher), "refreshing HOSTA must not touch HOSTB's copycode nodes or edges");
}
}

View File

@@ -0,0 +1,265 @@
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 org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 193: a TypeScript frontend (generated web-service client + generated DTO interfaces) and the
* Java backend it calls, as two projects. The frontend's calls appear in {@code rest-endpoints} as
* outbound rows, and {@code COUNTERPART_OF} links call → handler, DTO → class, field → field;
* {@code counterparts?unmatched=true} names the calls nothing serves. Needs the Node sidecar
* ({@code node} + {@code ../ac-parser-typescript/sidecar/node_modules/typescript}); skipped otherwise.
*/
@QuarkusTest
class CounterpartsIT {
private static final String BACKEND = "counterparts-backend";
private static final String FRONTEND = "counterparts-frontend";
@TempDir
static Path backendRoot;
@TempDir
static Path frontendRoot;
static boolean sidecarAvailable() {
boolean node = false;
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
if (Files.isExecutable(Path.of(dir, "node"))) {
node = true;
}
}
}
return node && Files.isDirectory(Path.of("../ac-parser-typescript/sidecar/node_modules/typescript"));
}
@BeforeAll
static void ingestFixtures() {
assumeTrue(sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = backendRoot.resolve("src/main/java/com/example");
write(pkg, "AgstammController.java", """
package com.example;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@Path("/agstamm/ui")
public class AgstammController {
@GET
@Path("/{vermnr}")
public AgstammUseCase getBroker(@PathParam("vermnr") Long vermnr) {
return new AgstammUseCase();
}
@GET
@Path("/search")
public AgstammUseCase searchBroker(Long vermnr) {
return new AgstammUseCase();
}
@POST
public AgstammUseCase saveBroker(AgstammUseCase useCase) {
return useCase;
}
}
""");
write(pkg, "AgstammUseCase.java", """
package com.example;
public class AgstammUseCase {
private String brokerName;
private Broker broker;
}
""");
write(pkg, "Broker.java", """
package com.example;
public class Broker {
private Long vermnr;
private String ebene;
}
""");
write(frontendRoot, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = frontendRoot.resolve("app/src");
write(src.resolve("util"), "requestUtil.ts", """
export function buildPurURL(path: string): string { return 'http://backend' + path }
export function executeGetRequest<R>(url: string): Promise<R> { return Promise.reject(url) }
export function executePostRequest<R, B>(url: string, body: B): Promise<R> { return Promise.reject(url) }
export function encodePathParams(v: unknown): string { return String(v) }
""");
write(src.resolve("generated"), "api-interfaces.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
export interface Broker {
vermnr: number;
ebene: string;
}
export interface AgstammUseCase {
brokerName: string;
broker: Broker;
onlyInFrontend?: string;
}
export interface SvcResult<T> {
result?: T;
}
""");
write(src.resolve("generated"), "endpoints.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
import * as COMMON from 'util/requestUtil';
import * as API from './api-interfaces';
interface Endpoint { baseUrl: string; }
interface GetMethod<R> { get: () => Promise<R>; }
interface GetMethodWithParameters<R, P> { get: (params: P) => Promise<R>; }
interface PostMethod<R, B> { post: (body: B) => Promise<R>; }
export class AgstammControllerEndpoint implements Endpoint {
baseUrl: string = '/agstamm/ui/';
public getBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}${COMMON.encodePathParams(params.vermnr)}`)),
};
public searchBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${COMMON.encodePathParams(params.vermnr)}`)),
};
public saveBroker: PostMethod<API.SvcResult<API.AgstammUseCase>, API.AgstammUseCase> = {
post: (body) => COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body),
};
public ping: GetMethod<string> = {
get: () => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}nothing-serves-this`)),
};
}
""");
write(src.resolve("store"), "agstammSlice.ts", """
import { AgstammControllerEndpoint } from 'generated/endpoints'
import { AgstammUseCase } from 'generated/api-interfaces'
const api = new AgstammControllerEndpoint()
export const saveBrokerToServer = (useCase: AgstammUseCase) => api.saveBroker.post(useCase)
export const loadBroker = (vermnr: number) => api.getBroker.get({ vermnr })
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
List.of(BACKEND)))
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response counterparts(String query) {
return given().when().get("/api/projects/" + FRONTEND + "/counterparts" + query);
}
@Test
void theFrontendsCallsAreOutboundRestEndpoints() {
given().when().get("/api/projects/" + FRONTEND + "/rest-endpoints").then()
.statusCode(200)
.body("size()", equalTo(4))
.body("outbound", everyItem(equalTo(true)))
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.httpMethod", equalTo("POST"))
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.path", equalTo("/agstamm/ui"))
.body("find { it.handler == 'AgstammControllerEndpoint.getBroker' }.path", equalTo("/agstamm/ui/{vermnr}"))
.body("find { it.handler == 'AgstammControllerEndpoint.searchBroker' }.path", equalTo("/agstamm/ui/search"));
}
@Test
void callsAreLinkedToTheHandlersServingThem() {
counterparts("?kind=rest").then()
.statusCode(200)
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartName", equalTo("saveBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartModule", equalTo("com.example.AgstammController"))
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartProject", equalTo(BACKEND))
.body("find { it.name == 'AgstammControllerEndpoint.getBroker' }.counterpartName", equalTo("getBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.searchBroker' }.counterpartName", equalTo("searchBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.ping' }.counterpartName", nullValue());
}
@Test
void unmatchedNamesExactlyWhatNothingServes() {
counterparts("?kind=rest&unmatched=true").then()
.statusCode(200)
.body("name", contains("AgstammControllerEndpoint.ping"))
.body("[0].httpMethod", equalTo("GET"))
.body("[0].path", equalTo("/agstamm/ui/nothing-serves-this"));
}
@Test
void generatedDtosAndTheirFieldsAreLinkedByName() {
counterparts("?kind=dto").then()
.statusCode(200)
.body("find { it.name == 'AgstammUseCase' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
.body("find { it.name == 'Broker' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.name == 'SvcResult' }.counterpartName", nullValue());
counterparts("?kind=field&module=api-interfaces").then()
.statusCode(200)
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartName", equalTo("brokerName"))
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
.body("find { it.name == 'Broker.ebene' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.name == 'AgstammUseCase.onlyInFrontend' }.counterpartName", nullValue());
}
@Test
void thePairSurvivesABackendRefresh() {
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
counterparts("?kind=rest&unmatched=true").then()
.statusCode(200)
.body("name", contains("AgstammControllerEndpoint.ping"));
counterparts("?kind=rest&countOnly=true").then()
.statusCode(200)
.header("X-AC-Total-Count", equalTo("4"));
}
@Test
void theSettingIsVisibleAndSelfIsRejected() {
given().when().get("/api/projects/" + FRONTEND).then()
.statusCode(200)
.body("counterparts", contains(BACKEND));
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, null, null, null, null, null, List.of(FRONTEND)))
.when().put("/api/projects/" + FRONTEND).then()
.statusCode(400)
.body("code", equalTo("COUNTERPART_SELF"));
counterparts("?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
}
}

View File

@@ -0,0 +1,192 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.path.json.JsonPath;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
/**
* Characterization test for the 2026-07-28 WGEAGB0S deep-API audit, items 100/101/102 — a
* {@code DEFINE DATA ... USING <member>} names the data-area <b>file</b>, never a level-1 record
* inside it.
*
* <p>The fixtures reproduce both shapes measured on {@code upms}: {@code DAOTHER.pda} declares a
* copy-pasted {@code 1DAMEMBER} record (the {@code W-WIF-A7.pda} / {@code W-WIF-A2} case, which made
* {@code WGEAGB0S} report the wrong file <em>and</em> a field count belonging to a third node), and
* {@code DAMULTI.lda} has several level-1 records and none named after the member (the
* {@code VLAYERLA} / {@code USIX020L} case, whose {@code USING} resolved to nothing at all).
*/
@QuarkusTest
@org.junit.jupiter.api.TestMethodOrder(org.junit.jupiter.api.MethodOrderer.OrderAnnotation.class)
class DataAreaMemberResolutionIT {
private static final String PROJECT = "nat-dataarea";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String fixture : List.of("DAMEMBER.pda", "DAOTHER.pda", "DAMULTI.lda", "DAHOST.nat")) {
copyFixture("fixtures/natural/dataarea/" + fixture);
}
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
.then().statusCode(200);
}
private static void copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = DataAreaMemberResolutionIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + classpathResource);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static JsonPath moduleDataStructures() {
return given().pathParam("name", "DAHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/data-structures")
.then().statusCode(200)
.extract().jsonPath();
}
private static String searchIdentifierId() {
return given().queryParam("name", "DAMULTI").queryParam("type", "DATA_STRUCTURE")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then().statusCode(200)
.extract().jsonPath().getString("find { it.sourceFile == 'DAMULTI.lda' }.id");
}
/**
* Item 100: the USING binds to the file whose member name matches, not to the homonymous record.
*/
@Test
void usingBindsToTheMemberFileNotToAHomonymousLevelOneRecord() {
JsonPath body = moduleDataStructures();
assertEquals("DAMEMBER.pda", body.getString("find { it.name == 'DAMEMBER' }.sourceFile"));
assertEquals(2, body.getInt("find { it.name == 'DAMEMBER' }.fieldCount"));
}
/**
* Item 100: a multi-level-1 area still exposes a member-named root, so its USING resolves.
*/
@Test
void multiTopLevelAreaResolvesInsteadOfReportingUnknown() {
JsonPath body = moduleDataStructures();
assertEquals("DAMULTI.lda", body.getString("find { it.name == 'DAMULTI' }.sourceFile"));
assertEquals("LDA", body.getString("find { it.name == 'DAMULTI' }.area"));
assertTrue(body.getInt("find { it.name == 'DAMULTI' }.fieldCount") >= 5,
"The member root must contain every level-1 record's subtree");
}
/**
* Item 102: one row per definition, never a blend of file from one node and count from another.
*/
@Test
void moduleDataStructuresReportsOneRowPerUsedDefinition() {
List<String> rows = moduleDataStructures().getList("findAll { it.name == 'DAMEMBER' }.sourceFile");
assertEquals(List.of("DAMEMBER.pda"), rows);
}
/**
* Item 101: fields come from one definition and carry the file they were read from.
*/
@Test
void dataStructureFieldsAreNotUnionedAcrossHomonymousDefinitions() {
JsonPath body = given().pathParam("name", "DAMEMBER")
.when().get("/api/projects/" + PROJECT + "/data-structures/{name}/fields")
.then().statusCode(200)
.extract().jsonPath();
List<String> names = body.getList("name");
assertTrue(names.containsAll(List.of("P-LINE-TYPE", "P-LINE-VALUE")));
assertFalse(names.contains("P-DECOY-FLAG"),
"Fields of the homonymous DAOTHER.pda record must not be merged in");
assertEquals(List.of("DAMEMBER.pda"), body.getList("sourceFile").stream().distinct().toList());
}
/**
* Item 105: a fan-out query that surfaces a data-area file must not re-ingest it.
*
* <p>{@code FULLY_INGESTED_SOURCE_FILES} asks for a {@code MODULE} with {@code ingestDepth='FULL'},
* but a {@code .lda}/{@code .pda} yields only {@code DATA_STRUCTURE}s — so it could never be reported
* as fully ingested and was re-warmed on every single call, each warm dragging a whole-project
* finalize behind it. Measured on {@code upms}: {@code search/identifier?name=ZFRAMBL0} (one hit, a
* {@code .lda}) took <b>74.7 s</b>, the same call with no hits <b>0.9 s</b>.
*
* <p>Asserted via node ids, which are regenerated on every re-ingest: a stable id across two calls
* proves the file was not re-ingested. Timing would be too flaky to assert on a small fixture.
*/
@Test
void surfacingADataAreaDoesNotReIngestItOnEveryCall() {
String first = searchIdentifierId();
String second = searchIdentifierId();
assertEquals(first, second,
"A repeated lookup must not re-ingest the data area (ids are regenerated on re-ingest)");
}
/**
* Item 106: a module's {@code USING} set is rebuilt on refresh, not accumulated. A {@code USING}
* edge resolves onto a <em>real</em> data-area node, and before this nothing ever deleted it again —
* so an old binding survived every later refresh and the new one was merely added next to it. That
* is why {@code WGEAGB0S USING W-WIF-A2} still reported both {@code W-WIF-A2.pda} and the pre-fix
* {@code W-WIF-A7.pda} after the item-100 fix had landed and the project had been deep-refreshed.
*
* <p>Kept last: it edits the fixture, so it must not run before the tests above.
*/
@Test
@org.junit.jupiter.api.Order(Integer.MAX_VALUE)
void anEditedUsingDropsTheOldBindingOnRefresh() {
Path host = root.resolve("DAHOST.nat");
try {
String edited = Files.readString(host).replace("PARAMETER USING DAMEMBER", "PARAMETER USING DAOTHER");
Files.writeString(host, edited);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
List<String> used = moduleDataStructures().getList("findAll { it.relationship == 'USING' }.name");
assertTrue(used.contains("DAOTHER"), "The new USING must be reported");
assertFalse(used.contains("DAMEMBER"), "The dropped USING must not survive the refresh");
}
/**
* Item 101: an explicit sourceFile pins the other definition.
*/
@Test
void dataStructureFieldsCanBePinnedToOneSourceFile() {
List<String> names = given().pathParam("name", "DAMEMBER").queryParam("sourceFile", "DAOTHER.pda")
.when().get("/api/projects/" + PROJECT + "/data-structures/{name}/fields")
.then().statusCode(200)
.extract().jsonPath().getList("name");
assertTrue(names.contains("P-DECOY-FLAG"));
assertFalse(names.contains("P-LINE-TYPE"));
}
}

Some files were not shown because too many files have changed in this diff Show More