Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
234ea76911 | ||
|
|
7e0d75cc5e | ||
|
|
432ddf1c11 | ||
|
|
133e4e888d | ||
|
|
26d862b5e8 | ||
|
|
10971915e9 | ||
|
|
7043c8ab0b | ||
|
|
3182385e5f | ||
|
|
af68b8916c | ||
|
|
e5873f0e1d | ||
|
|
d6924a1dc2 | ||
|
|
56314d5e73 | ||
|
|
c4ff96b5fe | ||
|
|
9ce92c974c | ||
|
|
736fb48512 | ||
|
|
40f9aaf551 | ||
|
|
c3eaa0f55e | ||
|
|
039ff1e176 | ||
|
|
2732d69bd1 | ||
|
|
8bfef47e1e | ||
|
|
bd86a01e92 | ||
|
|
7adf7e54e4 | ||
|
|
4b8a441566 | ||
|
|
5091cba820 | ||
|
|
ec924971a9 | ||
|
|
56e20aee3b | ||
|
|
29269b83c3 | ||
|
|
907286dabb | ||
|
|
f5ca2584f3 | ||
|
|
0e54cc1589 | ||
|
|
3309afe0e1 | ||
|
|
cf5d5ea821 | ||
|
|
b6007b2096 | ||
|
|
97081718fd | ||
|
|
93589c2eb9 | ||
|
|
e2e8448b85 | ||
|
|
64d1a75f7c | ||
|
|
bb45865966 | ||
|
|
6cce4526c4 | ||
|
|
e29e91528c | ||
|
|
54fa071730 | ||
|
|
cac0cba379 | ||
|
|
19f59ff04e | ||
|
|
892847d5be | ||
|
|
54ed6bc822 | ||
|
|
949ce5d568 | ||
|
|
e07a071dbd | ||
|
|
ea654d0ce5 | ||
|
|
2c56eea161 | ||
|
|
3db477f4a3 | ||
|
|
38d5fafdc8 | ||
|
|
2cf16274c3 | ||
|
|
d4b14a3b6d | ||
|
|
4e1798eb02 | ||
|
|
a07405620a | ||
|
|
c8ab0274c3 | ||
|
|
364bcb2932 | ||
|
|
76159b0d25 | ||
|
|
ecfd8f94e2 | ||
|
|
9693c25edb | ||
|
|
acebfdca83 | ||
|
|
5880ebad46 | ||
|
|
dcaadce964 | ||
|
|
284bb9f110 | ||
|
|
826508f70a | ||
|
|
0dfc85a11b | ||
|
|
2a45f9fdc2 | ||
|
|
1095ce94fe | ||
|
|
0f3d9c8ec1 |
8
.dockerignore
Normal file
8
.dockerignore
Normal 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
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"agenticcode": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:8787/mcp/sse"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
CLAUDE.md
39
CLAUDE.md
@@ -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
583
README.md
@@ -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 | — |
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
ac-cli/src/main/java/com/agenticcode/cli/ModuleSelector.java
Normal file
36
ac-cli/src/main/java/com/agenticcode/cli/ModuleSelector.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
57
ac-cli/src/main/java/com/agenticcode/cli/ReachesCommand.java
Normal file
57
ac-cli/src/main/java/com/agenticcode/cli/ReachesCommand.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java
Normal file
26
ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java
Normal file
50
ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java
Normal file
29
ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* MCP tool endpoints for AI agent integration.
|
||||
*/
|
||||
@org.jspecify.annotations.NullMarked
|
||||
package com.agenticcode.codeserver.mcp;
|
||||
@@ -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?).
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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:"))));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user