69 Commits
ui ... main

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
314 changed files with 37299 additions and 4740 deletions

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,7 +6,8 @@
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
@@ -24,10 +25,9 @@
* **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 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.
* **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.
@@ -46,7 +46,8 @@
- 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.
@@ -54,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), **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 MCP/API/CLI** (not just unreachable, but the question is
- **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.
@@ -89,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
@@ -123,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
@@ -236,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
@@ -297,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
`manage-ac.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
./manage-ac.sh deploy
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)
```
`./manage-ac.sh deploy` builds the project, brings the Compose stack up (leaving an already-running
Neo4j untouched), and installs the `ac` CLI launcher. Other subcommands: `./manage-ac.sh stop`
(stop only the server), `./manage-ac.sh logs [-f]`, `./manage-ac.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);
}
@@ -56,6 +58,21 @@ abstract class AbstractApiCommand implements Callable<Integer> {
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.
*/
@@ -64,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

@@ -26,8 +26,11 @@ import java.util.concurrent.Callable;
FunctionCallersCommand.class,
CalleesCommand.class,
CallTreeCommand.class,
ReachesCommand.class,
DuplicatesCommand.class,
EgoGraphCommand.class,
DbAccessesCommand.class,
WorkfileAccessesCommand.class,
SqlStatementsCommand.class,
ContextCommand.class,
FunctionsCommand.class,
@@ -40,11 +43,22 @@ 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,

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,6 +15,9 @@ final class CallTreeCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@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;
@@ -25,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

@@ -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

@@ -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 EgoGraphCommand extends AbstractProjectCommand {
@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;
@@ -33,7 +37,7 @@ final class EgoGraphCommand extends AbstractProjectCommand {
}
path = appendQuery(path, "depth", depth);
path = appendQuery(path, "limit", limit);
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 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

@@ -1,6 +1,7 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Parameters;
/**
@@ -16,6 +17,9 @@ final class FunctionCallersCommand extends AbstractProjectCommand {
@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;
@@ -25,7 +29,7 @@ final class FunctionCallersCommand extends AbstractProjectCommand {
try {
String path = projectPath() + "/modules/" + encode(moduleName)
+ "/functions/" + encode(functionName) + "/callers";
return printResponse(apiClient().get(path));
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

@@ -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,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 ModuleSourceCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "Module name")
String moduleName;
@Mixin
ModuleSelector selector = new ModuleSelector();
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
int startLine = -1;
@@ -29,7 +33,7 @@ final class ModuleSourceCommand extends AbstractProjectCommand {
// half-open range).
path = appendQuery(path, "startLine", startLine);
path = appendQuery(path, "endLine", endLine);
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 PayloadCommand 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) + "/payload"));
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/payload")));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;

View File

@@ -12,17 +12,19 @@ import java.util.Objects;
import java.util.concurrent.Callable;
/**
* Groups project management subcommands: create, update, delete, recreate, list.
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
*/
@Command(
name = "project",
mixinStandardHelpOptions = true,
description = "Create, update, delete, recreate or list projects",
description = "Create, update, delete, recreate, rename, show or list projects",
subcommands = {
ProjectCommand.CreateCommand.class,
ProjectCommand.UpdateCommand.class,
ProjectCommand.DeleteCommand.class,
ProjectCommand.RecreateCommand.class,
ProjectCommand.RenameCommand.class,
ProjectCommand.ShowCommand.class,
ProjectCommand.ListCommand.class
}
)
@@ -58,13 +60,17 @@ final class ProjectCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Option(names = {"-l", "--language"}, required = true,
description = "Project source language (natural/java)")
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 = "";
@@ -74,12 +80,13 @@ final class ProjectCommand implements Callable<Integer> {
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
language, generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir)));
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@Command(name = "update", mixinStandardHelpOptions = true,
description = "Update a project's description, root, exclude-dirs, language and/or generated/user-exit 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")
@@ -96,13 +103,17 @@ 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)")
@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 = "";
@@ -115,7 +126,8 @@ final class ProjectCommand implements Callable<Integer> {
excludeDirs.isEmpty() ? null : excludeDirs,
language.isBlank() ? null : language,
generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir)));
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@@ -169,6 +181,28 @@ final class ProjectCommand implements Callable<Integer> {
}
}
/**
* 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
@@ -193,6 +227,20 @@ final class ProjectCommand implements Callable<Integer> {
}
}
@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")
String name;
@Override
public Integer call() throws Exception {
return printResponse(apiClient().get("/api/projects/" + encode(name)));
}
}
@Command(name = "list", mixinStandardHelpOptions = true, description = "List all projects")
static final class ListCommand extends AbstractApiCommand {

View File

@@ -14,5 +14,6 @@ import java.util.List;
* leave the stored values unchanged.
*/
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
@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

@@ -40,6 +40,22 @@ final class RefreshCommand extends AbstractProjectCommand {
@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()) {
@@ -60,6 +76,15 @@ final class RefreshCommand extends AbstractProjectCommand {
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,18 +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; a leading Natural sigil (# & +) is ignored (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;
@@ -37,7 +46,14 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
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));
} catch (IllegalStateException e) {

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

@@ -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

@@ -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

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# 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=63
version=334

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>
@@ -54,10 +58,6 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-sse</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
@@ -78,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>
@@ -138,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;
@@ -20,6 +21,7 @@ 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.
@@ -37,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;
}
@@ -54,7 +58,13 @@ public class ProjectResource {
.build();
}
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java");
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();
@@ -94,6 +104,23 @@ public class ProjectResource {
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)
@@ -112,8 +139,14 @@ public class ProjectResource {
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()))
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts == null ? List.of() : counterparts)
.map(result -> switch (result) {
case SUCCESS -> {
scanTier1(project);
@@ -161,8 +194,14 @@ public class ProjectResource {
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()))
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",
@@ -178,6 +217,48 @@ public class ProjectResource {
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")
@@ -205,14 +286,33 @@ public class ProjectResource {
*/
public record ProjectRequest(@Nullable String description, @Nullable String root,
@Nullable List<String> excludeDirs, @Nullable String language,
@Nullable String generatedDir, @Nullable String userExitDir) {
@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);
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() {

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

@@ -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,693 +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.nio.file.Path;
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 SourceSearchService sourceSearchService;
private final VersionInfo versionInfo;
private final int defaultCallTreeDepth;
private final int maxCallTreeDepth;
/**
* Item 67: raw hops allowed per module hop. A pure safety cap — the query prunes its traversal to the
* modules within the requested depth, so this only stops a pathological internal chain. Measured on
* upms: a single module hop reached 9 internal hops, and the quantifier costs nothing (identical
* results and runtime at 8, 20 and 40), so it is sized well above the observed maximum. Too tight a
* value silently truncates; exhausting it sets truncated=true on the response.
*/
private final int callTreeInternalBudget;
// -------------------------------------------------------------------------
// Projects & module discovery
// -------------------------------------------------------------------------
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
ProjectRootResolver rootResolver, DeepIngestCoordinator deepIngestCoordinator,
SourceSnippetService sourceSnippetService, SourceSearchService sourceSearchService,
VersionInfo versionInfo,
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth,
@ConfigProperty(name = "agenticcode.call-tree.internal-budget", defaultValue = "20") int callTreeInternalBudget) {
this.graphRepository = graphRepository;
this.support = support;
this.rootResolver = rootResolver;
this.deepIngestCoordinator = deepIngestCoordinator;
this.sourceSnippetService = sourceSnippetService;
this.sourceSearchService = sourceSearchService;
this.versionInfo = versionInfo;
this.defaultCallTreeDepth = defaultCallTreeDepth;
this.maxCallTreeDepth = maxCallTreeDepth;
this.callTreeInternalBudget = callTreeInternalBudget;
}
@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, language, generatedDir, userExitDir).")
@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 = "search_source", description = "Regex search over module SOURCE TEXT (item 54), read from disk. Returns matches as {module, sourceFile, lineNo, line}, with a 'truncated' flag when the match limit is hit. Case-insensitive by default (legacy Natural is case-insensitive). Use this to grep for code patterns (statements, table names, literals) across a project — complementary to search_identifier (which matches declared names).")
@Blocking
public Uni<ToolResponse> searchSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Java regular expression to search for") String regex,
@ToolArg(description = "Max matches to return (default 200)", required = false) @Nullable Integer limit,
@ToolArg(description = "Case-insensitive match (default true)", required = false) @Nullable Boolean ignoreCase) {
if (regex == null || regex.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_REGEX", "'regex' is required"));
}
boolean caseInsensitive = ignoreCase == null || ignoreCase;
java.util.regex.Pattern pattern;
try {
pattern = java.util.regex.Pattern.compile(regex, caseInsensitive ? java.util.regex.Pattern.CASE_INSENSITIVE : 0);
} catch (java.util.regex.PatternSyntaxException e) {
return Uni.createFrom().item(support.error("INVALID_REGEX", "Invalid regex: " + e.getMessage()));
}
int effectiveLimit = limit != null && limit > 0 ? limit : 200;
String root;
switch (rootResolver.resolve(project)) {
case ProjectRootResolver.Resolved r -> root = r.project().root();
case ProjectRootResolver.Failed failed -> {
return Uni.createFrom().item(support.error(failed.code(), failed.message()));
}
}
List<ModuleInfo> modules = graphRepository.listModules(project, null, null, null).await().indefinitely();
return Uni.createFrom().item(support.ok(sourceSearchService.search(root, modules, pattern, effectiveLimit)));
}
@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. For a project with a generated/user_exit split (item 47), each row also carries userExitLoc/userExitSloc and generatedExclusiveLoc/generatedExclusiveSloc (= total − user_exit); zero when unconfigured. 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 = "function_callers", description = "FUNCTION-level callers of a subroutine/method: who PERFORMs (Natural) or calls (Java cross-class) the given 'function' in module 'name', with call-site line numbers. Finer-grained than module-level 'callers'.")
@Blocking
public Uni<ToolResponse> functionCallers(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name that defines the subroutine/method") String name,
@ToolArg(description = "Subroutine/method name") String function) {
return withProject(project, () -> graphRepository.functionCallers(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_payload", description = "XML wire payload contract of a Natural module: the tag/field/direction triples emitted via the ADD-XML-LINE idiom.")
@Blocking
public Uni<ToolResponse> modulePayload(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name) {
return withProject(project, () -> graphRepository.payload(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,
callTreeInternalBudget),
CallTreeResponse::sourceFiles);
}
@Tool(name = "ego_graph", description = "Bounded call-graph neighbourhood (nodes + edges) around one module, at module granularity (item 49). Unlike call_tree it returns the edges too, so a caller can render an interactive sub-graph. 'direction' is out (callees), in (callers), or both; 'depth' is the max call hops (clamped to the configured maximum); 'limit' caps the number of nodes (BFS order) and sets 'truncated' when hit. Unresolved dynamic/cross-file targets appear as nodes with an empty sourceFile and unresolved=true.")
@Blocking
public Uni<ToolResponse> egoGraph(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Max call hops from the module (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
@ToolArg(description = "Traversal direction: out (callees), in (callers), or both (default out)", required = false) @Nullable String direction,
@ToolArg(description = "Cap on number of nodes returned (default 50, BFS order)", required = false) @Nullable Integer limit) {
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
String dir = direction != null ? direction : "out";
int effectiveLimit = effectiveLimit(limit);
return withProject(project, () -> graphRepository.egoGraph(project, name, effectiveDepth, dir, effectiveLimit)
.map(support::ok));
}
@Tool(name = "search_identifier", description = "Find identifiers across a project by exact name and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE). Name match is sigil-insensitive: a leading Natural sigil (# user, & AIV, + GDA) is ignored on both sides, so 'K-OUT-MAX' matches the declared '#K-OUT-MAX'. Optionally scope to one source file or one module (by name) to pinpoint the local declaration when a name recurs across modules.")
@Blocking
public Uni<ToolResponse> searchIdentifier(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Identifier name", required = false) @Nullable String name,
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
@ToolArg(description = "Scope to this exact source file (relative path)", required = false) @Nullable String sourceFile,
@ToolArg(description = "Scope to nodes of this module (by name)", required = false) @Nullable String module,
@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, sourceFile, module, 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 for a module by name (roadmap item 28), read from the project root on disk. Omit both startLine and endLine to get the whole file (M1); or pass both for a line range.")
@Blocking
public Uni<ToolResponse> moduleSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Module name") String name,
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
boolean wholeFile = startLine == null && endLine == null;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
"Supply both startLine and endLine, or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
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 + "'"))
: wholeFile
? sourceSnippetWhole(project, sourceFile)
: sourceSnippet(project, sourceFile, sl, el)));
}
@Tool(name = "file_source", description = "Source text for a project file by its relative path (not a module name), read from the project root on disk. Use for files that aren't standalone modules — e.g. a Natural data area (PDA/LDA) USING'd by a module, whose fields' line numbers refer to that file. Omit both startLine and endLine to get the whole file; or pass both for a line range.")
@Blocking
public Uni<ToolResponse> fileSource(
@ToolArg(description = "Project name") String project,
@ToolArg(description = "Relative source file path (as reported by e.g. module_data_structures.sourceFile)") String file,
@ToolArg(description = "Start line (1-indexed); omit with endLine for the whole file", required = false) @Nullable Integer startLine,
@ToolArg(description = "End line (1-indexed, inclusive); omit with startLine for the whole file", required = false) @Nullable Integer endLine) {
if (file == null || file.isBlank()) {
return Uni.createFrom().item(support.error("MISSING_FILE", "'file' (relative source path) is required"));
}
boolean wholeFile = startLine == null && endLine == null;
if (!wholeFile && (startLine == null || endLine == null)) {
return Uni.createFrom().item(support.error("MISSING_LINE_RANGE",
"Supply both startLine and endLine, or neither (whole file)"));
}
int sl = startLine != null ? startLine : 1;
int el = endLine != null ? endLine : Integer.MAX_VALUE;
return withProject(project, () -> wholeFile ? sourceSnippetWhole(project, file) : sourceSnippet(project, file, sl, el));
}
@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 -> readSnippet(project, sourceFile,
(root, hash) -> sourceSnippetService.read(root, sourceFile, startLine, endLine, hash), expectedHash));
}
/**
* M1: whole-file variant of {@link #sourceSnippet}.
*/
private Uni<ToolResponse> sourceSnippetWhole(String project, String sourceFile) {
return graphRepository.sourceHash(project, sourceFile)
.map(expectedHash -> readSnippet(project, sourceFile,
(root, hash) -> sourceSnippetService.readWholeFile(root, sourceFile, hash), expectedHash));
}
/**
* Resolves the project root and runs {@code reader} against it, mapping root-resolution/IO
* failures to MCP tool errors. When the file on disk no longer matches the stored hash (item 41),
* returns a {@code STALE_SOURCE} error telling the caller to re-ingest, rather than serving
* changed text against stale line numbers.
*/
private ToolResponse readSnippet(String project, String sourceFile, SnippetReader reader,
@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());
}
}
// Defence in depth: file_source takes a client-supplied path, so reject any that escapes root.
Path base = Path.of(root).toAbsolutePath().normalize();
if (!base.resolve(sourceFile).normalize().startsWith(base)) {
return support.error("INVALID_SOURCE_FILE", "Source file '" + sourceFile + "' escapes the project root");
}
try {
return support.ok(reader.read(root, expectedHash));
} 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());
}
}
@FunctionalInterface
private interface SnippetReader {
SourceSnippet read(String root, @Nullable String expectedHash) throws IOException, StaleSourceException;
}
@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;
@@ -16,13 +15,18 @@ 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.Collection;
import java.util.List;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
/**
* Parses a source file with the language-specific parser and persists the resulting
@@ -40,9 +44,53 @@ public class AstIngestService {
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);
}
/**
@@ -73,10 +121,21 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
if (language == SourceFiles.Language.JAVA) {
return javaParser.parse(sourceFile, content);
}
return naturalParser.parse(sourceFile, content, 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);
};
}
/**
@@ -94,10 +153,16 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
if (language == SourceFiles.Language.JAVA) {
return javaScanner.scan(sourceFile, content);
}
return naturalScanner.scan(sourceFile, content, 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);
};
}
/**
@@ -105,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);
}
@@ -123,7 +193,8 @@ public class AstIngestService {
* for a coarse Tier-1 scan.
*/
public Uni<Void> persist(String project, LanguageParser.ParseResult result, boolean reconcile) {
return graphRepository.persist(project, result, reconcile).replaceWithVoid();
return graphRepository.persist(project, commentsEnabled ? result : stripComments(result),
reconcile).replaceWithVoid();
}
/**
@@ -140,7 +211,10 @@ public class AstIngestService {
* coarse Tier-1 scan.
*/
public Uni<Void> persistBatch(String project, List<LanguageParser.ParseResult> results, boolean reconcile) {
return graphRepository.persistBatch(project, results, reconcile).replaceWithVoid();
List<LanguageParser.ParseResult> effective = commentsEnabled
? results
: results.stream().map(this::stripComments).toList();
return graphRepository.persistBatch(project, effective, reconcile).replaceWithVoid();
}
/**
@@ -159,6 +233,30 @@ public class AstIngestService {
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.
@@ -179,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();
}
/**
@@ -199,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

@@ -166,7 +166,7 @@ public class DeepIngestCoordinator {
if (!autoInvalidateEnabled) {
return false;
}
String sourceFile = graphRepository.moduleSourceFile(project, module).await().indefinitely();
String sourceFile = graphRepository.moduleSourceFile(project, module, GraphRepository.ANY_SOURCE_FILE).await().indefinitely();
if (sourceFile == null || sourceFile.isEmpty()) {
return false;
}
@@ -205,6 +205,29 @@ public class DeepIngestCoordinator {
};
}
/**
* 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
@@ -217,11 +240,12 @@ 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> distinct = sourceFiles.stream().distinct().toList();
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) {
@@ -307,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);
@@ -351,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);
@@ -398,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,10 @@ 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;
@@ -42,14 +47,28 @@ public class ProjectIngestService {
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.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);
@@ -184,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.
@@ -204,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)) {
@@ -223,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));
}
}
@@ -263,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
@@ -273,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);
}
/**
@@ -286,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);
}
/**
@@ -296,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);
}
/**
@@ -309,8 +360,46 @@ public class ProjectIngestService {
* wipe.)
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep) throws IOException {
IngestSummary summary = 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;
}
@@ -349,35 +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<String> excludeDirs = ingestExcludeDirs(project);
List<Candidate> candidates = walk(root, excludeDirs);
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, copycodes)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
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()));
}
}
}
@@ -396,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);
}
});
@@ -404,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.
@@ -424,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)}.
@@ -471,7 +766,7 @@ public class ProjectIngestService {
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, ingestExcludeDirs(project));
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.
@@ -505,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, ingestExcludeDirs(project));
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<>();
@@ -582,8 +877,13 @@ public class ProjectIngestService {
Map<NameKey, List<Candidate>> index,
Map<String, List<Candidate>> implementorsByBase,
List<QueuedRef> seeds, int depthLimit, int nodeLimit, String label) {
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.
@@ -591,7 +891,6 @@ public class ProjectIngestService {
Set<String> inferredCallTargets = new HashSet<>();
Set<String> staticCallTargets = new HashSet<>();
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
List<String> examinedFiles = new ArrayList<>();
Set<String> ingestedModuleNames = new HashSet<>();
@@ -640,7 +939,7 @@ public class ProjectIngestService {
String sourceFile = relativeSourceFile(root, candidate.file());
examinedFiles.add(sourceFile);
ParseResult result = withUserExitMetrics(withShellMetrics(
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
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);
// Full deep parse of this module — reconcile so a by-name refresh purges its stale
@@ -690,8 +989,15 @@ public class ProjectIngestService {
}
List<String> unresolved = filterDataLiteralRefs(project.name(), unresolvedRefs,
inferredCallTargets, staticCallTargets);
return new IngestSummary(ingested, unresolved, duplicates, failed, examinedFiles,
truncation(label, depthLimit, nodeLimit, depthTruncated, nodeTruncated));
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);
}
/**

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

@@ -18,8 +18,15 @@ import java.util.Locale;
*
* <p>Java classes carry {@link NodeType#MODULE}; Natural {@code .nat}/{@code .nsn} programs are
* {@code MODULE}s and {@code .lda}/{@code .pda}/{@code .gda} data areas (local/parameter/global) are
* {@link NodeType#DATA_STRUCTURE}s. Files with any other extension are not ingestible and classify
* to {@code null}.
* {@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 {
@@ -41,9 +48,27 @@ final class SourceFiles {
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.
@@ -84,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,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

@@ -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,13 +1,9 @@
# 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=63
# 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
@@ -25,6 +21,9 @@ 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
@@ -41,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

@@ -296,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
@@ -328,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.
@@ -335,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"));
@@ -346,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
@@ -365,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
@@ -475,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
@@ -516,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
@@ -531,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"));
}
@@ -853,12 +853,12 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/graph?direction=out&depth=1")
.then()
.statusCode(200)
.body("root", equalTo("OrderController"))
.body("root", equalTo("com.example.sample.OrderController"))
.body("direction", equalTo("out"))
.body("nodes.name", hasItems("OrderController", "OrderService"))
.body("nodes.find { it.name == 'OrderController' }.distance", equalTo(0))
.body("nodes.find { it.name == 'OrderService' }.distance", equalTo(1))
.body("edges.find { it.from == 'OrderController' && it.to == 'OrderService' }.edgeKind",
.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")));
@@ -872,8 +872,8 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules/OrderService/graph?direction=in&depth=1")
.then()
.statusCode(200)
.body("nodes.name", hasItems("OrderService", "OrderController"))
.body("edges.find { it.to == 'OrderService' }.from", equalTo("OrderController"));
.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
@@ -897,7 +897,7 @@ class AnalysisResourceIT {
.then()
.statusCode(200)
.body("nodes.size()", equalTo(1))
.body("nodes[0].name", equalTo("OrderController"))
.body("nodes[0].name", equalTo("com.example.sample.OrderController"))
.body("truncated", equalTo(true));
}
@@ -918,7 +918,7 @@ class AnalysisResourceIT {
.when().get("/api/projects/" + PROJECT + "/modules?sourceFile=OrderController.java")
.then()
.statusCode(200)
.body("find { it.name == 'OrderController' }.ingestStatus", equalTo("INGESTED"));
.body("find { it.name == 'com.example.sample.OrderController' }.ingestStatus", equalTo("INGESTED"));
}
@Test
@@ -1319,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()
@@ -1391,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"));
}
@@ -1403,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")
@@ -1420,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
@@ -1443,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,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

@@ -33,8 +33,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*
* <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). At {@code depth=1} the raw bound is therefore {@code 1*(1+2)=3}, and
* {@code DEPTHLEAF} sits 5 raw edges behind {@code DEEPCHAIN}'s {@code PERFORM} chain.
* 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)
@@ -84,11 +91,28 @@ class CallTreeTruncationIT {
"a subroutine of the root module crosses no module boundary");
body.setRootPath("");
assertTrue(body.getBoolean("truncated"),
"the traversal stopped at its raw-hop budget, so the caller must be told the list may be "
"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(
"DEPTHLEAF sits 5 raw hops away, beyond the bound of 3 — it is the thing that got cut",
body.getList("items.name"), not(hasItem("DEPTHLEAF")));
"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 {

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

@@ -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", null, null, 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

@@ -44,6 +44,8 @@ class CopycodeExpansionIT {
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))
@@ -171,6 +173,42 @@ class CopycodeExpansionIT {
+ "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.
*

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"));
}
}

View File

@@ -71,7 +71,7 @@ class DeepIngestStatusIT {
void durableIngestStatusLifecycle() {
// After a call-graph ingest the module exists but is not deeply ingested.
ModuleIngestState initial = state();
assertTrue(initial.exists(), "call-graph ingest should create a real module node");
assertTrue(initial.ingested(), "call-graph ingest should create a real module node");
assertFalse(initial.isFull(), "call-graph ingest is not a deep (FULL) ingest");
assertEquals(IngestStatus.NOT_INGESTED.name(), initial.status());

View File

@@ -0,0 +1,206 @@
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.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.*;
/**
* The dispatch-<em>table</em> idiom — an array filled with literal program names, called through an
* indexed read of it — resolves to candidate call edges. 65 modules in {@code upms} dispatch this way.
*
* <p>{@code RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT} handles it: for the assignment writing the
* dispatch variable it follows the {@code READS} on that same line to the array, then takes every
* string literal written to the array as a target. Item 83's scalar fold does not reach these — the
* literals live one hop away, on the array node.
*
* <p><b>Written while investigating item 108, which claims this idiom is unsupported.</b> It is
* supported; what is missing there is only the {@code dispatch-table} <em>endpoint</em> reporting the
* rows. The reason `upms` shows nothing is item 107: those target modules are absent from the
* checkout entirely, and a literal naming no ingested module correctly yields no edge. This fixture
* exists because the behaviour had no test of its own, so nothing would have caught its loss.
*
* <p>Which index is live at runtime is not statically known, so resolution over-approximates to the
* set of literals ever assigned to the array — the same multi-target model item 82 allows a manual
* override. `TBRANCH` pins that this is not merely a simplification but the only correct answer: it
* builds the table in an `IF`/`ELSE`, so index 1 carries a different program per branch and any
* index-keyed pairing would be wrong.
*/
@QuarkusTest
class DispatchTableFoldIT {
private static final String PROJECT = "nat-dispatch-table-fold";
/**
* Straight table: four literals, called through an indexed read.
*/
private static final String ROUTER = """
* Router dispatching through a literal-filled table.
DEFINE DATA LOCAL
01 #WT-PROG (A8/1:4)
01 #W-ACT (A8)
01 #I-OBJ (I2)
END-DEFINE
*
PERFORM INIT-TABLE
*
#W-ACT := #WT-PROG (#I-OBJ)
CALLNAT #W-ACT
*
DEFINE SUBROUTINE INIT-TABLE
ASSIGN #WT-PROG (1) = 'TDISPA'
ASSIGN #WT-PROG (2) = 'TDISPB'
ASSIGN #WT-PROG (3) = 'TDISPC'
ASSIGN #WT-PROG (4) = 'TNOSUCH'
END-SUBROUTINE
*
END
""";
/**
* Table built in two branches: index 1 is TDISPA in one and TDISPB in the other. Also proves the
* resolver does not depend on the init preceding the call — here the subroutine is defined after
* the CALLNAT, as Natural routinely does.
*/
private static final String BRANCHED = """
* Router whose table depends on a runtime condition.
DEFINE DATA LOCAL
01 #WT-PROG (A8/1:2)
01 #W-ACT (A8)
01 #I-OBJ (I2)
01 #MODE (A4)
END-DEFINE
*
PERFORM INIT-TABLE
#W-ACT := #WT-PROG (#I-OBJ)
CALLNAT #W-ACT
*
DEFINE SUBROUTINE INIT-TABLE
IF #MODE = 'ADD'
ASSIGN #WT-PROG (1) = 'TDISPA'
ELSE
ASSIGN #WT-PROG (1) = 'TDISPB'
END-IF
ASSIGN #WT-PROG (2) = 'TDISPC'
END-SUBROUTINE
*
END
""";
/**
* A dispatch fed from a plain scalar, not an array — must stay untouched by this resolver.
*/
private static final String SCALARDSP = """
* Dispatch through a scalar the table resolver must not claim.
DEFINE DATA LOCAL
01 #W-ACT (A8)
END-DEFINE
*
#W-ACT := 'TDISPA'
CALLNAT #W-ACT
END
""";
private static final String TARGET = "DEFINE DATA LOCAL\nEND-DEFINE\nEND\n";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("TROUTER.nat", ROUTER);
write("TBRANCH.nat", BRANCHED);
write("TSCALAR.nat", SCALARDSP);
write("TDISPA.nat", TARGET);
write("TDISPB.nat", TARGET);
write("TDISPC.nat", TARGET);
// TNOSUCH.nat deliberately absent: a literal that is not a real module must yield no edge.
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);
}
}
private static JsonPath callees(String module) {
return given().pathParam("name", module)
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees?scope=external")
.then().statusCode(200).extract().jsonPath();
}
/**
* Every literal in the table becomes a candidate target.
*/
@Test
void everyTableLiteralBecomesACandidateTarget() {
List<String> names = callees("TROUTER").getList("items.name");
assertTrue(names.containsAll(List.of("TDISPA", "TDISPB", "TDISPC")),
"all three real table targets must be resolved: " + names);
}
/**
* A literal that names no ingested module produces no edge — the resolver does not invent nodes.
*/
@Test
void aLiteralThatIsNotARealModuleYieldsNoEdge() {
assertFalse(callees("TROUTER").getList("items.name").contains("TNOSUCH"),
"'TNOSUCH' is a literal in the table but no module exists — it must not become an edge");
}
/**
* The resolved edges are marked inferred, and distinguishable from item 83's string fold.
*/
@Test
void resolvedEdgesAreMarkedInferredNotStatic() {
JsonPath body = callees("TROUTER");
assertEquals("CALLNAT_DYNAMIC", body.getString("items.find { it.name == 'TDISPA' }.edgeKind"),
"a table dispatch is inferred, so it must not masquerade as a static CALLNAT");
}
/**
* The branched table: both branch values are candidates. This is the case that makes the
* candidate-set model necessary rather than merely convenient — index 1 has two different targets,
* so no index-keyed answer could be right.
*/
@Test
void aTableBuiltInTwoBranchesContributesBothValues() {
List<String> names = callees("TBRANCH").getList("items.name");
assertTrue(names.contains("TDISPA") && names.contains("TDISPB"),
"index 1 is TDISPA in one branch and TDISPB in the other; both are possible: " + names);
assertTrue(names.contains("TDISPC"), "the unbranched index must resolve too: " + names);
}
/**
* The scalar case is item 83's fold, and it must keep resolving on its own — the two paths are
* separate, so a change to the indirect resolver must not silently take the scalar one with it.
*/
@Test
void aScalarFedDispatchStillResolvesOnItsOwnPath() {
assertTrue(callees("TSCALAR").getList("items.name").contains("TDISPA"),
"a directly-assigned literal target must resolve without the array path");
}
}

View File

@@ -0,0 +1,163 @@
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 static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 114: an identity that exists in two files is deliberately not ingested — and that state used to
* be indistinguishable from "does not exist".
*
* <p>Worse, the answer was not even stable: an unreferenced duplicate answered {@code 404}, while one
* that something called answered {@code 409 NOT_INGESTED}. One cause, two different wrong answers.
* Both cases are covered here.
*/
@QuarkusTest
class DuplicateIdentityIT {
private static final String PROJECT = "item114-duplicates";
@TempDir
static Path root;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
// Same module name in two directories: neither may be ingested.
write("generated/DUPE.nat", "END\n");
write("manual/DUPE.nat", "END\n");
// A second duplicate, this one referenced by a caller — the case that used to answer 409
// NOT_INGESTED and send an agent to an ingest that skips it again.
write("generated/CALLED.nat", "END\n");
write("manual/CALLED.nat", "END\n");
write("CALLER.nat", "CALLNAT 'CALLED'\nEND\n");
// Control: an ordinary module must keep answering 200.
write("PLAIN.nat", "END\n");
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 {
Path target = root.resolve(fileName);
Files.createDirectories(target.getParent());
Files.write(target, content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Item 136: the duplicate marker is a node like any other, and every node must carry
* {@code startLine}/{@code endLine}. {@code MARK_DUPLICATE_IDENTITIES} was the one node-creating
* query that set neither, and the identifier search's row mapper coerced them unconditionally — so
* a page deep enough to reach a marker faulted with an unstructured 500 instead of returning rows.
*/
@Test
void aDuplicateMarkerCarriesLinesAndDoesNotFaultTheIdentifierSearch() {
given().queryParam("name", "DUPE").queryParam("limit", 500)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("findAll { it.name == 'DUPE' }.startLine", everyItem(notNullValue()))
.body("findAll { it.name == 'DUPE' }.endLine", everyItem(notNullValue()));
}
/**
* The same page, fetched whole. Pins the actual reported symptom — a 500 several hundred rows in —
* rather than only the property that caused it.
*/
@Test
void aFullIdentifierPageOverTheWholeProjectDoesNotFault() {
given().queryParam("name", "E").queryParam("contains", true).queryParam("limit", 500)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then().statusCode(200);
}
/**
* The unreferenced duplicate: used to answer 404, i.e. "no such module".
*/
@Test
void anUnreferencedDuplicateIsReportedAsDuplicateNotAsMissing() {
given().when().get("/api/projects/" + PROJECT + "/modules/DUPE/digest")
.then().statusCode(409)
.body("code", equalTo("DUPLICATE_IDENTITY"))
.body("details.paths", hasSize(2))
.body("details.paths", containsInAnyOrder("generated/DUPE.nat", "manual/DUPE.nat"));
}
/**
* The referenced duplicate: used to answer 409 NOT_INGESTED, pointing at an ingest that skips it.
*/
@Test
void aReferencedDuplicateIsReportedAsDuplicateNotAsNotIngested() {
given().when().get("/api/projects/" + PROJECT + "/modules/CALLED/digest")
.then().statusCode(409)
.body("code", equalTo("DUPLICATE_IDENTITY"))
.body("details.paths", hasSize(2));
}
/**
* Same state on an endpoint served through the 404-only guard.
*/
@Test
void theDuplicateStateAlsoAppliesToCallerDerivedEndpoints() {
given().when().get("/api/projects/" + PROJECT + "/modules/DUPE/callers")
.then().statusCode(409)
.body("code", equalTo("DUPLICATE_IDENTITY"));
}
/**
* The set survives the ingest response it used to be trapped in.
*/
@Test
void duplicatesAreQueryableAfterTheIngest() {
given().when().get("/api/projects/" + PROJECT + "/duplicates")
.then().statusCode(200)
.body("name", containsInAnyOrder("DUPE", "CALLED"))
.body("find { it.name == 'DUPE' }.kind", equalTo("MODULE"))
.body("find { it.name == 'DUPE' }.paths", hasSize(2));
}
@Test
void anOrdinaryModuleIsUnaffected() {
given().when().get("/api/projects/" + PROJECT + "/modules/PLAIN/digest")
.then().statusCode(200)
.body("name", equalTo("PLAIN"));
}
/**
* A name that exists nowhere must still be 404 — the new state must not swallow the old one.
*/
@Test
void anUnknownModuleIsStillNotFound() {
given().when().get("/api/projects/" + PROJECT + "/modules/NOSUCHTHING/digest")
.then().statusCode(404)
.body("code", equalTo("MODULE_NOT_FOUND"));
}
/**
* The duplicate list must not be exposed as ordinary modules.
*/
@Test
void duplicatesDoNotAppearInTheModuleList() {
given().when().get("/api/projects/" + PROJECT + "/modules?limit=100")
.then().statusCode(200)
.body("name", contains("CALLER", "PLAIN"));
}
}

View File

@@ -0,0 +1,267 @@
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 java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 82 — manual override for unresolvable dynamic {@code CALLNAT} targets. {@code CALLERDYN} does
* {@code CALLNAT #TGT} with no literal ever written to {@code #TGT}, so no auto-resolver can recover
* it: it stays an unresolved placeholder. The tests drive the REST surface end to end: list the
* unresolved site, pin it (single and multi-target), reject a bad target, reset it inline, and prove
* the override survives a deep refresh (auto re-apply).
*/
@QuarkusTest
class DynamicCallOverrideIT {
private static final String PROJECT = "nat-dynamic-override";
/**
* Unresolvable dynamic call: #TGT is never assigned a literal, so nothing resolves CALLNAT #TGT.
*/
private static final String CALLERDYN = """
* Subprogram with an unresolvable dynamic CALLNAT.
DEFINE DATA
LOCAL
01 #TGT (A8)
END-DEFINE
*
CALLNAT #TGT
*
END
""";
private static final String TARGETMOD = """
DEFINE DATA
LOCAL
01 #A (A8)
END-DEFINE
*
END
""";
private static final String TARGET2 = """
DEFINE DATA
LOCAL
01 #B (A8)
END-DEFINE
*
END
""";
@TempDir
static Path root;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("CALLERDYN.nat", CALLERDYN);
write("TARGETMOD.nat", TARGETMOD);
write("TARGET2.nat", TARGET2);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
deepRefresh();
}
private static void deepRefresh() {
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);
}
}
private static void resetAll() {
given().when().delete("/api/projects/" + PROJECT + "/dynamic-calls/overrides").then().statusCode(200);
}
/**
* The one unresolved call site's originFile (index 0) and lineNo (index 1).
*/
private static Object[] unresolvedSite() {
var resp = given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/unresolved")
.then().statusCode(200).extract();
String file = resp.path("find { it.variable == '#TGT' }.originFile");
Integer line = resp.path("find { it.variable == '#TGT' }.lineNo");
return new Object[]{file, line};
}
private static void setOverride(String file, int line, List<String> targets) {
given().contentType("application/json")
.body(Map.of("originFile", file, "lineNo", line, "targets", targets, "variable", "#TGT"))
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200);
}
@Test
void unresolvedListsTheDynamicCall() {
resetAll();
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/unresolved")
.then().statusCode(200)
.body("variable", hasItem("#TGT"))
.body("module", hasItem("CALLERDYN"));
// And it currently shows up as an unresolved callee of CALLERDYN.
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"));
}
@Test
void overrideRebuildsTheDerivedModuleEdgesWithoutARefresh() {
// Item 200: CALLS_MODULE (item 68) drives `reaches`; it was only rebuilt by the finalize, so a
// freshly pinned target was unreachable until the next refresh.
resetAll();
Object[] site = unresolvedSite();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(false));
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD", "TARGET2"));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(true));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGET2&depth=3")
.then().statusCode(200).body("reachable", equalTo(true));
// a reset of the one site drops the derived edges again, inline
given().when().delete("/api/projects/" + PROJECT + "/dynamic-calls/overrides?originFile=" + site[0] + "&lineNo=" + site[1])
.then().statusCode(200);
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(false));
}
@Test
void setOverrideResolvesCalleeAndHidesPlaceholder() {
resetAll();
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD"));
// Callee is now the real module; the #TGT placeholder is hidden.
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("TARGETMOD"))
.body("items.name", not(hasItem("#TGT")));
// And the call site no longer reads as unresolved.
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/unresolved")
.then().statusCode(200)
.body("variable", not(hasItem("#TGT")));
resetAll();
}
@Test
void multiTargetCreatesAnEdgeToEachBranch() {
resetAll();
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD", "TARGET2"));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("TARGETMOD"))
.body("items.name", hasItem("TARGET2"));
resetAll();
}
@Test
void unknownTargetIsRejected() {
resetAll();
Object[] site = unresolvedSite();
given().contentType("application/json")
.body(Map.of("originFile", site[0], "lineNo", site[1], "targets", List.of("NOSUCHMOD")))
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(400)
.body("code", equalTo("UNKNOWN_TARGET"));
}
@Test
void resetRestoresTheUnresolvedPlaceholderInline() {
resetAll();
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD"));
resetAll();
// Placeholder is back, real target edge gone — no refresh needed.
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"))
.body("items.name", not(hasItem("TARGETMOD")));
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/unresolved")
.then().statusCode(200)
.body("variable", hasItem("#TGT"));
}
@Test
void callTreeHonoursTheOverrideLikeCallees() {
// Audit defect C: the call-tree BFS did not filter manualHidden, so it walked the marker edge the
// override only hides and reported the variable #TGT as a MODULE in the closure — while callees,
// which does filter, correctly did not. The two views must agree at every step.
resetAll();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"));
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD"));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("TARGETMOD"))
.body("items.name", not(hasItem("#TGT")));
resetAll();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=3")
.then().statusCode(200)
.body("items.name", hasItem("#TGT"))
.body("items.name", not(hasItem("TARGETMOD")));
}
@Test
void overrideSurvivesDeepRefresh() {
resetAll();
Object[] site = unresolvedSite();
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD"));
deepRefresh(); // wipes AstNodes and re-ingests; the :DynamicCallOverride node must survive and re-apply.
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("TARGETMOD"))
.body("items.name", not(hasItem("#TGT")));
// Override is still listed (and not obsolete).
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200)
.body("obsolete", not(hasItem(true)));
resetAll();
// After reset + refresh, no overrides remain.
given().when().get("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200)
.body("originFile", empty());
}
}

View File

@@ -0,0 +1,153 @@
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 java.util.Map;
import java.util.Objects;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 83 — constant-fold a string-<em>assembled</em> dynamic {@code CALLNAT} target. A dispatcher
* builds the callee name from a base literal plus a {@code SUBSTR} overlay
* ({@code MOVE 'YABALKEY' TO #GETSHORT-MODUL} then {@code MOVE 'GN0' TO SUBSTR(#GETSHORT-MODUL,6,3)}
* then {@code CALLNAT #GETSHORT-MODUL}); the fold enricher must resolve that site to the assembled
* module {@code YABALGN0}. A manual override (item 82) present when the caller is (re)ingested must
* still win over the fold.
*/
@QuarkusTest
class DynamicCallnatFoldIT {
private static final String PROJECT = "nat-dynamic-fold";
private static final String YABALGN0 = """
DEFINE DATA
LOCAL
01 #A (A8)
END-DEFINE
*
END
""";
private static final String OTHERMOD = """
DEFINE DATA
LOCAL
01 #B (A8)
END-DEFINE
*
END
""";
@TempDir
static Path root;
/**
* Base literal 'YABALKEY' (A8), then a SUBSTR overlay of 'GN0' at position 6, length 3, assembles
* 'YABALGN0' — YABAL[KEY] with KEY overwritten by GN0 → CALLNAT #GETSHORT-MODUL targets YABALGN0.
* {@code #V} keeps a distinct base name per caller only cosmetically; the fold logic is identical.
*/
private static String foldCaller(String comment) {
return """
* %s
DEFINE DATA
LOCAL
01 #GETSHORT-MODUL (A8)
END-DEFINE
*
MOVE 'YABALKEY' TO #GETSHORT-MODUL
MOVE 'GN0' TO SUBSTR( #GETSHORT-MODUL ,6,3)
CALLNAT #GETSHORT-MODUL
*
END
""".formatted(comment);
}
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("FOLDCALL.nat", foldCaller("Item 83: string-assembled dynamic CALLNAT target."));
write("FOLDOVR.nat", foldCaller("Item 83: same pattern, used for the override-precedence test."));
write("YABALGN0.nat", YABALGN0);
write("OTHERMOD.nat", OTHERMOD);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT)
.then().statusCode(201);
deepRefresh();
}
private static void deepRefresh() {
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);
}
}
/**
* The lineNo + interned source file of module {@code caller}'s external call site to {@code callee}.
*/
private static Object[] callSite(String caller, String callee) {
var callees = given()
.when().get("/api/projects/" + PROJECT + "/modules/" + caller + "/callees?scope=external")
.then().statusCode(200).extract();
List<String> sourceFiles = callees.path("sourceFiles");
List<Map<String, Object>> sites = callees.path("items.find { it.name == '" + callee + "' }.sites");
Map<String, Object> site = sites.get(0);
int line = ((Number) Objects.requireNonNull(site.get("lineNo"))).intValue();
String file = sourceFiles.get(((Number) Objects.requireNonNull(site.get("callSiteFileIndex"))).intValue());
return new Object[]{line, file};
}
@Test
void foldedDynamicCallnatResolvesToAssembledTarget() {
// The base literal + SUBSTR overlay fold to YABALGN0, which is a real module → a resolved
// CALLNAT_DYNAMIC edge, so the assembled target appears among FOLDCALL's external callees.
given()
.when().get("/api/projects/" + PROJECT + "/modules/FOLDCALL/callees?scope=external")
.then().statusCode(200)
.body("items.name", hasItem("YABALGN0"))
.body("items.find { it.name == 'YABALGN0' }.edgeKind", equalTo("CALLNAT_DYNAMIC"));
}
@Test
void manualOverrideWinsOverFold() {
// Precedence (item 83): a human/agent override present when the caller is (re)ingested must win
// over the fold. FOLDOVR initially folds to YABALGN0; pin the site to OTHERMOD, then re-ingest
// FOLDOVR (content changed, CALLNAT line preserved) so the fold re-evaluates with the override
// in place — it must step aside: OTHERMOD appears, the auto-folded YABALGN0 does not.
Object[] site = callSite("FOLDOVR", "YABALGN0");
int line = (int) site[0];
String file = (String) site[1];
given().contentType("application/json")
.body(Map.of("originFile", file, "lineNo", line, "targets", List.of("OTHERMOD"),
"variable", "#GETSHORT-MODUL"))
.when().post("/api/projects/" + PROJECT + "/dynamic-calls/overrides")
.then().statusCode(200);
// Change FOLDOVR's content (a different trailing comment) so its sourceHash changes and the
// refresh re-ingests it, discarding the stale fold edge and re-running the fold with the override.
write("FOLDOVR.nat", foldCaller("Item 83: override-precedence test (touched to force re-ingest)."));
deepRefresh();
given()
.when().get("/api/projects/" + PROJECT + "/modules/FOLDOVR/callees?scope=external")
.then().statusCode(200)
.body("items.name", hasItem("OTHERMOD"))
.body("items.name", not(hasItem("YABALGN0")));
}
}

View File

@@ -0,0 +1,111 @@
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.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 197: {@code functions/{fn}/callers} sees cross-module calls. A Java method called from its own
* class (a {@code FUNCTION -CALLS-> FUNCTION} edge) and from another class (a module-to-module edge
* carrying {@code callerFn}/{@code calleeMethod}) lists both callers with their lines; a TypeScript
* function called from another module lists the calling component. The TypeScript half needs the
* Node sidecar (skipped otherwise); the Java half always runs.
*/
@QuarkusTest
class FunctionCallersCrossModuleIT {
private static final String JAVA = "fncallers-java";
private static final String TS = "fncallers-ts";
@TempDir
static Path javaRoot;
@TempDir
static Path tsRoot;
@BeforeAll
static void setUp() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
}
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 void ingest(String project, Path root, String language) {
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, language, 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 io.restassured.response.Response functionCallers(String project, String module, String fn) {
return given().urlEncodingEnabled(false).when().get("/api/projects/" + project + "/modules/" + module + "/functions/" + fn + "/callers");
}
@Test
void javaMethodListsSameClassAndCrossClassCallers() {
Path pkg = javaRoot.resolve("src/main/java/com/example");
write(pkg, "Logic.java", """
package com.example;
public class Logic {
public void handle() {}
public void twice() { handle(); }
}
""");
write(pkg, "Controller.java", """
package com.example;
public class Controller {
private final Logic logic = new Logic();
public void save() { logic.handle(); }
public void merge() { logic.handle(); logic.handle(); }
public void other() { logic.twice(); }
}
""");
ingest(JAVA, javaRoot, "java");
functionCallers(JAVA, "com.example.Logic", "handle").then().statusCode(200)
.body("items.name", containsInAnyOrder("twice", "save", "merge"))
.body("sourceFiles", hasItems(endsWith("Logic.java"), endsWith("Controller.java")))
.body("items.find { it.name == 'twice' }.sourceFileIndex", not(equalTo(-1)))
.body("items.find { it.name == 'save' }.edgeKind", equalTo("METHOD_CALL"))
.body("items.find { it.name == 'save' }.sites.lineNo", contains(4))
// two calls on one line: one site per (lineNo) key, so a single site at line 5
.body("items.find { it.name == 'merge' }.sites.lineNo", contains(5));
functionCallers(JAVA, "com.example.Logic", "twice").then().statusCode(200)
.body("items.name", contains("other"));
// a method nobody calls
functionCallers(JAVA, "com.example.Controller", "other").then().statusCode(200).body("items", hasSize(0));
}
@Test
void typeScriptFunctionListsTheCallingComponent() {
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
write(tsRoot, "package.json", "{\"name\": \"fe\", \"workspaces\": [\"app\"], \"dependencies\": {\"react\": \"^18\"}}");
Path src = tsRoot.resolve("app/src");
write(src, "util.ts", "export function format(v: string) { return v.trim() }\nexport function unused() { return format('x') }\n");
write(src, "Page.tsx", "import { format } from './util'\nexport function Page() { return <div>{format(' a ')}</div> }\n");
ingest(TS, tsRoot, "typescript");
functionCallers(TS, "app%2Fsrc%2Futil", "format").then().statusCode(200)
.body("items.name", containsInAnyOrder("Page", "unused"))
.body("sourceFiles", hasItem("app/src/Page.tsx"))
.body("items.find { it.name == 'Page' }.sites.lineNo", contains(2));
functionCallers(TS, "app%2Fsrc%2Futil", "unused").then().statusCode(200).body("items", hasSize(0));
}
}

View File

@@ -0,0 +1,87 @@
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.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.junit.jupiter.api.Assertions.assertEquals;
/**
* Item 81 — a leaf qualified by a <em>group</em> whose name is not a {@code USING} member must resolve
* by using the group to disambiguate an otherwise-ambiguous leaf name. `WGEAGB0S` reads
* {@code #MAP-T.V-ID}, where {@code #MAP-T} is a group inside the included {@code BGEAGA01.pda} and
* {@code V-ID} occurs in dozens of PDAs, so the bare resolver's {@code size(matches)=1} guard left it
* unresolved.
*
* <p>{@code MG81} includes both {@code GRPPDA} (whose {@code MAPT} group holds {@code FLD-X}) and
* {@code AMBIG} (which also declares {@code FLD-X}), then reads {@code MAPT.FLD-X}. The bare leaf
* {@code FLD-X} is ambiguous across the two includes, but the group qualifier {@code MAPT} pins it to
* {@code GRPPDA.lda}. Asserted against the raw {@code READS} target. Written RED.
*/
@QuarkusTest
class GroupQualifiedLeafResolveIT {
private static final String PROJECT = "nat-group-qualifier";
@TempDir
static Path root;
@Inject
Driver driver;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String f : List.of("GRPPDA.lda", "AMBIG.lda", "MG81.nat")) {
copyFixture("fixtures/natural/groupqual/" + 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 cp) {
String fileName = cp.substring(cp.lastIndexOf('/') + 1);
try (InputStream in = GroupQualifiedLeafResolveIT.class.getClassLoader().getResourceAsStream(cp)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + cp);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void groupQualifierDisambiguatesAnAmbiguousLeaf() {
try (Session session = driver.session()) {
session.run("CALL db.awaitIndexes(120)").consume();
List<String> targets = session.run("""
MATCH (m:AstNode {project:$p, type:'MODULE', name:'MG81'})
-[:CONTAINS*0..]->(s)-[:READS]->(v:AstNode {name:'FLD-X'})
WHERE v.sourceFile <> ""
RETURN DISTINCT v.sourceFile AS f ORDER BY f
""", Map.of("p", PROJECT))
.list(r -> r.get("f").asString());
assertEquals(List.of("GRPPDA.lda"), targets,
"MG81 reads MAPT.FLD-X — the MAPT group pins the ambiguous FLD-X to GRPPDA.lda, not "
+ "AMBIG.lda, and it must not stay unresolved. Targets: " + targets);
}
}
}

View File

@@ -0,0 +1,101 @@
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.*;
/**
* Characterization of {@code search/identifier?priorityModule=…} (item: current-module prioritization).
* A variable name declared LOCAL in several modules must, under a caller's {@code limit}, keep the
* global list yet surface the requested module's own declaration first so it is not truncated away.
* Modules are named so {@code PRIO_ZZZ_TARGET} sorts last by source file — without prioritization it
* falls outside {@code limit=2}; {@code priorityModule} must pull it into the page.
*/
@QuarkusTest
class IdentifierPriorityModuleIT {
private static final String PROJECT = "prio-test-project";
private static final String NAME = "#SHARED-FLD";
@TempDir
static Path root;
@BeforeAll
static void ingestFixtures() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String module : new String[]{"PRIO_AAA", "PRIO_BBB", "PRIO_CCC", "PRIO_ZZZ_TARGET"}) {
writeSource(module + ".nat", """
DEFINE DATA
LOCAL
1 #SHARED-FLD (A8)
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 writeSource(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void withoutPriorityModuleTheLastSortingModuleIsTruncatedByTheLimit() {
given()
.queryParam("name", NAME).queryParam("limit", 2)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(2))
.body("sourceFile", not(hasItem("PRIO_ZZZ_TARGET.nat")));
}
@Test
void priorityModulePinsItsMatchIntoThePageDespiteTheLimit() {
given()
.queryParam("name", NAME).queryParam("limit", 2).queryParam("priorityModule", "PRIO_ZZZ_TARGET")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(2))
.body("sourceFile", hasItem("PRIO_ZZZ_TARGET.nat"))
// Pinned match is first; the rest of the page is filled from the global list.
.body("sourceFile[0]", org.hamcrest.Matchers.equalTo("PRIO_ZZZ_TARGET.nat"));
}
@Test
void priorityModuleDoesNotFilterOutOtherModules() {
// Unlike module=, priorityModule keeps the global result set — a large limit still returns all four.
given()
.queryParam("name", NAME).queryParam("limit", 50).queryParam("priorityModule", "PRIO_ZZZ_TARGET")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(4))
.body("name", everyItem(org.hamcrest.Matchers.equalTo(NAME)));
}
}

View File

@@ -0,0 +1,165 @@
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.*;
/**
* Item 125: a Java type declaration must be findable through {@code search/identifier} by its
* <b>short</b> name, not only by the fully-qualified identity the graph stores (item 117), and
* {@code ?contains=} must do what it does on {@code /search/value} instead of being dropped.
*
* <p>The bug this pins down was not "types are not indexed" — they were, under their FQN — but that
* the short form an agent actually types answered {@code []}, which is indistinguishable from "no
* such name". The match now also carries {@code simpleName} and {@code moduleKind}, so "is this a
* type or a method?" is answered by the search itself.
*/
@QuarkusTest
class IdentifierTypeDeclarationIT {
private static final String PROJECT = "identifier-type-decl-project";
private static final String FQN = "com.example.idt.PartnerUpdateLogic";
@TempDir
static Path root;
@BeforeAll
static void ingestFixtures() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example/idt");
writeSource(pkg, "PartnerUpdateLogic.java", """
package com.example.idt;
public class PartnerUpdateLogic {
private String partnerName;
public void updatePartner() {
this.partnerName = "x";
}
}
""");
writeSource(pkg, "PartnerReadPort.java", """
package com.example.idt;
public interface PartnerReadPort {
String read();
}
""");
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 writeSource(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.specification.RequestSpecification search() {
return given().queryParam("type", "MODULE");
}
@Test
void theShortNameFindsTheTypeDeclaration() {
search()
.queryParam("name", "PartnerUpdateLogic")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN))
.body("find { it.name == '" + FQN + "' }.simpleName", equalTo("PartnerUpdateLogic"))
.body("find { it.name == '" + FQN + "' }.moduleKind", equalTo("CLASS"));
}
@Test
void theQualifiedNameStillFindsIt() {
search()
.queryParam("name", FQN)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN));
}
@Test
void moduleKindDistinguishesAnInterfaceFromAClass() {
search()
.queryParam("name", "PartnerReadPort")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("moduleKind", everyItem(equalTo("INTERFACE")));
}
@Test
void containsMatchesASubstringOfTheShortNameCaseInsensitively() {
search()
.queryParam("name", "partnerupd").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItem(FQN));
}
@Test
void containsAlsoMatchesThePackageFragmentOfTheQualifiedName() {
search()
.queryParam("name", "com.example.idt").queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("name", hasItems(FQN, "com.example.idt.PartnerReadPort"));
}
@Test
void withoutContainsASubstringIsNotAMatch() {
search()
.queryParam("name", "PartnerUpd")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("$", hasSize(0));
}
@Test
void aMethodMatchCarriesNoSimpleNameOrModuleKind() {
given()
.queryParam("name", "updatePartner")
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(200)
.body("find { it.type == 'FUNCTION' }.simpleName", nullValue())
.body("find { it.type == 'FUNCTION' }.moduleKind", nullValue());
}
@Test
void containsWithoutANameIsRejectedRatherThanDumpingEveryNode() {
given()
.queryParam("contains", true)
.when().get("/api/projects/" + PROJECT + "/search/identifier")
.then()
.statusCode(400)
.body("code", equalTo("MISSING_NAME"));
}
}

View File

@@ -0,0 +1,129 @@
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;
/**
* Characterization test for the 2026-07-28 WGEAGB0S deep-API audit, items 103/104.
*
* <ul>
* <li><b>104 — nested include provenance:</b> {@code ISI173N0 -> YFRAMN04} reported
* {@code includedAt: 27}, a line in {@code YFRAMMC1.cpy} — an intermediate member the response
* never named, while for a one-level include the same field is a line in the module's own file.
* {@code includedAt} must always be the host-module line, and the chain must be visible.</li>
* <li><b>103 — silent truncation:</b> {@code db-accesses} returned a bare array capped at 50 with no
* total and no {@code truncated} flag, so {@code WGEAGB0S?depth=10} silently dropped 14 rows and
* hid 7 tables. Absent {@code limit} must mean "all rows".</li>
* </ul>
*/
@QuarkusTest
class IncludeProvenanceAndAccessLimitIT {
private static final String PROJECT = "nat-includeprov";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String fixture : List.of("NIHOST.nat", "NIMID.cpy", "NIINNER.cpy", "NITARGET.nat", "NILIMIT.nat")) {
copyFixture("fixtures/natural/includeprov/" + 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 = IncludeProvenanceAndAccessLimitIT.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);
}
}
/**
* Item 104: includedAt is the host-module INCLUDE line, and the whole chain is reported.
*/
@Test
void nestedIncludeReportsHostLineAndTheWholeChain() {
JsonPath body = given().pathParam("name", "NIHOST")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.extract().jsonPath();
String site = "items.find { it.name == 'NITARGET' }.sites[0]";
assertEquals("NIINNER", body.getString(site + ".viaCopycode"),
"viaCopycode names the innermost member, where the CALLNAT is written");
assertEquals(7, body.getInt(site + ".includedAt"),
"includedAt must be the INCLUDE line in NIHOST.nat, not a line of the intermediate .cpy");
List<String> chainFiles = body.getList(site + ".includePath.sourceFile");
assertEquals(List.of("NIHOST.nat", "NIMID.cpy"), chainFiles,
"Every hop must be visible: the host INCLUDE and the nested one inside NIMID.cpy");
assertEquals(List.of(7, 2), body.getList(site + ".includePath.lineNo"));
}
/**
* Item 104 regression guard: a direct call keeps an empty chain and no copycode attribution.
*/
@Test
void directCallHasNoIncludeChain() {
JsonPath body = given().pathParam("name", "NILIMIT")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.extract().jsonPath();
assertTrue(body.getList("items").isEmpty() || body.getList("items.sites.flatten().includePath.flatten()").isEmpty());
}
/**
* Item 103: no limit means every row, not a silent 50.
*/
@Test
void dbAccessesAreNotSilentlyTruncatedAtFifty() {
List<String> all = given().pathParam("name", "NILIMIT")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.extract().jsonPath().getList("name");
assertEquals(60, all.size(), "All accesses must be returned when the caller sets no limit");
}
/**
* Item 103: an explicit limit is still honoured exactly — caller-chosen truncation is not silent.
*/
@Test
void explicitLimitIsStillHonoured() {
List<String> page = given().pathParam("name", "NILIMIT").queryParam("limit", 10)
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
.then().statusCode(200)
.extract().jsonPath().getList("name");
assertEquals(10, page.size());
}
}

View File

@@ -53,7 +53,7 @@ class IngestModuleJavaIT {
.when().get("/api/projects/" + PROJECT + "/modules")
.then()
.statusCode(200)
.body("name", hasItems("CheckoutService", "PaymentGateway", "CardGateway", "WireGateway"));
.body("name", hasItems("com.example.pay.CheckoutService", "com.example.pay.PaymentGateway", "com.example.pay.CardGateway", "com.example.pay.WireGateway"));
}
@Test
@@ -63,6 +63,6 @@ class IngestModuleJavaIT {
.when().get("/api/projects/" + PROJECT + "/modules/CardGateway/callers")
.then()
.statusCode(200)
.body("items.name", hasItems("CheckoutService"));
.body("items.name", hasItems("com.example.pay.CheckoutService"));
}
}

View File

@@ -0,0 +1,86 @@
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.*;
/**
* Item 203: a caller that invokes an interface at two lines gets one synthetic (CHA) call edge to the
* implementation per line, each carrying the called method — so {@code callees} lists both real lines
* (not one picked at random) and {@code functions/{impl-method}/callers} sees the call made through the
* interface.
*/
@QuarkusTest
class InheritanceCallSitesIT {
private static final String PROJECT = "item203-inheritance-sites";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example");
write(pkg, "Repo.java", "package com.example;\npublic interface Repo { void save(); void load(); }\n");
write(pkg, "RepoImpl.java", "package com.example;\npublic class RepoImpl implements Repo {\n public void save() {}\n public void load() {}\n}\n");
write(pkg, "Service.java", """
package com.example;
public class Service {
private Repo repo;
public void store() {
repo.save();
}
public void read() {
repo.load();
}
}
""");
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).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);
}
}
@Test
void everyInterfaceCallSiteReachesTheImplementationWithItsLine() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.lineNo", containsInAnyOrder(5, 8))
.body("items.find { it.name == 'com.example.Repo' }.sites.lineNo", containsInAnyOrder(5, 8));
}
@Test
void implementationMethodCallersIncludeCallsThroughTheInterface() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/save/callers").then().statusCode(200)
.body("items.name", contains("store"))
.body("items[0].sites.lineNo", contains(5));
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/load/callers").then().statusCode(200)
.body("items.name", contains("read"));
}
@Test
void aSecondRefreshDoesNotMultiplyTheSyntheticEdges() {
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.size()", equalTo(2));
}
}

View File

@@ -0,0 +1,178 @@
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.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Item 116b: a call on a field <em>inherited</em> from a supertype must produce a call edge.
*
* <p>The parser sees one file at a time, so a subclass calling {@code repo.find()} — where {@code repo}
* is declared in a base class in another file — resolved to nothing and the edge was dropped. Measured
* on a real codebase, that made a repository interface invisible to its actual callers, and the API
* reported the absence as fact rather than as unknown.
*
* <p>Also asserts the cleanup: the {@code field:*} placeholders the parser emits to carry the
* unresolved receiver must not survive enrichment, or they would show up as modules.
*/
@QuarkusTest
class InheritedFieldCallIT {
private static final String PROJECT = "item116b-inherited-field";
@TempDir
static Path root;
@Inject
Driver driver;
@BeforeAll
static void createProject() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write("Repo.java", """
package p;
public class Repo {
public void find() {
}
}
""");
write("BaseLogic.java", """
package p;
public class BaseLogic {
protected Repo repo;
}
""");
// One hop: declares nothing, calls the field it inherits.
write("ChildLogic.java", """
package p;
public class ChildLogic extends BaseLogic {
public void work() {
repo.find();
}
}
""");
// Two hops: the chain must be walked, not just the direct supertype.
write("GrandChildLogic.java", """
package p;
public class GrandChildLogic extends ChildLogic {
public void alsoWork() {
repo.find();
}
}
""");
// A receiver that resolves to nothing at all: the marker for it can never be rewired, so it
// is the case the cleanup has to catch. It used to survive and be served from /callees.
write("DanglingLogic.java", """
package p;
public class DanglingLogic {
public void work(Object o) {
somethingUndeclared.doIt();
}
}
""");
// Item 118/B (lambda and catch parameters are bound names, not inherited fields) is covered by
// JavaParserTest#boundNamesAreNotTakenForInheritedFieldReceivers, not here: the cleanup deletes
// every marker, so an end-to-end assertion would pass whether or not the marker was created.
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 fileName, String content) {
try {
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void callOnAnInheritedFieldReachesItsType() {
given().when().get("/api/projects/" + PROJECT + "/modules/Repo/callers")
.then().statusCode(200)
.body("items.name", hasItem("p.ChildLogic"));
}
/**
* The failure this guards is the one that made the finding hard to see: only the direct subclass
* resolving would look like success while deeper hierarchies stayed silently empty.
*/
@Test
void theExtendsChainIsWalkedNotJustOneHop() {
given().when().get("/api/projects/" + PROJECT + "/modules/Repo/callers")
.then().statusCode(200)
.body("items.name", hasItem("p.GrandChildLogic"));
}
/**
* These two assertions replace a pair that tested {@code STARTS WITH 'field:'} and
* {@code hasItem("field:repo")}. The prefix constant carried a stray {@code U+0001}, so the real
* markers were named {@code field:*} — the tests were true because they matched nothing,
* while 202 markers survived in a real project and one was served from {@code /callees}.
*
* <p>Hence: match the marker anywhere in the name (a ':' cannot occur in a module name), and
* assert the underlying invariant separately — a module name never contains a control character.
* Either one alone can be satisfied by a name the author did not anticipate.
*/
@Test
void noMarkerSurvivesEnrichmentUnderAnyName() {
try (Session session = driver.session()) {
long leftovers = session.run(
"MATCH (m:MODULE {project: $p}) WHERE m.name CONTAINS 'field:' RETURN count(m) AS c",
Map.of("p", PROJECT)).single().get("c").asLong();
assertEquals(0, leftovers, "the field: receiver markers are scaffolding and must be cleaned up");
}
}
@Test
void noModuleNameContainsAControlCharacter() {
try (Session session = driver.session()) {
List<String> odd = session.run(
"MATCH (m:MODULE {project: $p}) RETURN m.name AS n", Map.of("p", PROJECT))
.list(r -> r.get("n").asString()).stream()
.filter(n -> n.chars().anyMatch(c -> c < 0x20))
.toList();
assertEquals(List.of(), odd, "a module name is an identity an agent passes back in a URL");
}
}
/**
* The leak was visible through the API, not only in the graph — which is why the graph-only
* assertions above are not enough on their own. {@code somethingUndeclared} resolves to nothing,
* so its marker is the one that used to survive.
*/
@Test
void anUnresolvableReceiverDoesNotLeakIntoCallees() {
List<String> callees = given().when().get("/api/projects/" + PROJECT + "/modules/p.DanglingLogic/callees")
.then().statusCode(200)
.extract().jsonPath().getList("items.name", String.class);
assertEquals(List.of(), callees.stream().filter(n -> n != null && n.contains("field:")).toList(),
"internal scaffolding must never reach an API response");
}
@Test
void theMarkerIsNotExposedAsAModule() {
given().when().get("/api/projects/" + PROJECT + "/modules?limit=100")
.then().statusCode(200)
.body("name", not(hasItem(containsString("field:"))));
}
}

View File

@@ -67,15 +67,15 @@ class JavaBulkFunctionOverridesIT {
*/
@Test
void perMethodOverridesAlreadyWorkForEachHook() {
given().pathParam("name", "AbstractProcessingStep").pathParam("fn", "clearTable")
given().pathParam("name", "fixtures.bulkoverrides.AbstractProcessingStep").pathParam("fn", "clearTable")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/functions/{fn}/overrides")
.then().statusCode(200)
.body("module", hasItems("AccountProcessingStep", "RiskProcessingStep"));
.body("module", hasItems("fixtures.bulkoverrides.AccountProcessingStep", "fixtures.bulkoverrides.RiskProcessingStep"));
given().pathParam("name", "AbstractProcessingStep").pathParam("fn", "writeEntities")
given().pathParam("name", "fixtures.bulkoverrides.AbstractProcessingStep").pathParam("fn", "writeEntities")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/functions/{fn}/overrides")
.then().statusCode(200)
.body("module", hasItems("AccountProcessingStep", "RiskProcessingStep"));
.body("module", hasItems("fixtures.bulkoverrides.AccountProcessingStep", "fixtures.bulkoverrides.RiskProcessingStep"));
}
/**
@@ -86,13 +86,13 @@ class JavaBulkFunctionOverridesIT {
*/
@Test
void bulkEndpointGroupsOverridesByHookMethod() {
given().pathParam("name", "AbstractProcessingStep")
given().pathParam("name", "fixtures.bulkoverrides.AbstractProcessingStep")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/functions/overrides")
.then().statusCode(200)
.body("method", hasItems("clearTable", "writeEntities"))
.body("findAll { it.method == 'clearTable' }.module",
hasItems("AccountProcessingStep", "RiskProcessingStep"))
hasItems("fixtures.bulkoverrides.AccountProcessingStep", "fixtures.bulkoverrides.RiskProcessingStep"))
.body("findAll { it.method == 'writeEntities' }.module",
hasItems("AccountProcessingStep", "RiskProcessingStep"));
hasItems("fixtures.bulkoverrides.AccountProcessingStep", "fixtures.bulkoverrides.RiskProcessingStep"));
}
}

View File

@@ -0,0 +1,111 @@
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.InputStream;
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.*;
/**
* Item 140: a Java project with no JPA entity has no {@code DB_TABLE}, so every {@code DB_ACCESS}
* candidate the parser's over-approximating heuristic emitted is a false positive by construction
* and is reaped at the end of enrichment.
*
* <p>Both projects ingest the <em>same</em> two source files; the second adds one entity. That is
* the whole difference, and it is what makes this a test of the gate rather than of the parser: the
* reaper is project-level, so the identical false positives survive in the project that happens to
* own a table. {@code sql-statements} is the endpoint under test because it joins the table with
* {@code OPTIONAL MATCH} and is therefore the one that actually leaked them.
*/
@QuarkusTest
class JavaDbAccessNoEntityIT {
private static final String WITHOUT_ENTITY = "item140-no-entity";
private static final String WITH_ENTITY = "item140-with-entity";
@TempDir
static Path withoutEntityRoot;
@TempDir
static Path withEntityRoot;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
copyFixture(withoutEntityRoot, "fixtures/java/nodb/TextUtils.java");
copyFixture(withoutEntityRoot, "fixtures/java/nodb/ReportRenderer.java");
copyFixture(withEntityRoot, "fixtures/java/nodb/TextUtils.java");
copyFixture(withEntityRoot, "fixtures/java/nodb/ReportRenderer.java");
copyFixture(withEntityRoot, "fixtures/java/nodb/Ledger.java");
ingestProject(WITHOUT_ENTITY, withoutEntityRoot);
ingestProject(WITH_ENTITY, withEntityRoot);
}
private static void ingestProject(String project, Path root) {
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 copyFixture(Path root, String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = JavaDbAccessNoEntityIT.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);
}
}
/**
* Without an entity, {@code TextUtils.getColumn(...)} must not be reported as a database read.
*/
@Test
void noEntityMeansNoDbAccessAtAll() {
given().pathParam("name", "ReportRenderer")
.when().get("/api/projects/" + WITHOUT_ENTITY + "/modules/{name}/sql-statements")
.then().statusCode(200)
.body("$", empty());
}
/**
* db-accesses was already empty here (it joins the table with a plain MATCH), and stays empty —
* the reaper must not have made it worse by leaving a dangling row behind.
*/
@Test
void noEntityMeansNoDbAccessesEither() {
given().pathParam("name", "ReportRenderer")
.when().get("/api/projects/" + WITHOUT_ENTITY + "/modules/{name}/db-accesses")
.then().statusCode(200)
.body("$", empty());
}
/**
* The gate is project-level, so one entity anywhere in the project keeps every candidate alive —
* including the same static-getter false positives, which still arrive with a null table. This
* pins the deliberate limit of item 140's chosen rule rather than glossing over it.
*/
@Test
void oneEntityInTheProjectKeepsTheSameFalsePositives() {
given().pathParam("name", "ReportRenderer")
.when().get("/api/projects/" + WITH_ENTITY + "/modules/{name}/sql-statements")
.then().statusCode(200)
.body("$", not(empty()))
.body("statement", hasItem("TextUtils.getColumn(line, 0, 8)"))
.body("table", hasItem(nullValue()));
}
}

View File

@@ -0,0 +1,119 @@
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.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* Characterization tests for the MultiTableImportJob Java deep-API audit — three defect classes in the
* inheritance/CHA wiring materialization:
*
* <ul>
* <li><b>Finding A — inherited wiring provenance:</b> an {@code INJECTS}/{@code REFERENCES} edge a
* subclass inherits from its base (item 31) must report its call-site file as the <em>base</em>
* file (where the injected field / class-literal really lives), not the subclass's own file. The
* base's line number stamped against the subclass file is meaningless (often past its end).</li>
* <li><b>Finding B — constructor CHA fan-out:</b> {@code new ArrayList<>()} is statically bound and
* must not be fanned out to project subtypes of {@code ArrayList} — no phantom {@code CONSTRUCTOR}
* callee to {@code MyList}.</li>
* <li><b>Finding C — qualified same-name supertype:</b> {@code class Foo extends com.external.Foo}
* must not resolve to the project's own {@code Foo} (no self-{@code EXTENDS} loop).</li>
* </ul>
*/
@QuarkusTest
class JavaInheritanceWiringIT {
private static final String PROJECT = "java-inheritance-wiring";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String f : List.of("Collab.java", "SomeListener.java", "BaseJob.java",
"MyList.java", "ConcreteJob.java", "Foo.java")) {
copyFixture("fixtures/java/inheritance-wiring/" + f);
}
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 copyFixture(String classpathResource) {
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
try (InputStream in = JavaInheritanceWiringIT.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);
}
}
/**
* Finding A: the inherited INJECTS call site is attributed to the base file, not the subclass.
*/
@Test
void inheritedInjectSiteNamesTheBaseFileNotTheSubclass() {
JsonPath body = given().pathParam("name", "ConcreteJob")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("iw.Collab"))
.extract().jsonPath();
List<String> sourceFiles = body.getList("sourceFiles");
Integer fileIdx = body.get("items.find { it.name == 'iw.Collab' }.sites[0].callSiteFileIndex");
assertNotNull(fileIdx, "Collab INJECTS site must have a callSiteFileIndex");
String siteFile = sourceFiles.get(fileIdx);
assertEquals("BaseJob.java", siteFile.substring(siteFile.lastIndexOf('/') + 1),
"inherited INJECTS site must point at the base class file, not the subclass");
}
/**
* Finding B: a `new ArrayList<>()` must not produce a phantom CONSTRUCTOR callee to MyList.
*/
@Test
void constructorCallIsNotFannedOutToProjectSubtypes() {
JsonPath body = given().pathParam("name", "ConcreteJob")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.extract().jsonPath();
List<String> constructorTargets =
body.getList("items.findAll { it.edgeKind == 'CONSTRUCTOR' }.name");
org.junit.jupiter.api.Assertions.assertFalse(constructorTargets.contains("MyList"),
"new ArrayList<>() must not be fanned out to the project subtype MyList");
}
/**
* Finding C: a qualified same-name supertype must not create a self-EXTENDS edge.
*/
@Test
void qualifiedSameNameSupertypeIsNotResolvedToSelf() {
given().pathParam("name", "Foo")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.findAll { it.edgeKind == 'EXTENDS' }.name", not(hasItem("Foo")));
}
}

View File

@@ -70,8 +70,8 @@ class JavaInheritedWiringIT {
given().pathParam("name", "AbstractJob")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=2&followWiring=true")
.then().statusCode(200)
.body("items.name", hasItem("StepA"))
.body("items.name", hasItem("StepB"));
.body("items.name", hasItem("fixtures.wiringinherited.StepA"))
.body("items.name", hasItem("fixtures.wiringinherited.StepB"));
}
/**
@@ -84,7 +84,7 @@ class JavaInheritedWiringIT {
given().pathParam("name", "ConcreteJob")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=2&followWiring=true")
.then().statusCode(200)
.body("items.name", hasItem("StepA"))
.body("items.name", hasItem("StepB"));
.body("items.name", hasItem("fixtures.wiringinherited.StepA"))
.body("items.name", hasItem("fixtures.wiringinherited.StepB"));
}
}

View File

@@ -65,23 +65,23 @@ class JavaInterfaceResolutionIT {
given().pathParam("name", "SignupService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
.then().statusCode(200)
.body("items.name", hasItem("Notifier"));
.body("items.name", hasItem("fixtures.iface.Notifier"));
// resolveInterfaces: Notifier is replaced by its single impl EmailNotifier.
given().pathParam("name", "SignupService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees?resolveInterfaces=true")
.then().statusCode(200)
.body("items.name", hasItem("EmailNotifier"))
.body("items.name", not(hasItem("Notifier")));
.body("items.name", hasItem("fixtures.iface.EmailNotifier"))
.body("items.name", not(hasItem("fixtures.iface.Notifier")));
}
@Test
void multipleImplementationsExpandAndDropInterface() {
given().pathParam("name", "CheckoutService")
given().pathParam("name", "com.example.pay.CheckoutService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees?resolveInterfaces=true")
.then().statusCode(200)
.body("items.name", hasItems("CardGateway", "WireGateway"))
.body("items.name", not(hasItem("PaymentGateway")));
.body("items.name", hasItems("com.example.pay.CardGateway", "com.example.pay.WireGateway"))
.body("items.name", not(hasItem("com.example.pay.PaymentGateway")));
}
@Test
@@ -89,7 +89,7 @@ class JavaInterfaceResolutionIT {
given().pathParam("name", "SignupService")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree?depth=2&resolveInterfaces=true")
.then().statusCode(200)
.body("items.name", hasItem("EmailNotifier"))
.body("items.name", not(hasItem("Notifier")));
.body("items.name", hasItem("fixtures.iface.EmailNotifier"))
.body("items.name", not(hasItem("fixtures.iface.Notifier")));
}
}

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