Compare commits
100 Commits
pre-ingest
...
main
| 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 | ||
|
|
8d27d7cc7e | ||
|
|
ee4bbd31d6 | ||
|
|
30c583610e | ||
|
|
c5e6a90038 | ||
|
|
9d234ebd0d | ||
|
|
9da3e42b4e | ||
|
|
1224efbf5b | ||
|
|
5a53731305 | ||
|
|
692d4a13cc | ||
|
|
fbc8317daa | ||
|
|
0d46fcb489 | ||
|
|
e0ce83c422 | ||
|
|
12ff54d485 | ||
|
|
8e44df68c7 | ||
|
|
52c03130b3 | ||
|
|
12750f2086 | ||
|
|
9f42190097 | ||
|
|
17d8a7853c | ||
|
|
9d6b577be5 | ||
|
|
4d2cbf37cd | ||
|
|
2d3d3d33f3 | ||
|
|
e98e9930f1 | ||
|
|
b8539ca011 | ||
|
|
5fb86cb743 | ||
|
|
c2b70d01de | ||
|
|
ebf25ad773 | ||
|
|
438b0841d4 | ||
|
|
cafc057fa9 | ||
|
|
c49901e919 | ||
|
|
3cf712fc98 | ||
|
|
d7f26f278d |
13
.claude/settings.json
Normal file
13
.claude/settings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(git:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
CLAUDE.md
65
CLAUDE.md
@@ -6,15 +6,28 @@
|
||||
exceptions. If you need to ask something, use `AskUserQuestion`. If you need clarification, use `AskUserQuestion`. If
|
||||
you need a decision, use `AskUserQuestion`. The tool provides a freetext option automatically — use it.
|
||||
* **Never assume anything about the user's intent.** Ask questions and wait for the user to clarify.
|
||||
* **Always use agentic code** see x-docs/mcp-api-usage-ac-implementation.md to understand the code, get an overview and
|
||||
* **Always use agentic code** see x-docs/agent-api-usage-ac-implementation.md to understand the code, get an overview
|
||||
and
|
||||
if somethinmg is missing or not working, report it directly. Also. find identifier via agentic code. if agentic code
|
||||
is not running, report it.
|
||||
* **NEVER start Docker yourself — the human starts it.** If you need the server/Neo4j for analysis and
|
||||
`http://localhost:8787` is unreachable, **stop and ask the user to run `./manage-ac.sh deploy`** (via
|
||||
`AskUserQuestion`, per the rule above). Do not run `./manage-ac.sh deploy`, `docker compose up/start/restart`, or
|
||||
otherwise bring containers up on your own initiative — not to "just check something", not because it is
|
||||
faster. Starting the stack is the human's call, like committing. The same goes for stopping or restarting
|
||||
it: ask first. You may *read* container state (`docker ps`, logs) freely.
|
||||
* **Clean up what you start.** Any long-running probe you launch (`cypher-shell`, background Bash, a query
|
||||
against Neo4j) must be stopped before you report done. `TaskStop` kills the *client*, not the JVM behind
|
||||
a `docker exec` — verify with `ps` on the host **and** inside the container, then say so. Never report
|
||||
"all shells stopped" from the task list alone.
|
||||
* If agentic code is not ingested, refresh it again (`ac refresh` / `POST /api/projects/ac/refresh`)
|
||||
* After implementation do a refresh again to get the latest changes.
|
||||
* **Keep REST API, MCP, and `ac-cli` in sync.** Every REST endpoint added or changed in
|
||||
`ac-code-server/.../api/*Resource.java` MUST have a matching MCP tool in `.../mcp/Mcp*Tools.java` **and** a matching
|
||||
`ac-cli` command, delivered in the same change. Treat REST + MCP + CLI as one unit of work — none of the three is
|
||||
"done" without the other two.
|
||||
* **A deep refresh is long and mutates the graph.** Do not abort one halfway: the earlier enrichment steps
|
||||
are already committed, so the graph is left partially updated and every later query silently answers from
|
||||
it. If one must be stopped, say plainly that the graph is now in a half-updated state.
|
||||
* **Keep the REST API and `ac-cli` in sync.** Every REST endpoint added or changed in
|
||||
`ac-code-server/.../api/*Resource.java` MUST have a matching `ac-cli` command, delivered in the same change. Treat
|
||||
REST + CLI as one unit of work — neither is "done" without the other.
|
||||
## 1. Core Rules
|
||||
|
||||
- Never assume. Always read files first with tools.
|
||||
@@ -27,10 +40,14 @@
|
||||
- Trivial tasks: Ask "Trivial? Direct edit or full workflow?"
|
||||
- Never run `git commit` (or `git push`) without the user explicitly asking for it in that turn. A prior commit approval
|
||||
does not carry over to later changes.
|
||||
- **The human decides what to commit and when.** Committing is a human decision, not yours. Do not stage, group, or
|
||||
propose commits on your own initiative, and do not nudge toward committing. You may prepare a suggested commit message
|
||||
when asked, but the act — and its timing and scope — is the human's call alone.
|
||||
- All features must be tracked in `x-docs/roadmap.md`. Once a feature has been implemented, mark it `[x]` and add a
|
||||
timestamp (date) indicating when it was completed.
|
||||
- For **every feature** that changes API behavior or what an agent can query, you MUST update
|
||||
`x-docs/mcp-api-usage-ac-implementation.md` to reflect it (new/changed endpoints, response fields, semantics) — treat
|
||||
`x-docs/agent-api-usage-ac-implementation.md` to reflect it (new/changed endpoints, response fields, semantics) —
|
||||
treat
|
||||
this doc
|
||||
update as part of the feature's Definition of Done, not an optional follow-up.
|
||||
- Never add a `Co-Authored-By:` line (or any AI-attribution trailer) to commit messages.
|
||||
@@ -38,34 +55,32 @@
|
||||
server runs at `http://localhost:8787` with this repo already ingested as project `ac`. Prefer it
|
||||
over grep/Explore for call graphs, callers/callees, DB access, dataflow, and module overviews — it's
|
||||
exactly the tool this project builds, so using it here is both faster and the best test of its own
|
||||
output. Usage guide: `x-docs/mcp-api-usage-ac-implementation.md`. Re-ingest after code changes
|
||||
output. Usage guide: `x-docs/agent-api-usage-ac-implementation.md`. Re-ingest after code changes
|
||||
(`ac refresh` / `POST /api/projects/ac/refresh`, or `--deep`/`?deep=true` for a full field-level
|
||||
pass) before trusting query results.
|
||||
- **Tool priority: MCP first, then REST API, then the `ac` CLI, then grep/Explore.** Try the
|
||||
`mcp__agenticcode__*` MCP tools before anything else. If an MCP call fails or misbehaves (e.g. a
|
||||
session/protocol error), fall back to the equivalent REST endpoint
|
||||
(`GET /api/projects/ac/modules/{name}/...`) rather than abandoning the API — don't let one broken
|
||||
MCP call push you straight to grep. The `ac <command>` CLI (`ac callers`, `ac callees`,
|
||||
- **Tool priority: REST API first, then the `ac` CLI, then grep/Explore.** Query the REST endpoints
|
||||
(`GET /api/projects/ac/modules/{name}/...`) before anything else. The `ac <command>` CLI (`ac callers`,
|
||||
`ac callees`,
|
||||
`ac call-tree`, `ac context`, `ac db-accesses`, `ac refresh`, ...) is a convenience wrapper over the same REST API,
|
||||
useful for quick manual checks. **If the server is unavailable** (`http://localhost:8787`
|
||||
unreachable), try starting it first with `./deploy.sh up` before falling back. **Only when it still
|
||||
can't answer the question** (info the API doesn't expose, or the analysis needs exact source
|
||||
text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent) exactly as you
|
||||
would on any other codebase.
|
||||
- **If a needed capability is missing from MCP/API/CLI** (not just unreachable, but the question is
|
||||
unreachable), **ask the user to run `./manage-ac.sh deploy`** — never start it yourself (see Hard Rules).
|
||||
**Only when it still can't answer the question** (info the API doesn't expose, or the analysis needs
|
||||
exact source text/comments/formatting) fall back to normal code analysis (Read/Grep/Explore agent)
|
||||
exactly as you would on any other codebase.
|
||||
- **If a needed capability is missing from the API/CLI** (not just unreachable, but the question is
|
||||
one none of them can answer at all), don't silently fall back to grep and move on — after finishing
|
||||
the task with the grep-based fallback, use `AskUserQuestion` to tell the user what was missing and
|
||||
ask whether it should be added as a feature in `x-docs/roadmap.md`.
|
||||
|
||||
## 2. Mandatory 4-Phase Workflow
|
||||
|
||||
0. **Clarify Intent** -Assess if the user's request is fully clear.
|
||||
``0. **Clarify Intent** -Assess if the user's request is fully clear.
|
||||
- **Clear**: State your understanding and proceed.
|
||||
- **Unclear**: Use `AskUserQuestion` tool with concrete options. Repeat until unambiguous.
|
||||
1. **PROPOSAL** – Clear plan + impact. Wait for `OK PROPOSAL`.
|
||||
2. **VALIDATE** – Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`.
|
||||
3. **IMPLEMENT** – One unit at a time. Show exact diff only.
|
||||
4. **VERIFICATION** – Run relevant tests. Suggest a commit message, but do not commit unless asked (see Core Rules).
|
||||
4. **VERIFICATION** – Run relevant tests. Suggest a commit message, but do not commit unless asked (see Core Rules).``
|
||||
|
||||
Quarkus-based server for parsing, storing, and agentically querying source code (Natural/Software AG and Java).
|
||||
Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched with semantic information, and exposed via an agent-optimized API.
|
||||
@@ -73,14 +88,14 @@ Programs are parsed into a unified AST, persisted as a graph in Neo4j, enriched
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST + MCP)
|
||||
Parser Layer → Unified AST → Neo4j Graph DB → Enrichment → Agentic API (REST)
|
||||
```
|
||||
|
||||
- **Natural Parser**: custom implementation (no OSS parser available for Software AG Natural)
|
||||
- **Java Parser**: JavaParser library
|
||||
- **Graph Store**: Neo4j — nodes for modules, functions, variables, data structures, DB accesses
|
||||
- **Enrichment**: call graph, identifier index, data structures, ADABAS/SQL access patterns
|
||||
- **API**: Quarkus REST (JAX-RS) + MCP endpoint, optimized for AI agent tool use
|
||||
- **API**: Quarkus REST (JAX-RS), optimized for AI agent tool use
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -107,8 +122,7 @@ agenticcode/
|
||||
│ ├── src/main/java/de/agenticcode/
|
||||
│ │ ├── api/ # JAX-RS Resources
|
||||
│ │ ├── service/ # Business logic
|
||||
│ │ ├── enrichment/ # Enrichment pipelines
|
||||
│ │ └── mcp/ # MCP tool endpoints
|
||||
│ │ └── enrichment/ # Enrichment pipelines
|
||||
│ └── src/main/resources/
|
||||
│ └── application.properties
|
||||
├── ac-parser-natural/ # Natural parser module
|
||||
@@ -155,7 +169,7 @@ mvn test -pl ac-parser-natural
|
||||
Requirements: Java 21+, Maven 3.9+, Docker (for Testcontainers).
|
||||
|
||||
```bash
|
||||
# Start Neo4j locally for dev
|
||||
# Start Neo4j locally for dev — for the human to run, not the agent (see Hard Rules)
|
||||
docker run -p 7474:7474 -p 7687:7687 \
|
||||
-e NEO4J_AUTH=neo4j/agenticcode \
|
||||
neo4j:5
|
||||
@@ -220,7 +234,7 @@ The API is designed primarily for AI agent tool use:
|
||||
- `GET /api/search/identifier?name=...` — find an identifier across all modules
|
||||
- `POST /api/ingest` — parse and ingest source code into the graph
|
||||
|
||||
MCP endpoint structure: `@docs/api-endpoints.md`
|
||||
Endpoint structure: `@docs/api-endpoints.md`
|
||||
|
||||
## Natural Parser: Key Constructs
|
||||
|
||||
@@ -281,5 +295,4 @@ mvn test -Dtest="*IT"
|
||||
| Multi-module Maven | Parsers are independently testable and replaceable |
|
||||
| Custom Natural parser | No OSS parser available for Software AG Natural |
|
||||
| JavaParser over tree-sitter | Mature Java library, type-safe AST API |
|
||||
| MCP alongside REST | Native integration with Claude Code and other AI agents |
|
||||
|
||||
|
||||
583
README.md
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
|
||||
`deploy.sh` wrapper around it.
|
||||
|
||||
**Full stack (recommended):**
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/your-org/agenticcode.git
|
||||
cd agenticcode
|
||||
|
||||
# Build, (re)build + start neo4j + ac-code-server, and install the `ac` CLI
|
||||
./deploy.sh up
|
||||
mvn test # all tests (integration tests use Testcontainers Neo4j)
|
||||
mvn test -Dtest="*Test" # unit tests only
|
||||
mvn test -Dtest="*IT" # integration tests only (need Docker)
|
||||
```
|
||||
|
||||
`./deploy.sh up` builds the project, brings the Compose stack up (leaving an already-running
|
||||
Neo4j untouched), and installs the `ac` CLI launcher. Other subcommands: `./deploy.sh stop`
|
||||
(stop only the server), `./deploy.sh logs [-f]`, `./deploy.sh cli` (rebuild the CLI only).
|
||||
|
||||
**Dev mode (hot reload):** run only Neo4j from Compose and the server from Maven:
|
||||
|
||||
```bash
|
||||
mvn clean install
|
||||
|
||||
# Start just the Neo4j service from docker-compose.yml
|
||||
docker compose up -d neo4j
|
||||
|
||||
# Run the server in dev mode (hot reload)
|
||||
cd ac-code-server && mvn quarkus:dev
|
||||
```
|
||||
|
||||
The API is available at `http://localhost:8787/api` and the Neo4j browser at `http://localhost:7474`
|
||||
(credentials `neo4j` / `agenticcode`).
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
`ac-cli` is a standalone command-line client for ingesting source code and querying the graph, useful for bulk-loading a codebase or scripting agent workflows without going through HTTP directly.
|
||||
|
||||
```bash
|
||||
# Build the CLI (produces an executable uber-jar)
|
||||
mvn -pl ac-cli -am package
|
||||
|
||||
# Run it
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar --help
|
||||
```
|
||||
|
||||
By default the CLI talks to `http://localhost:8787`. Override with `-s/--server <url>`, the `AC_SERVER_URL` environment
|
||||
variable, or the config file `~/.agenticcode/config.properties` (key `server.url`, written automatically by `connect`).
|
||||
|
||||
Most commands operate on a project. Select one with `-p/--project <name>`, the `AC_PROJECT` environment variable, the
|
||||
config file (key `project`, written automatically by `use`), or by running `use <project>` in the interactive shell.
|
||||
|
||||
### Interactive shell
|
||||
|
||||
Running the jar with no arguments starts a `psql`-like interactive shell with line editing and history (`~/.agenticcode_history`):
|
||||
|
||||
```bash
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
```
|
||||
AgenticCode interactive shell. Type 'help' for commands, 'exit' to quit.
|
||||
agenticcode> connect http://my-server:8787
|
||||
Connected to http://my-server:8787
|
||||
agenticcode> project create my-app -d "My application"
|
||||
agenticcode> use my-app
|
||||
Using project my-app
|
||||
agenticcode> callers MY-MODULE
|
||||
[ ... ]
|
||||
agenticcode> ingest ./src/main/natural
|
||||
OK ./src/main/natural/FOO.nat
|
||||
...
|
||||
agenticcode> exit
|
||||
```
|
||||
|
||||
`connect <url>` sets the server for the rest of the session and persists it to the config file (overridden by `-s` on an
|
||||
individual command). `use <project>` does the same for the project (overridden by `-p`). `help`/`?` shows available
|
||||
commands, `exit`/`quit` ends the session.
|
||||
|
||||
### Managing projects
|
||||
|
||||
```bash
|
||||
# Create a project
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project create my-app -d "My application"
|
||||
|
||||
# Update its description
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project update my-app -d "New description"
|
||||
|
||||
# List all projects
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project list
|
||||
|
||||
# Delete a project and all its ingested data
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar project delete my-app
|
||||
```
|
||||
|
||||
### Ingest files or folders
|
||||
|
||||
Recursively walks a file or directory; `.java` files are sent to `/api/projects/{project}/ingest/java` and `.nat`/`.nsn`
|
||||
files to `/api/projects/{project}/ingest/natural`. Files with other extensions are skipped.
|
||||
|
||||
```bash
|
||||
# Ingest a single file into the selected project
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest src/main/java/com/example/Foo.java
|
||||
|
||||
# Ingest an entire directory tree into an explicit project
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest /path/to/natural-sources -p my-app
|
||||
|
||||
# Against a non-default server
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar ingest ./src -p my-app -s http://my-server:8787
|
||||
```
|
||||
|
||||
Output shows `OK`/`FAIL`/`SKIP`/`ERROR` per file plus a summary line; the process exits non-zero if any file failed.
|
||||
|
||||
### Query the graph
|
||||
|
||||
```bash
|
||||
# Who calls this module?
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callers MY-MODULE -p my-app
|
||||
|
||||
# What does this module call?
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar callees MY-MODULE -p my-app
|
||||
|
||||
# Which DB tables does this module read/write?
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar db-accesses MY-MODULE -p my-app
|
||||
|
||||
# Find an identifier across all modules
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier myVariable -p my-app
|
||||
|
||||
# List all identifiers in the project
|
||||
java -jar ac-cli/target/ac-cli-1.0.0-SNAPSHOT.jar search-identifier -p my-app
|
||||
```
|
||||
|
||||
Each query command prints the API's JSON response, pretty-printed. If no project is selected (no `use`, `-p`,
|
||||
`AC_PROJECT`, or config file entry), the command fails with an error.
|
||||
Integration tests spin up their own Neo4j via Testcontainers — independent of the Compose stack.
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Status | Parser |
|
||||
|---|---|---|
|
||||
| Natural (Software AG) | 🚧 In development | Custom |
|
||||
| Java | 🚧 In development | JavaParser |
|
||||
| Python | 📋 Planned | — |
|
||||
| COBOL | 📋 Planned | — |
|
||||
| Language | Status | Parser |
|
||||
|-----------------------|-------------|------------|
|
||||
| Natural (Software AG) | ✅ Supported | Custom |
|
||||
| Java | ✅ Supported | JavaParser |
|
||||
| Python | 📋 Planned | — |
|
||||
| COBOL | 📋 Planned | — |
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
@@ -30,7 +32,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
return new ApiClient(baseUrl);
|
||||
}
|
||||
|
||||
protected static String encode(String value) {
|
||||
static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -45,6 +47,32 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
return path + (path.contains("?") ? "&" : "?") + key + "=" + value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an URL-encoded {@code key=value} to {@code path}, choosing {@code ?} or {@code &} based
|
||||
* on whether the path already has a query string. Skips when {@code value} is {@code null}.
|
||||
*/
|
||||
protected static String appendQuery(String path, String key, @org.jspecify.annotations.Nullable String value) {
|
||||
if (value == null) {
|
||||
return path;
|
||||
}
|
||||
return path + (path.contains("?") ? "&" : "?") + key + "=" + encode(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 131: says so when the server cut the answer. Written to <b>stderr</b> so piping the body
|
||||
* into {@code jq} stays clean, and printed at all because the alternative — a capped list that
|
||||
* looks like the whole set — is what made a real audit report 17 missing annotations that were
|
||||
* never missing.
|
||||
*/
|
||||
private static void warnIfTruncated(ApiClient.ApiResponse response) {
|
||||
if (!"true".equalsIgnoreCase(String.valueOf(response.header("X-AC-Truncated")))) {
|
||||
return;
|
||||
}
|
||||
@Nullable String total = response.header("X-AC-Total-Count");
|
||||
System.err.println("note: this answer is truncated" + (total == null ? "" : " (" + total + " rows match)")
|
||||
+ " — re-run with a larger --limit, page with --offset, or use --count-only");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the response body and returns an exit code derived from the HTTP status.
|
||||
*/
|
||||
@@ -53,6 +81,7 @@ abstract class AbstractApiCommand implements Callable<Integer> {
|
||||
if (!pretty.isBlank()) {
|
||||
System.out.println(pretty);
|
||||
}
|
||||
warnIfTruncated(response);
|
||||
if (!response.isSuccess()) {
|
||||
System.err.println("HTTP " + response.statusCode());
|
||||
return 1;
|
||||
|
||||
@@ -23,9 +23,14 @@ import java.util.concurrent.Callable;
|
||||
ProjectCommand.class,
|
||||
RefreshCommand.class,
|
||||
CallersCommand.class,
|
||||
FunctionCallersCommand.class,
|
||||
CalleesCommand.class,
|
||||
CallTreeCommand.class,
|
||||
ReachesCommand.class,
|
||||
DuplicatesCommand.class,
|
||||
EgoGraphCommand.class,
|
||||
DbAccessesCommand.class,
|
||||
WorkfileAccessesCommand.class,
|
||||
SqlStatementsCommand.class,
|
||||
ContextCommand.class,
|
||||
FunctionsCommand.class,
|
||||
@@ -38,19 +43,32 @@ import java.util.concurrent.Callable;
|
||||
DbTableColumnsCommand.class,
|
||||
EntityColumnsCommand.class,
|
||||
SearchIdentifierCommand.class,
|
||||
SearchReferencesCommand.class,
|
||||
RestEndpointsCommand.class,
|
||||
CounterpartsCommand.class,
|
||||
StoreCommand.class,
|
||||
StoreAccessesCommand.class,
|
||||
BindingsCommand.class,
|
||||
ThemeCommand.class,
|
||||
ThemeUsagesCommand.class,
|
||||
StylesCommand.class,
|
||||
ModulesCommand.class,
|
||||
LocCommand.class,
|
||||
ModuleDataStructuresCommand.class,
|
||||
PayloadCommand.class,
|
||||
CommentsCommand.class,
|
||||
DispatchTableCommand.class,
|
||||
DynamicCallsCommand.class,
|
||||
DigestCommand.class,
|
||||
FunctionOverridesCommand.class,
|
||||
SearchValueCommand.class,
|
||||
SearchAnnotationCommand.class,
|
||||
SearchSourceCommand.class,
|
||||
InspectNodeCommand.class,
|
||||
NodeSourceCommand.class,
|
||||
ModuleSourceCommand.class,
|
||||
VersionCommand.class,
|
||||
ClearCommand.class
|
||||
FileSourceCommand.class,
|
||||
VersionCommand.class
|
||||
}
|
||||
)
|
||||
public final class AgenticCodeCli implements Callable<Integer> {
|
||||
|
||||
@@ -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,7 +15,11 @@ final class CallTreeCommand extends AbstractProjectCommand {
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Option(names = "--depth", description = "Maximum number of CALLS hops to traverse")
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Maximum number of module hops to traverse (a call made from "
|
||||
+ "inside a subroutine is still one hop)")
|
||||
int depth = -1;
|
||||
|
||||
@Override
|
||||
@@ -24,7 +29,7 @@ final class CallTreeCommand extends AbstractProjectCommand {
|
||||
if (depth >= 0) {
|
||||
path += "?depth=" + depth;
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Deletes all projects and data, leaving an empty Neo4j database.
|
||||
*/
|
||||
@Command(name = "clear", mixinStandardHelpOptions = true, description = "Delete ALL projects and data (empties the database)")
|
||||
final class ClearCommand extends AbstractApiCommand {
|
||||
|
||||
@Option(names = {"-y", "--yes"}, description = "Skip the confirmation prompt")
|
||||
boolean yes;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
if (!yes) {
|
||||
System.out.print("Delete ALL projects and data? This cannot be undone. (y/N) ");
|
||||
System.out.flush();
|
||||
String answer = new java.io.BufferedReader(new java.io.InputStreamReader(System.in)).readLine();
|
||||
if (answer == null || !answer.trim().equalsIgnoreCase("y")) {
|
||||
System.out.println("Aborted.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return printResponse(apiClient().delete("/api/projects"));
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ final class CliConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* The ac-cli version stamped into the jar at build time by {@code deploy.sh}
|
||||
* The ac-cli version stamped into the jar at build time by {@code manage-ac.sh}
|
||||
* ({@code stamp_cli_version}), or {@code "dev"} for a jar built outside that script.
|
||||
*/
|
||||
static String bundledVersion() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Item 49: prints the bounded call-graph neighbourhood (nodes + edges) around a module.
|
||||
*/
|
||||
@Command(name = "ego-graph", mixinStandardHelpOptions = true,
|
||||
description = "Bounded call-graph neighbourhood (nodes + edges) around a module")
|
||||
final class EgoGraphCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--depth", description = "Max call hops from the module (clamped to the configured maximum)")
|
||||
int depth = -1;
|
||||
|
||||
@Option(names = "--direction", description = "Traversal direction: out (callees), in (callers) or both")
|
||||
String direction = "";
|
||||
|
||||
@Option(names = "--limit", description = "Cap on the number of nodes returned (BFS order)")
|
||||
int limit = -1;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName) + "/graph";
|
||||
if (!direction.isEmpty()) {
|
||||
path += "?direction=" + encode(direction);
|
||||
}
|
||||
path = appendQuery(path, "depth", depth);
|
||||
path = appendQuery(path, "limit", limit);
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Shows the source text for a project file by its relative path (not a module name) — the whole file
|
||||
* by default, or a line range with {@code --start-line}/{@code --end-line}. Use for files that aren't
|
||||
* standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module. Read from disk.
|
||||
*/
|
||||
@Command(name = "file-source", mixinStandardHelpOptions = true, description = "Show a file's source by relative path (whole file, or a line range)")
|
||||
final class FileSourceCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Relative source file path (e.g. as reported by data-structure fields)")
|
||||
String file;
|
||||
|
||||
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
|
||||
int startLine = -1;
|
||||
|
||||
@Option(names = "--end-line", description = "Last line to include (1-based); omit with --start-line for the whole file")
|
||||
int endLine = -1;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/source?file=" + encode(file);
|
||||
path = appendQuery(path, "startLine", startLine);
|
||||
path = appendQuery(path, "endLine", endLine);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Lists the FUNCTION-level callers of a subroutine/method — who PERFORMs (Natural) or calls (Java
|
||||
* cross-class) the given function in a module, with call-site line numbers. Finer-grained than the
|
||||
* module-level {@code callers} command (item 52).
|
||||
*/
|
||||
@Command(name = "function-callers", mixinStandardHelpOptions = true,
|
||||
description = "List the subroutines/methods that PERFORM/call a given function in a module")
|
||||
final class FunctionCallersCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Module name that defines the subroutine/method")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "1", description = "Subroutine/method name")
|
||||
String functionName;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName)
|
||||
+ "/functions/" + encode(functionName) + "/callers";
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -89,7 +89,7 @@ final class InteractiveShell {
|
||||
terminal.writer().println("ac-cli version " + result.cliVersion());
|
||||
if (result.mismatched()) {
|
||||
terminal.writer().println("ERROR: version mismatch — ac-cli is v" + result.cliVersion()
|
||||
+ " but the server is v" + result.serverVersion() + ". Run './deploy.sh cli' to reinstall a matching ac-cli.");
|
||||
+ " but the server is v" + result.serverVersion() + ". Run './manage-ac.sh cli' to reinstall a matching ac-cli.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,32 +1,39 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Shows the source-text snippet for a module by name and line range, read from the project root
|
||||
* on disk.
|
||||
* Shows the source text for a module by name — the whole file by default, or a line range when
|
||||
* {@code --start-line}/{@code --end-line} are given. Read from the project root on disk.
|
||||
*/
|
||||
@Command(name = "module-source", mixinStandardHelpOptions = true, description = "Show a source snippet for a module by line range")
|
||||
@Command(name = "module-source", mixinStandardHelpOptions = true, description = "Show a module's source (whole file, or a line range)")
|
||||
final class ModuleSourceCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Option(names = "--start-line", required = true, description = "First line to include (1-based)")
|
||||
int startLine;
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Option(names = "--end-line", required = true, description = "Last line to include (1-based)")
|
||||
int endLine;
|
||||
@Option(names = "--start-line", description = "First line to include (1-based); omit with --end-line for the whole file")
|
||||
int startLine = -1;
|
||||
|
||||
@Option(names = "--end-line", description = "Last line to include (1-based); omit with --start-line for the whole file")
|
||||
int endLine = -1;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/modules/" + encode(moduleName) + "/source"
|
||||
+ "?startLine=" + startLine + "&endLine=" + endLine;
|
||||
return printResponse(apiClient().get(path));
|
||||
String path = projectPath() + "/modules/" + encode(moduleName) + "/source";
|
||||
// Whole file when neither bound is given; otherwise pass both (the server rejects a
|
||||
// half-open range).
|
||||
path = appendQuery(path, "startLine", startLine);
|
||||
path = appendQuery(path, "endLine", endLine);
|
||||
return printResponse(apiClient().get(selector.append(path)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
|
||||
30
ac-cli/src/main/java/com/agenticcode/cli/PayloadCommand.java
Normal file
30
ac-cli/src/main/java/com/agenticcode/cli/PayloadCommand.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Mixin;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Shows the XML wire payload contract of a module: the tag/field/direction triples extracted from
|
||||
* the {@code ADD-XML-LINE} emit idiom.
|
||||
*/
|
||||
@Command(name = "payload", mixinStandardHelpOptions = true, description = "Show the XML payload contract for a module")
|
||||
final class PayloadCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Module name")
|
||||
String moduleName;
|
||||
|
||||
@Mixin
|
||||
ModuleSelector selector = new ModuleSelector();
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(selector.append(projectPath() + "/modules/" + encode(moduleName) + "/payload")));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,30 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine.*;
|
||||
import picocli.CommandLine.Model.CommandSpec;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
import picocli.CommandLine.Spec;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* Groups project management subcommands: create, update, delete, clear, list.
|
||||
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
|
||||
*/
|
||||
@Command(
|
||||
name = "project",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Create, update, delete, clear or list projects",
|
||||
description = "Create, update, delete, recreate, rename, show or list projects",
|
||||
subcommands = {
|
||||
ProjectCommand.CreateCommand.class,
|
||||
ProjectCommand.UpdateCommand.class,
|
||||
ProjectCommand.DeleteCommand.class,
|
||||
ProjectCommand.ClearCommand.class,
|
||||
ProjectCommand.RecreateCommand.class,
|
||||
ProjectCommand.RenameCommand.class,
|
||||
ProjectCommand.ShowCommand.class,
|
||||
ProjectCommand.ListCommand.class
|
||||
}
|
||||
)
|
||||
@@ -54,15 +58,35 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
|
||||
List<String> excludeDirs = List.of();
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Option(names = {"-l", "--language"}, required = true,
|
||||
description = "Project source language (natural/java/typescript)")
|
||||
String language;
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
|
||||
String userExitDir = "";
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
|
||||
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs)));
|
||||
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
|
||||
language, generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "update", mixinStandardHelpOptions = true,
|
||||
description = "Update a project's description, root folder and/or exclude-dirs (unset fields are left unchanged)")
|
||||
description = "Update a project's description, root, exclude-dirs, language, counterparts and/or generated/user-exit dirs (unset fields are left unchanged)")
|
||||
static final class UpdateCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@@ -79,31 +103,133 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
|
||||
List<String> excludeDirs = List.of();
|
||||
|
||||
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/typescript)")
|
||||
String language = "";
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
|
||||
String userExitDir = "";
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().putJson("/api/projects/" + encode(name),
|
||||
new ProjectRequest(description.isBlank() ? null : description,
|
||||
root.isBlank() ? null : root,
|
||||
excludeDirs.isEmpty() ? null : excludeDirs)));
|
||||
excludeDirs.isEmpty() ? null : excludeDirs,
|
||||
language.isBlank() ? null : language,
|
||||
generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "delete", mixinStandardHelpOptions = true, description = "Delete a project and all its data")
|
||||
/**
|
||||
* Item 79: one verb for deleting, with the blast radius spelled out rather than implied by a missing
|
||||
* argument. {@code --all} and a project name are mutually exclusive and one is required — picocli
|
||||
* enforces that through the {@link ArgGroup}, so forgetting the name is a usage error, never an empty
|
||||
* database. This replaces {@code ac clear} and {@code ac project clear}, which were two commands of the
|
||||
* same name and wildly different reach ({@code ac project clear} was also a byte-for-byte duplicate of
|
||||
* this one).
|
||||
*/
|
||||
@Command(name = "delete", mixinStandardHelpOptions = true,
|
||||
description = "Delete one project, or every project with --all")
|
||||
static final class DeleteCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
String name;
|
||||
@ArgGroup(multiplicity = "1")
|
||||
Target target;
|
||||
@Option(names = {"-y", "--yes"}, description = "Skip the confirmation prompt (--all only)")
|
||||
boolean yes;
|
||||
|
||||
/**
|
||||
* Inherited verbatim from the old {@code ac clear}: only {@code --all} is guarded this way.
|
||||
*/
|
||||
private static boolean confirmed() throws IOException {
|
||||
System.out.print("Delete ALL projects and data? This cannot be undone. (y/N) ");
|
||||
System.out.flush();
|
||||
String answer = new BufferedReader(new InputStreamReader(System.in)).readLine();
|
||||
return answer != null && answer.trim().equalsIgnoreCase("y");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().delete("/api/projects/" + encode(name)));
|
||||
if (target.all) {
|
||||
if (!yes && !confirmed()) {
|
||||
System.out.println("Aborted.");
|
||||
return 0;
|
||||
}
|
||||
return printResponse(apiClient().delete("/api/projects"));
|
||||
}
|
||||
return printResponse(apiClient().delete("/api/projects/" + encode(Objects.requireNonNull(target.name))));
|
||||
}
|
||||
|
||||
static final class Target {
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
@Nullable
|
||||
String name;
|
||||
|
||||
@Option(names = "--all", description = "Delete ALL projects and data (empties the database)")
|
||||
boolean all;
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "clear", mixinStandardHelpOptions = true,
|
||||
description = "Delete a project and all its data (alias of 'delete')")
|
||||
static final class ClearCommand extends AbstractApiCommand {
|
||||
/**
|
||||
* Item 202: rename a project in place (nodes, overrides and other projects' counterparts follow).
|
||||
*/
|
||||
@Command(name = "rename", mixinStandardHelpOptions = true,
|
||||
description = "Rename a project; every node, override and counterpart reference follows")
|
||||
static final class RenameCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Current project name")
|
||||
String name;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "1", description = "New project name")
|
||||
String newName;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().postJson("/api/projects/" + encode(name) + "/rename",
|
||||
java.util.Map.of("newName", newName)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 78: re-initialise a project from its own stored config — no need to look up and re-type
|
||||
* {@code root}/{@code language}/{@code generatedDir}/{@code userExitDir}, and no window in which they
|
||||
* could be lost. Refuses with {@code 400 ROOT_NOT_FOUND} — before deleting anything — if the stored
|
||||
* root no longer resolves.
|
||||
*/
|
||||
@Command(name = "recreate", mixinStandardHelpOptions = true,
|
||||
description = "Wipe a project's graph and ingest it again from its stored config")
|
||||
static final class RecreateCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
String name;
|
||||
|
||||
@Option(names = "--deep", description = "Run the full field-level ingest (slower)")
|
||||
boolean deep;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
String path = "/api/projects/" + encode(name) + "/recreate" + (deep ? "?deep=true" : "");
|
||||
return printResponse(apiClient().post(path));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "show", mixinStandardHelpOptions = true,
|
||||
description = "Show one project's config and its last whole-root ingest (ingestedAt, mode, file counts)")
|
||||
static final class ShowCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Project name")
|
||||
@@ -111,7 +237,7 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().delete("/api/projects/" + encode(name)));
|
||||
return printResponse(apiClient().get("/api/projects/" + encode(name)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import java.util.List;
|
||||
* Request body for {@code POST}/{@code PUT /api/projects/{project}}.
|
||||
*
|
||||
* <p>{@code root} is the server-side path the project's sources live under (required on create);
|
||||
* {@code excludeDirs} are path components skipped when scanning {@code root}. On update, {@code null}
|
||||
* fields leave the stored values unchanged.
|
||||
* {@code excludeDirs} are path components skipped when scanning {@code root}. {@code language} is
|
||||
* required on create (item 47); {@code generatedDir}/{@code userExitDir} are directory names for the
|
||||
* generated vs. user-exit LoC split (set together or not at all). On update, {@code null} fields
|
||||
* leave the stored values unchanged.
|
||||
*/
|
||||
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
|
||||
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable List<String> counterparts) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,25 @@ final class RefreshCommand extends AbstractProjectCommand {
|
||||
@Option(names = "--max-nodes", description = "Module refresh only: max number of files to ingest; default server setting")
|
||||
int maxNodes = -1;
|
||||
|
||||
@Option(names = "--neighborhood", description = "Module refresh only: also deep-ingest the module's transitive callers (not just callees/data areas)")
|
||||
boolean neighborhood;
|
||||
|
||||
@Option(names = "--paths", split = ",",
|
||||
description = "Whole-project refresh only: re-ingest just these relative source paths (repeatable or comma-separated) "
|
||||
+ "instead of the whole root. Paths matching no file come back under 'unresolved'")
|
||||
List<String> paths = List.of();
|
||||
|
||||
@Option(names = "--changed-only",
|
||||
description = "Whole-project refresh only: re-parse only files whose content differs from the graph's stored hash. "
|
||||
+ "Enrichment still runs in full; a changed Natural copycode re-parses everything (its text is inlined at parse time)")
|
||||
boolean changedOnly;
|
||||
|
||||
@Option(names = "--profile",
|
||||
description = "DIAGNOSTIC: run the enrichment steps under Cypher PROFILE and log the dominant operators of every step "
|
||||
+ "slower than 5 s, to see where a step spends its time. Costs 10-30% on its own, so this run's absolute "
|
||||
+ "timings are not comparable to a normal refresh")
|
||||
boolean profile;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
if (name != null && !name.isBlank()) {
|
||||
@@ -48,12 +67,24 @@ final class RefreshCommand extends AbstractProjectCommand {
|
||||
if (maxNodes >= 0) {
|
||||
params.add("maxNodes=" + maxNodes);
|
||||
}
|
||||
if (neighborhood) {
|
||||
params.add("scope=neighborhood");
|
||||
}
|
||||
if (!params.isEmpty()) {
|
||||
path += "?" + String.join("&", params);
|
||||
}
|
||||
return printResponse(apiClient().post(path));
|
||||
}
|
||||
String path = projectPath() + "/refresh" + (deep ? "?deep=true" : "");
|
||||
if (!paths.isEmpty()) {
|
||||
path = appendQuery(path, "paths", String.join(",", paths));
|
||||
}
|
||||
if (changedOnly) {
|
||||
path = appendQuery(path, "changedOnly", "true");
|
||||
}
|
||||
if (profile) {
|
||||
path = appendQuery(path, "profile", "true");
|
||||
}
|
||||
return printResponse(apiClient().post(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +12,27 @@ import picocli.CommandLine.Parameters;
|
||||
@Command(name = "search-identifier", mixinStandardHelpOptions = true, description = "Search for an identifier across all modules, or list all identifiers")
|
||||
final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
|
||||
@Parameters(index = "0", arity = "0..1", description = "Identifier name (omit to list all identifiers)")
|
||||
@Parameters(index = "0", arity = "0..1", description = "Identifier name — a Java type's short or fully-qualified form; a leading Natural sigil (# & +) is ignored (omit to list all identifiers)")
|
||||
@Nullable String name;
|
||||
|
||||
@Option(names = "--contains", description = "Match names that contain the given name (case-insensitive) instead of equalling it; requires a name")
|
||||
boolean contains;
|
||||
|
||||
@Option(names = "--type", description = "Node type filter (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE)")
|
||||
@Nullable String type;
|
||||
|
||||
@Option(names = "--module", description = "Scope to nodes of this module (by name)")
|
||||
@Nullable String module;
|
||||
|
||||
@Option(names = "--priority-module", description = "Pin this module's matches to the front (no filtering) so its local declaration survives the limit when a name recurs across modules")
|
||||
@Nullable String priorityModule;
|
||||
|
||||
@Option(names = "--source-file", description = "Scope to this exact source file (relative path)")
|
||||
@Nullable String sourceFile;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return")
|
||||
int limit = -1;
|
||||
|
||||
@@ -25,8 +43,16 @@ final class SearchIdentifierCommand extends AbstractProjectCommand {
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/search/identifier";
|
||||
if (name != null) {
|
||||
path += "?name=" + encode(name);
|
||||
path = appendQuery(path, "name", name);
|
||||
path = appendQuery(path, "type", type);
|
||||
path = appendQuery(path, "module", module);
|
||||
path = appendQuery(path, "priorityModule", priorityModule);
|
||||
path = appendQuery(path, "sourceFile", sourceFile);
|
||||
if (contains) {
|
||||
path = appendQuery(path, "contains", "true");
|
||||
}
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
import picocli.CommandLine.Parameters;
|
||||
|
||||
/**
|
||||
* Regex search over module source text (item 54): greps the source of all modules for a pattern and
|
||||
* prints {module, sourceFile, lineNo, line} hits. Case-insensitive by default.
|
||||
*/
|
||||
@Command(name = "search-source", mixinStandardHelpOptions = true, description = "Regex-search module source text (grep across the project)")
|
||||
final class SearchSourceCommand extends AbstractProjectCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Java regular expression to search for")
|
||||
String regex;
|
||||
|
||||
@Option(names = "--limit", description = "Max matches to return (default 200)")
|
||||
int limit = -1;
|
||||
|
||||
@Option(names = "--case-sensitive", description = "Match case-sensitively (default: case-insensitive)")
|
||||
boolean caseSensitive;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/search/source?regex=" + encode(regex);
|
||||
path = appendQuery(path, "limit", limit);
|
||||
if (caseSensitive) {
|
||||
path += "&ignoreCase=false";
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ final class VersionCheck {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param cliVersion the bundled ac-cli version ({@code "dev"} for a non-deploy.sh build)
|
||||
* @param cliVersion the bundled ac-cli version ({@code "dev"} for a non-manage-ac.sh build)
|
||||
* @param serverVersion the server's version, or {@code null} if the server couldn't be reached
|
||||
* @param errorStatus the HTTP status if the server request failed, otherwise {@code null}
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,7 @@ final class VersionCommand extends AbstractApiCommand {
|
||||
|
||||
if (result.mismatched()) {
|
||||
System.err.println("Version mismatch: ac-cli is v" + result.cliVersion() + " but the server is v"
|
||||
+ result.serverVersion() + " — run './deploy.sh cli' to reinstall a matching ac-cli.");
|
||||
+ result.serverVersion() + " — run './manage-ac.sh cli' to reinstall a matching ac-cli.");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
# Used as the lowest-priority fallback for the server URL, below session state
|
||||
# (connect), $AC_SERVER_URL, and ~/.agenticcode/config.properties.
|
||||
server.url=http://localhost:8787
|
||||
# Stamped by deploy.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via deploy.sh.
|
||||
version=11
|
||||
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
|
||||
version=334
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import picocli.CommandLine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Item 79: argument wiring of {@code ac project delete}.
|
||||
*
|
||||
* <p>The whole point of the command's shape is that the destructive case must be <em>asked for</em>. Before
|
||||
* this, {@code clear} was bound twice at two levels — {@code ac clear} emptied the entire database while
|
||||
* {@code ac project clear <name>} deleted one project — so a forgotten word was the difference between the
|
||||
* two. Here a forgotten project name is a usage error rather than a silently wider delete, and that is
|
||||
* enforced by picocli's {@code ArgGroup(multiplicity = "1")} rather than by a hand-written check.
|
||||
*
|
||||
* <p>These tests parse only. They never execute: {@code AbstractApiCommand.apiClient()} constructs its
|
||||
* client inside the call, so there is no seam to inject a fake, and executing would fire real HTTP at
|
||||
* localhost:8787. What the command <em>does</em> with a valid parse is therefore covered by
|
||||
* {@code ProjectRecreateIT} at the API level, not here.
|
||||
*/
|
||||
class ProjectDeleteArgsTest {
|
||||
|
||||
private static CommandLine cli() {
|
||||
return new CommandLine(new AgenticCodeCli());
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `ac project delete` must not mean "delete everything".
|
||||
*/
|
||||
@Test
|
||||
void deleteWithoutAnyTargetIsRejected() {
|
||||
CommandLine.MissingParameterException e = assertThrows(CommandLine.MissingParameterException.class,
|
||||
() -> cli().parseArgs("project", "delete"));
|
||||
String message = String.valueOf(e.getMessage());
|
||||
assertTrue(message.contains("--all") || message.toLowerCase().contains("project"),
|
||||
"the error should name what is missing; was: " + message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Naming a project and --all at once is contradictory, not a merge of the two.
|
||||
*/
|
||||
@Test
|
||||
void deleteRejectsAProjectNameTogetherWithAll() {
|
||||
assertThrows(CommandLine.MutuallyExclusiveArgsException.class,
|
||||
() -> cli().parseArgs("project", "delete", "upms", "--all"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAcceptsAProjectName() {
|
||||
assertDoesNotThrow(() -> cli().parseArgs("project", "delete", "upms"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAcceptsAllOnItsOwn() {
|
||||
assertDoesNotThrow(() -> cli().parseArgs("project", "delete", "--all"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The commands removed in item 79 must be gone, not merely undocumented.
|
||||
*/
|
||||
@Test
|
||||
void theClearCommandsNoLongerExist() {
|
||||
assertThrows(CommandLine.UnmatchedArgumentException.class,
|
||||
() -> cli().parseArgs("clear"));
|
||||
assertThrows(CommandLine.UnmatchedArgumentException.class,
|
||||
() -> cli().parseArgs("project", "clear", "upms"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recreateTakesAProjectNameAndAnOptionalDeepFlag() {
|
||||
assertNotNull(cli().parseArgs("project", "recreate", "upms"));
|
||||
assertNotNull(cli().parseArgs("project", "recreate", "upms", "--deep"));
|
||||
assertThrows(CommandLine.MissingParameterException.class,
|
||||
() -> cli().parseArgs("project", "recreate"));
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-java</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-neo4j-store</artifactId>
|
||||
@@ -51,8 +55,8 @@
|
||||
<artifactId>quarkus-smallrye-health</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkiverse.mcp</groupId>
|
||||
<artifactId>quarkus-mcp-server-sse</artifactId>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-openapi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jspecify</groupId>
|
||||
@@ -74,11 +78,6 @@
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkiverse.mcp</groupId>
|
||||
<artifactId>quarkus-mcp-server-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -134,6 +133,14 @@
|
||||
<plugin>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-mvn-plugins</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>bump-version</id>
|
||||
<goals>
|
||||
<goal>bump-version</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
@@ -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;
|
||||
@@ -10,17 +11,24 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* REST API for creating, updating, deleting and listing projects.
|
||||
*/
|
||||
@Path("/api/projects")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "projects", description = "Create, update, delete and list projects (ingest roots).")
|
||||
// All endpoints drive the blocking Neo4j driver (e.g. clearAll's full-graph DETACH DELETE), so
|
||||
// dispatch to a worker thread instead of blocking the Vert.x event loop.
|
||||
@Blocking
|
||||
@@ -31,14 +39,16 @@ public class ProjectResource {
|
||||
private final GraphRepository graphRepository;
|
||||
private final ProjectIngestService ingestService;
|
||||
private final ProjectRootResolver rootResolver;
|
||||
private final ProjectMetadataCache projectMetadataCache;
|
||||
private final boolean scanOnCreate;
|
||||
|
||||
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
|
||||
ProjectRootResolver rootResolver,
|
||||
ProjectRootResolver rootResolver, ProjectMetadataCache projectMetadataCache,
|
||||
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
|
||||
this.graphRepository = graphRepository;
|
||||
this.ingestService = ingestService;
|
||||
this.rootResolver = rootResolver;
|
||||
this.projectMetadataCache = projectMetadataCache;
|
||||
this.scanOnCreate = scanOnCreate;
|
||||
}
|
||||
|
||||
@@ -48,20 +58,95 @@ public class ProjectResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
static Response error(Response.Status status, String code, String message, Map<String, Object> details) {
|
||||
return Response.status(status)
|
||||
.entity(ErrorResponse.of(code, message, details))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "typescript");
|
||||
|
||||
private static @Nullable String normalize(@Nullable String value) {
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the item-47 fields. {@code language} is required on create (a project declares its
|
||||
* source language) and, if present anywhere, must be one of {@link #SUPPORTED_LANGUAGES}.
|
||||
* {@code generatedDir}/{@code userExitDir} are set together or not at all (a generated module is
|
||||
* annotated with its user-exit twin's metrics, which needs both). Returns an error {@link Response}
|
||||
* or {@code null} when valid.
|
||||
*/
|
||||
private static @Nullable Response validateLanguageAndDirs(ProjectRequest request, boolean onCreate) {
|
||||
String language = normalize(request.language());
|
||||
if (onCreate && language == null) {
|
||||
return error(Response.Status.BAD_REQUEST, "LANGUAGE_REQUIRED",
|
||||
"A project requires a 'language' (" + String.join("/", SUPPORTED_LANGUAGES) + ")");
|
||||
}
|
||||
if (language != null && !SUPPORTED_LANGUAGES.contains(language.toLowerCase())) {
|
||||
return error(Response.Status.BAD_REQUEST, "LANGUAGE_UNSUPPORTED",
|
||||
"Unsupported language '" + language + "'; expected one of " + SUPPORTED_LANGUAGES);
|
||||
}
|
||||
String generatedDir = normalize(request.generatedDir());
|
||||
String userExitDir = normalize(request.userExitDir());
|
||||
// Both-or-neither: enforced on create always, and on update whenever either is being set.
|
||||
boolean either = generatedDir != null || userExitDir != null;
|
||||
if ((onCreate || either) && (generatedDir == null) != (userExitDir == null)) {
|
||||
return error(Response.Status.BAD_REQUEST, "GENERATED_USEREXIT_PAIR",
|
||||
"'generatedDir' and 'userExitDir' must be provided together or not at all");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Operation(summary = "List all projects")
|
||||
public Uni<List<ProjectInfo>> list() {
|
||||
return graphRepository.listProjects();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{project}")
|
||||
@Operation(summary = "One project's config and last whole-root ingest",
|
||||
description = "Item 126: the project's configuration plus what its last whole-root ingest did "
|
||||
+ "(ingestedAt, mode, filesExamined/Persisted/Failed, serverVersion). 'ingest' is null when "
|
||||
+ "no whole-root ingest has been recorded, which is not the same as one that found nothing.")
|
||||
@APIResponse(responseCode = "200", description = "The project.")
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> get(@PathParam("project") String project) {
|
||||
return graphRepository.getProject(project)
|
||||
.map(info -> info == null
|
||||
? error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
"Project '" + project + "' does not exist")
|
||||
: Response.ok(info).build());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{project}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a project", description = "Registers a project with a server-side source root and Tier-1 scans it.")
|
||||
@APIResponse(responseCode = "201", description = "Created.")
|
||||
@APIResponse(responseCode = "400", description = "Invalid request.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "Project already exists.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> create(@PathParam("project") String project, ProjectRequest request) {
|
||||
if (request.root() == null || request.root().isBlank()) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "ROOT_REQUIRED",
|
||||
"A project requires a 'root' folder (server-side path to its sources)"));
|
||||
}
|
||||
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty())
|
||||
@Nullable Response invalid = validateLanguageAndDirs(request, true);
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts == null ? List.of() : counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> {
|
||||
scanTier1(project);
|
||||
@@ -100,8 +185,23 @@ public class ProjectResource {
|
||||
@PUT
|
||||
@Path("/{project}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Update a project")
|
||||
@APIResponse(responseCode = "200", description = "Updated.")
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> update(@PathParam("project") String project, ProjectRequest request) {
|
||||
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs())
|
||||
@Nullable Response invalid = validateLanguageAndDirs(request, false);
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> Response.ok().build();
|
||||
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
@@ -111,12 +211,60 @@ public class ProjectResource {
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Operation(summary = "Clear the whole graph", description = "Deletes every node/edge across all projects.")
|
||||
@APIResponse(responseCode = "204", description = "Cleared.")
|
||||
public Uni<Response> clearAll() {
|
||||
return graphRepository.clearAll().replaceWith(Response.noContent().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 202: rename a project in place — every node, override and counterpart reference follows.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{project}/rename")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Rename a project (item 202)",
|
||||
description = "Rewrites the project key on every node and override and in other projects' counterparts lists. "
|
||||
+ "Batched; an interrupted rename is finished by re-running it.")
|
||||
@APIResponse(responseCode = "200", description = "The renamed project.")
|
||||
@APIResponse(responseCode = "400", description = "newName missing, blank or equal to the current name.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "A project with the new name already exists.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> rename(@PathParam("project") String project, RenameRequest request) {
|
||||
String newName = request == null || request.newName() == null ? "" : request.newName().trim();
|
||||
if (newName.isEmpty() || newName.equals(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "INVALID_REQUEST",
|
||||
"newName is required and must differ from the current name"));
|
||||
}
|
||||
return graphRepository.renameProject(project, newName)
|
||||
.flatMap(result -> switch (result) {
|
||||
case SUCCESS -> {
|
||||
projectMetadataCache.invalidate(project);
|
||||
projectMetadataCache.invalidate(newName);
|
||||
yield graphRepository.getProject(newName).map(info -> Response.ok(info).build());
|
||||
}
|
||||
case NOT_FOUND -> Uni.createFrom().item(error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
"Project '" + project + "' does not exist"));
|
||||
case CONFLICT -> Uni.createFrom().item(error(Response.Status.CONFLICT, "PROJECT_EXISTS",
|
||||
"Project '" + newName + "' already exists"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of {@code POST /api/projects/{project}/rename}.
|
||||
*/
|
||||
public record RenameRequest(@Nullable String newName) {
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{project}")
|
||||
@Operation(summary = "Delete a project")
|
||||
@APIResponse(responseCode = "204", description = "Deleted.")
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> delete(@PathParam("project") String project) {
|
||||
return graphRepository.deleteProject(project)
|
||||
.map(result -> switch (result) {
|
||||
@@ -133,9 +281,39 @@ public class ProjectResource {
|
||||
* <p>{@code root} is required on create (the server-side path the project's sources live under)
|
||||
* and optional on update (null leaves it unchanged). {@code excludeDirs} are path components
|
||||
* skipped when scanning {@code root}; null on update leaves the stored list unchanged.
|
||||
* {@code language} is required on create (item 47). {@code generatedDir}/{@code userExitDir} are
|
||||
* directory names for the generated vs. user-exit LoC split; set together or not at all.
|
||||
*/
|
||||
public record ProjectRequest(@Nullable String description, @Nullable String root,
|
||||
@Nullable List<String> excludeDirs) {
|
||||
@Nullable List<String> excludeDirs, @Nullable String language,
|
||||
@Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable List<String> counterparts) {
|
||||
|
||||
/**
|
||||
* Pre-item-193 shape (no counterparts).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor for callers that predate the item-47 fields (language /
|
||||
* generated / user-exit dir all absent).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
|
||||
this(description, root, excludeDirs, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
|
||||
*/
|
||||
@Nullable List<String> normalizedCounterparts() {
|
||||
if (counterparts == null) {
|
||||
return null;
|
||||
}
|
||||
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
|
||||
}
|
||||
|
||||
List<String> excludeDirsOrEmpty() {
|
||||
return excludeDirs != null ? excludeDirs : List.of();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Exposes the server's name/version (see {@link VersionInfo}) over REST.
|
||||
*/
|
||||
@Path("/api/version")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "meta", description = "Server metadata.")
|
||||
public class VersionResource {
|
||||
|
||||
private final VersionInfo versionInfo;
|
||||
@@ -20,6 +23,7 @@ public class VersionResource {
|
||||
}
|
||||
|
||||
@GET
|
||||
@Operation(summary = "Server name and version")
|
||||
public VersionInfo version() {
|
||||
return versionInfo;
|
||||
}
|
||||
|
||||
@@ -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,568 +0,0 @@
|
||||
package com.agenticcode.codeserver.mcp;
|
||||
|
||||
import com.agenticcode.codeserver.service.*;
|
||||
import com.agenticcode.neo4jstore.graph.*;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import io.quarkiverse.mcp.server.Tool;
|
||||
import io.quarkiverse.mcp.server.ToolArg;
|
||||
import io.quarkiverse.mcp.server.ToolResponse;
|
||||
import io.smallrye.common.annotation.Blocking;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* MCP tools mirroring the read endpoints of {@code AnalysisResource}. Each tool delegates to the
|
||||
* same {@link GraphRepository} query the REST resource uses, so the graph is queried identically
|
||||
* whether an agent goes through REST or MCP.
|
||||
*
|
||||
* <p>All tools are {@link Blocking} (they drive the blocking Neo4j driver, exactly like the
|
||||
* {@code @Blocking} REST resource) and return a {@link ToolResponse} whose text content is the same
|
||||
* concise JSON the REST API returns. Every module/variable/data-structure tool takes the
|
||||
* {@code project} as its first argument and returns a {@code PROJECT_NOT_FOUND} tool error if it
|
||||
* does not exist.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@McpLogged
|
||||
public class McpQueryTools {
|
||||
|
||||
private static final int DEFAULT_PAGE_LIMIT = 50;
|
||||
private final GraphRepository graphRepository;
|
||||
private final McpSupport support;
|
||||
private final ProjectRootResolver rootResolver;
|
||||
private final DeepIngestCoordinator deepIngestCoordinator;
|
||||
private final SourceSnippetService sourceSnippetService;
|
||||
private final VersionInfo versionInfo;
|
||||
private final int defaultCallTreeDepth;
|
||||
private final int maxCallTreeDepth;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Projects & module discovery
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public McpQueryTools(GraphRepository graphRepository, McpSupport support,
|
||||
ProjectRootResolver rootResolver, DeepIngestCoordinator deepIngestCoordinator,
|
||||
SourceSnippetService sourceSnippetService,
|
||||
VersionInfo versionInfo,
|
||||
@ConfigProperty(name = "agenticcode.call-tree.default-depth", defaultValue = "3") int defaultCallTreeDepth,
|
||||
@ConfigProperty(name = "agenticcode.call-tree.max-depth", defaultValue = "10") int maxCallTreeDepth) {
|
||||
this.graphRepository = graphRepository;
|
||||
this.support = support;
|
||||
this.rootResolver = rootResolver;
|
||||
this.deepIngestCoordinator = deepIngestCoordinator;
|
||||
this.sourceSnippetService = sourceSnippetService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.defaultCallTreeDepth = defaultCallTreeDepth;
|
||||
this.maxCallTreeDepth = maxCallTreeDepth;
|
||||
}
|
||||
|
||||
@Tool(name = "version", description = "AgenticCode server name and version.")
|
||||
public ToolResponse version() {
|
||||
return support.ok(versionInfo);
|
||||
}
|
||||
|
||||
private static int effectiveLimit(@Nullable Integer limit) {
|
||||
return limit != null ? Math.max(limit, 0) : DEFAULT_PAGE_LIMIT;
|
||||
}
|
||||
|
||||
private static int effectiveOffset(@Nullable Integer offset) {
|
||||
return offset != null ? Math.max(offset, 0) : 0;
|
||||
}
|
||||
|
||||
private static DeepIngestRequired deepIngestHint(String module, ModuleIngestState state) {
|
||||
String status = state.exists() ? "NOT_DEEPLY_INGESTED" : "NOT_INGESTED";
|
||||
String detail = state.exists()
|
||||
? "Module '" + module + "' has only its call graph ingested; field-level dataflow requires a deep ingest."
|
||||
: "Module '" + module + "' is not ingested. Ingest the call graph and/or deep-ingest it.";
|
||||
return new DeepIngestRequired(status, module, detail, "ingest_module");
|
||||
}
|
||||
|
||||
@Tool(name = "list_projects", description = "List all ingested projects (name, description, root, excludeDirs).")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> listProjects() {
|
||||
return graphRepository.listProjects().map(support::ok);
|
||||
}
|
||||
|
||||
@Tool(name = "list_modules", description = "List MODULE nodes (name, sourceFile, moduleKind, loc, sloc) in a project, optionally filtered by source file, moduleKind (CLASS/INTERFACE/PROGRAM/SUBPROGRAM), and/or direct EXTENDS base class name. loc = physical lines, sloc = source (non-blank, non-comment) lines; null for modules ingested before this existed.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> listModules(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile,
|
||||
@ToolArg(description = "Optional module sub-kind to filter by (CLASS/INTERFACE/PROGRAM/SUBPROGRAM)", required = false) @Nullable String moduleKind,
|
||||
@ToolArg(description = "Optional base class name; lists only its direct EXTENDS subclasses", required = false) @Nullable String extendsName) {
|
||||
return withProject(project, () -> graphRepository.listModules(project, sourceFile, moduleKind, extendsName).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "project_loc", description = "Per-language LoC/SLoC rollup for a project: a breakdown by language (fileCount, loc, sloc) plus the project-wide total. loc = physical lines, sloc = source (non-blank, non-comment) lines computed per language. Each source file is counted once. Optionally narrow to one language ('natural'/'java') and/or one source file.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> projectLoc(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Optional language to filter by ('natural'/'java')", required = false) @Nullable String language,
|
||||
@ToolArg(description = "Optional source file path to filter by", required = false) @Nullable String sourceFile) {
|
||||
return withProject(project, () -> graphRepository.projectLoc(project, language, sourceFile).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "module_digest", description = "Small triage bundle for a module: description, function count, caller/callee names, DB table names, data-structure names + field counts.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleDigest(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name (not a file path)") String name) {
|
||||
return withProject(project, () -> graphRepository.moduleDigest(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "module_context", description = "Full context bundle for a module. Heavy sub-arrays (variableAccesses, large sqlStatements) are summarized unless requested via 'include'.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleContext(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Comma-separated sections to fully expand, e.g. 'variableAccesses,sqlStatements'", required = false) @Nullable String include,
|
||||
@ToolArg(description = "Max items for expanded sub-arrays (0 = unbounded)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Offset into expanded sub-arrays", required = false) @Nullable Integer offset) {
|
||||
int effLimit = limit != null ? Math.max(limit, 0) : 0;
|
||||
int effOffset = offset != null ? Math.max(offset, 0) : 0;
|
||||
return withProject(project, () -> graphRepository.moduleContext(project, name, include, effLimit, effOffset).map(support::ok));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Call graph
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool(name = "module_functions", description = "List functions (Natural subroutines / Java methods) declared in a module; set includeInherited to include inherited Java methods. 'kind' filters Java methods by modifier: abstract/final/overridable.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleFunctions(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Include inherited (Java) methods", required = false) @Nullable Boolean includeInherited,
|
||||
@ToolArg(description = "Optional Java modifier filter: abstract/final/overridable", required = false) @Nullable String kind) {
|
||||
boolean inherited = includeInherited != null && includeInherited;
|
||||
return withProject(project, () -> graphRepository.moduleFunctions(project, name, inherited, kind).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "function_overrides", description = "Concrete subclass overrides of a base-class method (Java virtual/template-method dispatch). Pass 'function' for one hook method, or omit it to get overrides for every abstract method of the base class at once, grouped by hook method.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> functionOverrides(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module (base class) name") String name,
|
||||
@ToolArg(description = "Base method name; omit for the bulk (all-abstract-methods) variant", required = false) @Nullable String function) {
|
||||
return withProject(project, () -> function == null || function.isBlank()
|
||||
? graphRepository.functionOverrides(project, name).map(support::ok)
|
||||
: graphRepository.functionOverrides(project, name, function).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "module_data_structures", description = "Data structures (Natural DEFINE DATA blocks / Java DTOs) referenced by a module.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleDataStructures(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name) {
|
||||
return withProject(project, () -> graphRepository.moduleDataStructures(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "module_dispatch_table", description = "Dispatch table for a module: dynamic-call lookup arrays and the module names their literals resolve to.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleDispatchTable(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name) {
|
||||
return withProject(project, () -> graphRepository.dispatchTable(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DB access
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool(name = "module_columns", description = "Entity/table columns exposed by a module that maps to a DB table (e.g. a JPA @Entity).")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleColumns(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name) {
|
||||
return withProject(project, () -> graphRepository.entityColumns(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
private static Collection<String> stepModules(List<DataflowStep> steps) {
|
||||
return steps.stream().map(DataflowStep::module).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
@Tool(name = "callees", description = "Modules/functions the given module calls (plus Java INJECTS/REFERENCES wiring). 'scope' filters by edge kind; resolveInterfaces hops interface callees to their implementations.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> callees(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset,
|
||||
@ToolArg(description = "Hop interface callees to their implementation(s) (Java)", required = false) @Nullable Boolean resolveInterfaces) {
|
||||
return withProject(project, () -> graphRepository.callees(project, name, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||||
resolveInterfaces != null && resolveInterfaces).map(support::ok));
|
||||
}
|
||||
|
||||
private static Collection<String> fieldFlowModules(List<FieldFlow> flows) {
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
for (FieldFlow flow : flows) {
|
||||
names.add(flow.producer());
|
||||
names.add(flow.consumer());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Search
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool(name = "db_accesses", description = "DB tables a module reads/writes and the access mode. With 'depth' > 0, includes accesses of transitively-called modules.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> dbAccesses(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
int effLimit = effectiveLimit(limit);
|
||||
int effOffset = effectiveOffset(offset);
|
||||
return withProject(project, () -> depth != null && depth > 0
|
||||
? graphRepository.dbAccessesTransitive(project, name, depth, effLimit, effOffset).map(support::ok)
|
||||
: graphRepository.dbAccesses(project, name, effLimit, effOffset).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "sql_statements", description = "SQL/ADABAS statements issued by a module. With 'depth' > 0, includes statements of transitively-called modules.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> sqlStatements(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Transitive depth (0/absent = direct only)", required = false) @Nullable Integer depth) {
|
||||
return withProject(project, () -> depth != null && depth > 0
|
||||
? graphRepository.sqlStatementsTransitive(project, name, depth).map(support::ok)
|
||||
: graphRepository.sqlStatements(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Variable access & dataflow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool(name = "data_structure_fields", description = "Fields of a data structure (Natural DEFINE DATA / Java DTO): name, type, level, redefines.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> dataStructureFields(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Data-structure name") String name) {
|
||||
return withProject(project, () -> graphRepository.dataStructureFields(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "db_table_columns", description = "Columns of a DB table (ADABAS view / SQL table).")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> dbTableColumns(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "DB table name") String name) {
|
||||
return withProject(project, () -> graphRepository.dbTableColumns(project, name).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "callers", description = "Modules/functions that call the given module. 'scope' filters by edge kind.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> callers(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Optional edge-kind scope filter", required = false) @Nullable String scope,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.callers(project, name, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
CallRefResponse::sourceFiles);
|
||||
}
|
||||
|
||||
@Tool(name = "search_value", description = "Find nodes whose literal/assigned value matches the given string across a project.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> searchValue(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Value to search for") String value,
|
||||
@ToolArg(description = "If true, case-insensitive substring match instead of exact", required = false) @Nullable Boolean contains,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
if (value.isBlank()) {
|
||||
return Uni.createFrom().item(support.error("MISSING_VALUE", "Argument 'value' is required"));
|
||||
}
|
||||
return withProject(project, () -> graphRepository.searchByValue(project, value, Boolean.TRUE.equals(contains),
|
||||
effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "search_annotation", description = "Find nodes (Java only) carrying an annotation whose name matches (substring, case-insensitive), optionally filtered by node type.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> searchAnnotation(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Annotation name / substring") String name,
|
||||
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
if (name.isBlank()) {
|
||||
return Uni.createFrom().item(support.error("MISSING_NAME", "Argument 'name' is required"));
|
||||
}
|
||||
if (type != null) {
|
||||
try {
|
||||
NodeType.valueOf(type.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
|
||||
}
|
||||
}
|
||||
return withProject(project, () -> graphRepository.searchAnnotation(project, name,
|
||||
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)).map(support::ok));
|
||||
}
|
||||
|
||||
@Tool(name = "inspect_node", description = "Every property of a single node by id (roadmap item 27). Node ids are regenerated on re-ingest, so only valid until the next one.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> inspectNode(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Node id") String id) {
|
||||
return withProject(project, () -> graphRepository.nodeById(project, id).map(node -> node == null
|
||||
? support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)")
|
||||
: support.ok(node)));
|
||||
}
|
||||
|
||||
@Tool(name = "call_tree", description = "Transitive call tree rooted at a module, up to 'depth' hops (clamped to the configured maximum). resolveInterfaces drops dead-end interface nodes whose implementations are already reached. followWiring also traverses Java INJECTS/REFERENCES edges (DI/class-literal wiring), not just CALLS.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> callTree(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth,
|
||||
@ToolArg(description = "Drop interface nodes that have a known implementation (Java)", required = false) @Nullable Boolean resolveInterfaces,
|
||||
@ToolArg(description = "Also traverse INJECTS/REFERENCES edges (Java DI/class-literal wiring), not just CALLS", required = false) @Nullable Boolean followWiring) {
|
||||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.callTree(project, name, effectiveDepth,
|
||||
resolveInterfaces != null && resolveInterfaces, followWiring != null && followWiring),
|
||||
CallTreeResponse::sourceFiles);
|
||||
}
|
||||
|
||||
@Tool(name = "search_identifier", description = "Find identifiers across a project by name (substring) and optional node type (MODULE, FUNCTION, VARIABLE, DATA_STRUCTURE, DB_TABLE).")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> searchIdentifier(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Identifier name / substring", required = false) @Nullable String name,
|
||||
@ToolArg(description = "Optional node type filter", required = false) @Nullable String type,
|
||||
@ToolArg(description = "Page size (default 50)", required = false) @Nullable Integer limit,
|
||||
@ToolArg(description = "Page offset (default 0)", required = false) @Nullable Integer offset) {
|
||||
if (type != null) {
|
||||
try {
|
||||
NodeType.valueOf(type.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Uni.createFrom().item(support.error("INVALID_TYPE", "Unknown node type '" + type + "'"));
|
||||
}
|
||||
}
|
||||
return withFanoutWarm(project,
|
||||
() -> graphRepository.searchIdentifier(project, name,
|
||||
type != null ? type.toUpperCase() : null, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
matches -> matches.stream().map(IdentifierMatch::sourceFile).filter(sf -> !sf.isEmpty()).distinct().toList());
|
||||
}
|
||||
|
||||
@Tool(name = "node_source", description = "Source-text snippet for a single node by id (roadmap item 28), read from the project root on disk.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> nodeSource(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Node id") String id) {
|
||||
return withProject(project, () -> graphRepository.nodeById(project, id).flatMap(node -> {
|
||||
if (node == null) {
|
||||
return Uni.createFrom().item(support.error("NODE_NOT_FOUND", "No node with id '" + id + "' in project '" + project + "' (ids are regenerated on re-ingest)"));
|
||||
}
|
||||
String sourceFile = (String) node.get("sourceFile");
|
||||
if (sourceFile == null || sourceFile.isEmpty()) {
|
||||
return Uni.createFrom().item(support.error("NO_SOURCE_FILE", "Node '" + id + "' is an unresolved placeholder with no source file"));
|
||||
}
|
||||
int startLine = ((Number) Objects.requireNonNull(node.get("startLine"))).intValue();
|
||||
int endLine = ((Number) Objects.requireNonNull(node.get("endLine"))).intValue();
|
||||
return sourceSnippet(project, sourceFile, startLine, endLine);
|
||||
}));
|
||||
}
|
||||
|
||||
@Tool(name = "module_source", description = "Source-text snippet for a module by name and line range (roadmap item 28), read from the project root on disk.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> moduleSource(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Module name") String name,
|
||||
@ToolArg(description = "Start line (1-indexed)") int startLine,
|
||||
@ToolArg(description = "End line (1-indexed, inclusive)") int endLine) {
|
||||
return withProject(project, () -> graphRepository.moduleSourceFile(project, name).flatMap(sourceFile -> sourceFile == null
|
||||
? Uni.createFrom().item(support.error("MODULE_NOT_FOUND", "No module '" + name + "' in project '" + project + "'"))
|
||||
: sourceSnippet(project, sourceFile, startLine, endLine)));
|
||||
}
|
||||
|
||||
@Tool(name = "variable_reads", description = "Functions that read a given variable, following the call graph up to 'depth' hops.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> variableReads(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Variable name") String name,
|
||||
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
|
||||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
|
||||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||||
return withProject(project, () -> graphRepository.variableReads(project, name, module, effectiveDepth).map(support::ok));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers (mirror AnalysisResource)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Tool(name = "variable_writes", description = "Functions that write a given variable, following the call graph up to 'depth' hops.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> variableWrites(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Variable name") String name,
|
||||
@ToolArg(description = "Optional module to scope to", required = false) @Nullable String module,
|
||||
@ToolArg(description = "Traversal depth (default configured, clamped 1..max)", required = false) @Nullable Integer depth) {
|
||||
int effectiveDepth = Math.clamp(depth != null ? depth : defaultCallTreeDepth, 1, maxCallTreeDepth);
|
||||
return withProject(project, () -> graphRepository.variableWrites(project, name, module, effectiveDepth).map(support::ok));
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up {@code sourceFile}'s stored content hash (item 41), then reads its {@code [startLine,
|
||||
* endLine]} slice with a stale check (see {@link #readSourceSnippet}).
|
||||
*/
|
||||
private Uni<ToolResponse> sourceSnippet(String project, String sourceFile, int startLine, int endLine) {
|
||||
return graphRepository.sourceHash(project, sourceFile)
|
||||
.map(expectedHash -> readSourceSnippet(project, sourceFile, startLine, endLine, expectedHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the project root and reads {@code [startLine, endLine]} of {@code sourceFile},
|
||||
* mapping root-resolution/IO failures to MCP tool errors. When {@code expectedHash} is non-null
|
||||
* and the file on disk no longer matches it (item 41), returns a {@code STALE_SOURCE} error
|
||||
* telling the caller to re-ingest, rather than slicing changed text.
|
||||
*/
|
||||
private ToolResponse readSourceSnippet(String project, String sourceFile, int startLine, int endLine,
|
||||
@Nullable String expectedHash) {
|
||||
String root;
|
||||
switch (rootResolver.resolve(project)) {
|
||||
case ProjectRootResolver.Resolved r -> root = r.project().root();
|
||||
case ProjectRootResolver.Failed failed -> {
|
||||
return support.error(failed.code(), failed.message());
|
||||
}
|
||||
}
|
||||
try {
|
||||
SourceSnippet snippet = sourceSnippetService.read(root, sourceFile, startLine, endLine, expectedHash);
|
||||
return support.ok(snippet);
|
||||
} catch (StaleSourceException e) {
|
||||
return support.error("STALE_SOURCE", e.detail());
|
||||
} catch (IOException e) {
|
||||
return support.error("SOURCE_READ_FAILED", "Failed to read '" + sourceFile + "': " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Tool(name = "flow_forward", description = "Forward field-level dataflow from a variable (where its value flows to). Requires the module to be deep-ingested.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> flowForward(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Variable name") String name,
|
||||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||||
return withFlowPathWarm(project, module,
|
||||
() -> graphRepository.flowForward(project, name, module, effectiveDepth),
|
||||
McpQueryTools::stepModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs {@code action} if the project exists, otherwise a {@code PROJECT_NOT_FOUND} tool error.
|
||||
*/
|
||||
private Uni<ToolResponse> withProject(String project, Supplier<Uni<ToolResponse>> action) {
|
||||
return graphRepository.projectExists(project).flatMap(exists -> exists
|
||||
? action.get()
|
||||
: Uni.createFrom().item(support.error("PROJECT_NOT_FOUND", "Project '" + project + "' does not exist")));
|
||||
}
|
||||
|
||||
@Tool(name = "flow_backward", description = "Backward field-level dataflow into a variable (where its value comes from). Requires the module to be deep-ingested.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> flowBackward(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Variable name") String name,
|
||||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||||
return withFlowPathWarm(project, module,
|
||||
() -> graphRepository.flowBackward(project, name, module, effectiveDepth),
|
||||
McpQueryTools::stepModules);
|
||||
}
|
||||
|
||||
@Tool(name = "field_flow", description = "Producer/consumer field-flow pairs for a variable within its module. Requires the module to be deep-ingested.")
|
||||
@Blocking
|
||||
public Uni<ToolResponse> fieldFlow(
|
||||
@ToolArg(description = "Project name") String project,
|
||||
@ToolArg(description = "Variable name") String name,
|
||||
@ToolArg(description = "Module the variable lives in", required = false) @Nullable String module,
|
||||
@ToolArg(description = "Traversal depth (default configured)", required = false) @Nullable Integer depth) {
|
||||
int effectiveDepth = depth != null ? depth : defaultCallTreeDepth;
|
||||
return withFlowPathWarm(project, module,
|
||||
() -> graphRepository.fieldFlow(project, name, module, effectiveDepth),
|
||||
McpQueryTools::fieldFlowModules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out / traversal deep-ingest warm (item 37, blocking-then-rerun): runs {@code query} against
|
||||
* the graph as-is, deep-ingests the modules the result surfaced ({@code surfaced}) via
|
||||
* {@link DeepIngestCoordinator#ensureDeepMany}, and — only if that warm actually deepened
|
||||
* something — re-runs {@code query} against the now-deeper graph so the response reflects
|
||||
* newly-resolved dynamic dispatch. When nothing changed (already deep, empty set, or the warm
|
||||
* failed) the first result is returned directly, so the query never pays for a redundant re-run.
|
||||
*
|
||||
* @param surfaced maps a result to the {@code sourceFile} paths it surfaced (the warm target)
|
||||
*/
|
||||
private <T> Uni<ToolResponse> withFanoutWarm(String project, Supplier<Uni<T>> query,
|
||||
Function<T, Collection<String>> surfaced) {
|
||||
return withProject(project, () -> query.get().flatMap(first ->
|
||||
deepIngestCoordinator.ensureDeepMany(project, surfaced.apply(first)).flatMap(changed ->
|
||||
changed ? query.get().map(support::ok) : Uni.createFrom().item(support.ok(first)))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-module {@code flow_*} path-ingest (item 37a), the MCP counterpart of
|
||||
* {@code AnalysisResource.withFlowPathWarm}: deep-ingests the named start {@code module} (falling
|
||||
* back to the agent-actionable deep-ingest hint if it does not resolve to {@code FULL}), then runs
|
||||
* {@code query} and drives a bounded ingest-and-re-traverse fixpoint via {@link #flowFixpoint} —
|
||||
* each round deep-ingests the frontier the current result surfaced
|
||||
* ({@link DeepIngestCoordinator#ensureFlowFrontier}) and re-runs the trace, so the flow crosses
|
||||
* into callees (notably dynamically-dispatched ones) as they become deeply ingested. With no
|
||||
* {@code module} it is a plain project-wide query.
|
||||
*/
|
||||
private <T> Uni<ToolResponse> withFlowPathWarm(String project, @Nullable String module,
|
||||
Supplier<Uni<List<T>>> query,
|
||||
Function<List<T>, Collection<String>> surfaced) {
|
||||
if (module == null || module.isBlank()) {
|
||||
return withProject(project, () -> query.get().map(support::ok));
|
||||
}
|
||||
String startModule = module;
|
||||
return withProject(project, () -> deepIngestCoordinator.ensureDeep(project, startModule)
|
||||
.flatMap(ignored -> graphRepository.moduleIngestState(project, startModule).flatMap(state ->
|
||||
state.isFull()
|
||||
? flowFixpoint(project, startModule, query, surfaced, deepIngestCoordinator.flowRounds())
|
||||
: Uni.createFrom().item(support.errorBody(deepIngestHint(startModule, state))))));
|
||||
}
|
||||
|
||||
/**
|
||||
* One iteration of the flow path-ingest fixpoint: runs {@code query}, and while rounds remain and
|
||||
* {@link DeepIngestCoordinator#ensureFlowFrontier} reports it deepened the frontier, re-traverses;
|
||||
* otherwise returns the current result. The frontier seed always includes the start {@code module}
|
||||
* (not just what the trace surfaced) so the first round pulls in the start module's not-yet-deep —
|
||||
* notably dynamically-dispatched — callees even when the trace is still empty. Bounded by
|
||||
* {@code roundsLeft} (configured {@code flow-rounds}) so it terminates even if the graph keeps
|
||||
* surfacing new callees.
|
||||
*/
|
||||
private <T> Uni<ToolResponse> flowFixpoint(String project, String module, Supplier<Uni<List<T>>> query,
|
||||
Function<List<T>, Collection<String>> surfaced, int roundsLeft) {
|
||||
return query.get().flatMap(result -> {
|
||||
if (roundsLeft <= 0) {
|
||||
return Uni.createFrom().item(support.ok(result));
|
||||
}
|
||||
Set<String> frontier = new LinkedHashSet<>();
|
||||
frontier.add(module);
|
||||
frontier.addAll(surfaced.apply(result));
|
||||
return deepIngestCoordinator.ensureFlowFrontier(project, frontier).flatMap(changed ->
|
||||
changed
|
||||
? flowFixpoint(project, module, query, surfaced, roundsLeft - 1)
|
||||
: Uni.createFrom().item(support.ok(result)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-actionable hint: a deep query needs the module (deep-)ingested first via the named MCP tool.
|
||||
*/
|
||||
public record DeepIngestRequired(String status, String module, String detail, String nextTool) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -12,14 +11,22 @@ import com.agenticcode.parsercore.ast.spi.LineCounter;
|
||||
import com.agenticcode.parserjava.JavaCoarseScanner;
|
||||
import com.agenticcode.parserjava.JavaLineCounter;
|
||||
import com.agenticcode.parserjava.JavaParser;
|
||||
import com.agenticcode.parsernatural.CopycodeResolver;
|
||||
import com.agenticcode.parsernatural.NaturalCoarseScanner;
|
||||
import com.agenticcode.parsernatural.NaturalLineCounter;
|
||||
import com.agenticcode.parsernatural.NaturalParser;
|
||||
import com.agenticcode.parsertypescript.CssLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner;
|
||||
import com.agenticcode.parsertypescript.TypeScriptLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptParser;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Parses a source file with the language-specific parser and persists the resulting
|
||||
@@ -32,14 +39,58 @@ public class AstIngestService {
|
||||
|
||||
private final GraphRepository graphRepository;
|
||||
private final LanguageParser javaParser = new JavaParser();
|
||||
private final LanguageParser naturalParser = new NaturalParser();
|
||||
private final NaturalParser naturalParser = new NaturalParser();
|
||||
private final CoarseScanner javaScanner = new JavaCoarseScanner();
|
||||
private final CoarseScanner naturalScanner = new NaturalCoarseScanner();
|
||||
private final NaturalCoarseScanner naturalScanner = new NaturalCoarseScanner();
|
||||
private final LineCounter javaLineCounter = new JavaLineCounter();
|
||||
private final LineCounter naturalLineCounter = new NaturalLineCounter();
|
||||
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
|
||||
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
|
||||
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
|
||||
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
|
||||
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
|
||||
private final LineCounter cssLineCounter = new CssLineCounter();
|
||||
|
||||
public AstIngestService(GraphRepository graphRepository) {
|
||||
/**
|
||||
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
|
||||
* edges are dropped just before persist. Comments are 45.8 % of the `upms` node population and
|
||||
* 60.4 % of everything {@code merge-nodes} processes, and this switch exists to measure what
|
||||
* that actually costs. It is deliberately a config property and not a query parameter: it is an
|
||||
* experiment, not a feature, so it gets no REST or CLI surface.
|
||||
*
|
||||
* <p>Turning it off makes a full parse emit no comments, so a reconciling run <em>deletes</em>
|
||||
* the existing ones — the first run after a flip therefore pays a one-time sweep and must not be
|
||||
* used as a measurement. Compare steady-state runs only.
|
||||
*/
|
||||
private final boolean commentsEnabled;
|
||||
|
||||
public AstIngestService(GraphRepository graphRepository,
|
||||
@ConfigProperty(name = "agenticcode.ingest.comments.enabled",
|
||||
defaultValue = "true") boolean commentsEnabled) {
|
||||
this.graphRepository = graphRepository;
|
||||
this.commentsEnabled = commentsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 179: strips comment nodes and the edges touching them. Applied at the persist seam rather
|
||||
* than in the parsers, so the parse cost stays in both arms of the A/B and the delta isolates
|
||||
* persist — which is the whole question, since finalize never touches a comment node.
|
||||
*/
|
||||
private LanguageParser.ParseResult stripComments(LanguageParser.ParseResult result) {
|
||||
Set<UUID> commentIds = result.nodes().stream()
|
||||
.filter(n -> n.type() == NodeType.COMMENT)
|
||||
.map(AstNode::id)
|
||||
.collect(Collectors.toSet());
|
||||
if (commentIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<AstNode> nodes = result.nodes().stream()
|
||||
.filter(n -> n.type() != NodeType.COMMENT)
|
||||
.toList();
|
||||
List<AstEdge> edges = result.edges().stream()
|
||||
.filter(e -> !commentIds.contains(e.sourceId()) && !commentIds.contains(e.targetId()))
|
||||
.toList();
|
||||
return new LanguageParser.ParseResult(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +111,31 @@ public class AstIngestService {
|
||||
* Parses {@code content} with the parser for {@code language}. Does not persist anything.
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content) {
|
||||
LanguageParser parser = language == SourceFiles.Language.JAVA ? javaParser : naturalParser;
|
||||
return parser.parse(sourceFile, content);
|
||||
return parse(language, sourceFile, content, CopycodeResolver.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code content}, expanding Natural copycode {@code INCLUDE}s via {@code copycodes}
|
||||
* (item 46a) so a copycode's calls/DB access/dataflow surface on the including module. The Java
|
||||
* parser has no copycode concept and ignores the resolver.
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
return parse(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code content} with the parser for {@code language}; {@code copycodes} serves Natural
|
||||
* (item 46a), {@code typescript} serves TypeScript/CSS (item 192). The switch is exhaustive on
|
||||
* purpose: a new {@link SourceFiles.Language} must be routed here, not fall through to a default.
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaParser.parse(sourceFile, content);
|
||||
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,8 +144,25 @@ public class AstIngestService {
|
||||
* deep bodies. Persisted like a {@link #parse} result; the deep detail is filled in on demand.
|
||||
*/
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content) {
|
||||
CoarseScanner scanner = language == SourceFiles.Language.JAVA ? javaScanner : naturalScanner;
|
||||
return scanner.scan(sourceFile, content);
|
||||
return coarseScan(language, sourceFile, content, CopycodeResolver.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-1 coarse scan with Natural copycode expansion (item 46a) for call/DB visibility; the Java
|
||||
* scanner ignores the resolver.
|
||||
*/
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
return coarseScan(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaScanner.scan(sourceFile, content);
|
||||
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +170,12 @@ public class AstIngestService {
|
||||
* the Tier-1 coarse scanners use, so a module's metrics are identical at any ingest depth.
|
||||
*/
|
||||
public LocMetrics count(SourceFiles.Language language, String content) {
|
||||
LineCounter counter = language == SourceFiles.Language.JAVA ? javaLineCounter : naturalLineCounter;
|
||||
LineCounter counter = switch (language) {
|
||||
case JAVA -> javaLineCounter;
|
||||
case NATURAL -> naturalLineCounter;
|
||||
case TYPESCRIPT -> typeScriptLineCounter;
|
||||
case CSS -> cssLineCounter;
|
||||
};
|
||||
return counter.count(content);
|
||||
}
|
||||
|
||||
@@ -88,7 +184,17 @@ public class AstIngestService {
|
||||
* {@link #finalizeProject} once after persisting a batch of files.
|
||||
*/
|
||||
public Uni<Void> persist(String project, LanguageParser.ParseResult result) {
|
||||
return graphRepository.persist(project, result).replaceWithVoid();
|
||||
return persist(project, result, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #persist(String, LanguageParser.ParseResult)}, but when {@code reconcile} is true also
|
||||
* deletes the file's stale nodes this (full) parse no longer produces (item 58). Pass {@code false}
|
||||
* for a coarse Tier-1 scan.
|
||||
*/
|
||||
public Uni<Void> persist(String project, LanguageParser.ParseResult result, boolean reconcile) {
|
||||
return graphRepository.persist(project, commentsEnabled ? result : stripComments(result),
|
||||
reconcile).replaceWithVoid();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +202,67 @@ public class AstIngestService {
|
||||
* must run {@link #finalizeProject} once after persisting all batches.
|
||||
*/
|
||||
public Uni<Void> persistBatch(String project, List<LanguageParser.ParseResult> results) {
|
||||
return graphRepository.persistBatch(project, results).replaceWithVoid();
|
||||
return persistBatch(project, results, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #persistBatch(String, List)}, but when {@code reconcile} is true also deletes each
|
||||
* file's stale nodes this (full) parse no longer produces (item 58). Pass {@code false} for a
|
||||
* coarse Tier-1 scan.
|
||||
*/
|
||||
public Uni<Void> persistBatch(String project, List<LanguageParser.ParseResult> results, boolean reconcile) {
|
||||
List<LanguageParser.ParseResult> effective = commentsEnabled
|
||||
? results
|
||||
: results.stream().map(this::stripComments).toList();
|
||||
return graphRepository.persistBatch(project, effective, reconcile).replaceWithVoid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 62 (summary surface): which of {@code names} are real data fields of {@code project} —
|
||||
* asked project-wide, because a data literal is routinely declared outside the ingested tree.
|
||||
*/
|
||||
public Uni<Set<String>> dataFieldNames(String project, Collection<String> names) {
|
||||
return graphRepository.dataFieldNames(project, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: every distinct real source file that currently has a node in {@code project} — the
|
||||
* caller checks these against the filesystem to find files deleted on disk whose nodes linger.
|
||||
*/
|
||||
public Uni<Set<String>> distinctSourceFiles(String project) {
|
||||
return graphRepository.distinctSourceFiles(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: records what the last <b>whole-root</b> ingest of {@code project} did, so an agent can
|
||||
* date an answer and see how complete the graph is without crawling the file system.
|
||||
*/
|
||||
public Uni<Void> recordProjectIngest(String project, ProjectIngestInfo ingest) {
|
||||
return graphRepository.recordProjectIngest(project, ingest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: marks a whole-root ingest as in flight, so one that never finishes leaves the graph
|
||||
* visibly half-updated rather than looking clean.
|
||||
*/
|
||||
public Uni<Void> markProjectIngestStarted(String project, String mode, String startedAt) {
|
||||
return graphRepository.markProjectIngestStarted(project, mode, startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: every ingested file's stored content hash, the input to a {@code changedOnly} refresh's
|
||||
* skip decision.
|
||||
*/
|
||||
public Uni<java.util.Map<String, String>> sourceHashes(String project) {
|
||||
return graphRepository.sourceHashes(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: deletes every node of {@code project} belonging to one of {@code sourceFiles} — the
|
||||
* deleted-file orphan sweep run after a whole-project refresh.
|
||||
*/
|
||||
public Uni<Void> deleteNodesForSourceFiles(String project, Collection<String> sourceFiles) {
|
||||
return graphRepository.deleteNodesForSourceFiles(project, sourceFiles);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +277,14 @@ public class AstIngestService {
|
||||
* (field-level resolution runs only for {@link EnrichmentLevel#FULL}).
|
||||
*/
|
||||
public Uni<Void> finalizeProject(String project, EnrichmentLevel level) {
|
||||
return graphRepository.finalizeProject(project, level).replaceWithVoid();
|
||||
return finalizeProject(project, level, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param profile diagnostic: profile the slow enrichment steps (see {@code GraphRepository}).
|
||||
*/
|
||||
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
|
||||
return graphRepository.finalizeProject(project, level, profile).replaceWithVoid();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +304,14 @@ public class AstIngestService {
|
||||
return graphRepository.markIngestDepth(project, depth, names).replaceWithVoid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 114: persists the identities a whole-project walk skipped as duplicates. Whole-root walks
|
||||
* only — see {@link GraphRepository#markDuplicateIdentities}.
|
||||
*/
|
||||
public Uni<Void> markDuplicateIdentities(String project, List<Map<String, Object>> duplicates) {
|
||||
return graphRepository.markDuplicateIdentities(project, duplicates).replaceWithVoid();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the ingest state of a module (exists? deeply ingested?).
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.parsernatural.CopycodeResolver;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* A project's Natural copycode ({@code .cpy}) library, backing {@link CopycodeResolver} for
|
||||
* statement-level {@code INCLUDE} expansion (roadmap item 46a). Built once per ingest from a shallow
|
||||
* scan of the project root (name → path index; members are read + cached on first use), so the deep
|
||||
* and coarse parsers can splice a copycode's body into the modules that include it.
|
||||
*
|
||||
* <p>Copycodes are deliberately <em>not</em> {@link SourceFiles#classify ingestible} as standalone
|
||||
* modules — they only resolve in an including module's scope — so this side index is how they enter
|
||||
* the pipeline.
|
||||
*/
|
||||
final class CopycodeLibrary implements CopycodeResolver {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(CopycodeLibrary.class);
|
||||
|
||||
private final Path root;
|
||||
private final Map<String, Path> byName;
|
||||
private final Map<String, Copycode> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private CopycodeLibrary(Path root, Map<String, Path> byName) {
|
||||
this.root = root;
|
||||
this.byName = byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans {@code root} for {@code .cpy} members (honoring {@code excludeDirs}), indexing them by
|
||||
* uppercased stem. Returns an empty (but functional) library on I/O error or when none exist.
|
||||
*/
|
||||
static CopycodeLibrary scan(Path root, List<String> excludeDirs) {
|
||||
Map<String, Path> index = new HashMap<>();
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
stream.filter(Files::isRegularFile)
|
||||
.filter(f -> f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".cpy"))
|
||||
.filter(f -> !SourceFiles.isExcluded(root, f, excludeDirs))
|
||||
.forEach(f -> index.putIfAbsent(SourceFiles.stem(f), f));
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not scan copycodes under '%s': %s", root, e.toString());
|
||||
}
|
||||
return new CopycodeLibrary(root, index);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Copycode resolve(String name) {
|
||||
String key = name.toUpperCase(Locale.ROOT);
|
||||
Copycode cached = cache.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Path path = byName.get(key);
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String sourceFile = root.relativize(path).toString().replace('\\', '/');
|
||||
Copycode member = new Copycode(sourceFile, SourceFiles.read(path));
|
||||
cache.put(key, member);
|
||||
return member;
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not read copycode '%s' at '%s': %s", name, path, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,17 @@ package com.agenticcode.codeserver.service;
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import com.agenticcode.neo4jstore.graph.IngestStatus;
|
||||
import com.agenticcode.neo4jstore.graph.ModuleIngestState;
|
||||
import com.agenticcode.parsercore.ast.model.SourceHash;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import io.smallrye.mutiny.infrastructure.Infrastructure;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -74,6 +78,7 @@ public class DeepIngestCoordinator {
|
||||
private final long warmAcquireMillis;
|
||||
private final long ingestingTtlMillis;
|
||||
private final long claimWaitMillis;
|
||||
private final boolean autoInvalidateEnabled;
|
||||
private final Semaphore warmPermits;
|
||||
private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -84,7 +89,8 @@ public class DeepIngestCoordinator {
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-concurrent-warms", defaultValue = "2") int maxConcurrentWarms,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.warm-acquire-timeout-seconds", defaultValue = "10") int warmAcquireSeconds,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.ingesting-ttl-seconds", defaultValue = "1800") int ingestingTtlSeconds,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.claim-wait-seconds", defaultValue = "120") int claimWaitSeconds) {
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.claim-wait-seconds", defaultValue = "120") int claimWaitSeconds,
|
||||
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
|
||||
this.graphRepository = graphRepository;
|
||||
this.ingestService = ingestService;
|
||||
this.rootResolver = rootResolver;
|
||||
@@ -94,6 +100,7 @@ public class DeepIngestCoordinator {
|
||||
this.warmAcquireMillis = Math.max(0, warmAcquireSeconds) * 1000L;
|
||||
this.ingestingTtlMillis = Math.max(1, ingestingTtlSeconds) * 1000L;
|
||||
this.claimWaitMillis = Math.max(0, claimWaitSeconds) * 1000L;
|
||||
this.autoInvalidateEnabled = autoInvalidateEnabled;
|
||||
this.warmPermits = new Semaphore(this.maxConcurrentWarms, true);
|
||||
}
|
||||
|
||||
@@ -126,10 +133,20 @@ public class DeepIngestCoordinator {
|
||||
*/
|
||||
public Uni<Void> ensureDeep(String project, String module) {
|
||||
return graphRepository.moduleIngestState(project, module).flatMap(state -> {
|
||||
if (state.isFull()) {
|
||||
// Fast path: already deep and auto-invalidation off — nothing to check or do.
|
||||
if (state.isFull() && !autoInvalidateEnabled) {
|
||||
return Uni.createFrom().voidItem();
|
||||
}
|
||||
// The staleness check reads the file from disk (blocking), so run it — and any re-ingest —
|
||||
// on a worker thread. A FULL module whose source changed on disk is invalidated (item 43)
|
||||
// so the coalesce loop re-ingests it instead of short-circuiting on its stale FULL status.
|
||||
return Uni.createFrom().<Void>item(() -> {
|
||||
if (state.isFull()) {
|
||||
if (!isModuleStale(project, module)) {
|
||||
return null;
|
||||
}
|
||||
graphRepository.invalidateModule(project, module).await().indefinitely();
|
||||
}
|
||||
ingestIfNeeded(project, module);
|
||||
return null;
|
||||
})
|
||||
@@ -138,6 +155,79 @@ public class DeepIngestCoordinator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: whether module {@code module}'s source file has changed on disk since it was ingested —
|
||||
* i.e. its current content hash no longer matches the stored {@code sourceHash}. {@code false} when
|
||||
* auto-invalidation is off, the module has no source file, no hash was stored (legacy node), or the
|
||||
* file can't be read (a transient IO error must never thrash a FULL module; a deleted file is the
|
||||
* deleted-file sweep's job, not this path's).
|
||||
*/
|
||||
private boolean isModuleStale(String project, String module) {
|
||||
if (!autoInvalidateEnabled) {
|
||||
return false;
|
||||
}
|
||||
String sourceFile = graphRepository.moduleSourceFile(project, module, GraphRepository.ANY_SOURCE_FILE).await().indefinitely();
|
||||
if (sourceFile == null || sourceFile.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return isSourceFileStale(project, sourceFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: whether {@code sourceFile}'s current content hash differs from the stored {@code
|
||||
* sourceHash} (same comparison as the item-41 {@code /source} stale check). See {@link
|
||||
* #isModuleStale} for the conservative {@code false} cases.
|
||||
*/
|
||||
private boolean isSourceFileStale(String project, String sourceFile) {
|
||||
if (!autoInvalidateEnabled) {
|
||||
return false;
|
||||
}
|
||||
String expected = graphRepository.sourceHash(project, sourceFile).await().indefinitely();
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
String root = resolveRoot(project);
|
||||
if (root == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
String actual = SourceHash.of(SourceFiles.read(Path.of(root).resolve(sourceFile)));
|
||||
return !expected.equals(actual);
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable String resolveRoot(String project) {
|
||||
return switch (rootResolver.resolve(project)) {
|
||||
case ProjectRootResolver.Resolved r -> r.project().root();
|
||||
case ProjectRootResolver.Failed f -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 105: whether a fan-out-surfaced file is a deep-ingest candidate at all.
|
||||
*
|
||||
* <p>A Natural data area ({@code .lda}/{@code .pda}/{@code .gda}) has <b>no deep tier</b> — both
|
||||
* ingest tiers run the same {@code parseDataArea}, and the file yields {@code DATA_STRUCTURE} nodes
|
||||
* only, never a {@code MODULE}. But {@code FULLY_INGESTED_SOURCE_FILES} asks for a {@code MODULE}
|
||||
* with {@code ingestDepth = 'FULL'}, so a data area can never be reported as fully ingested and was
|
||||
* re-warmed on <em>every single call</em> that surfaced it. The warm itself is trivial (~40 ms), but
|
||||
* each one triggers a whole-project 45-step finalize — ~60 s on {@code upms} — and then reports
|
||||
* "changed", making the caller re-run its query as well.
|
||||
*
|
||||
* <p>Measured: {@code search/identifier?name=ZFRAMBL0} (1 hit, a {@code .lda}) took <b>62 s</b>,
|
||||
* while the same call with no hits took <b>0.9 s</b> — the query is not the cost, the pointless warm
|
||||
* is. Repeating the call re-ingested the same file again, confirming it never converges.
|
||||
*
|
||||
* <p>Excluding these files loses nothing: a deep ingest of a data area produces exactly what the
|
||||
* coarse one already produced.
|
||||
*/
|
||||
private static boolean isWarmable(String sourceFile) {
|
||||
String lower = sourceFile.toLowerCase(Locale.ROOT);
|
||||
return !lower.endsWith(".lda") && !lower.endsWith(".pda") && !lower.endsWith(".gda");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out warm (item 37): deep-ingests the result-set {@code sourceFiles} a fan-out/traversal
|
||||
* query surfaced ({@code callers}, {@code search_identifier}, {@code call_tree}), bounded by the
|
||||
@@ -150,15 +240,33 @@ public class DeepIngestCoordinator {
|
||||
* its Tier-1 answer.
|
||||
*/
|
||||
public Uni<Boolean> ensureDeepMany(String project, Collection<String> sourceFiles) {
|
||||
if (sourceFiles.isEmpty()) {
|
||||
List<String> warmable = sourceFiles.stream().filter(DeepIngestCoordinator::isWarmable).toList();
|
||||
if (warmable.isEmpty()) {
|
||||
return Uni.createFrom().item(false);
|
||||
}
|
||||
return graphRepository.fullyIngestedSourceFiles(project, sourceFiles).flatMap(full -> {
|
||||
List<String> pending = sourceFiles.stream().distinct().filter(sf -> !full.contains(sf)).toList();
|
||||
if (pending.isEmpty()) {
|
||||
return Uni.createFrom().item(false);
|
||||
return graphRepository.fullyIngestedSourceFiles(project, warmable).flatMap(full -> {
|
||||
List<String> distinct = warmable.stream().distinct().toList();
|
||||
List<String> notFull = distinct.stream().filter(sf -> !full.contains(sf)).toList();
|
||||
// Fast path: auto-invalidation off — re-warm only the not-yet-FULL files (item 37 behaviour).
|
||||
if (!autoInvalidateEnabled) {
|
||||
if (notFull.isEmpty()) {
|
||||
return Uni.createFrom().item(false);
|
||||
}
|
||||
return Uni.createFrom().item(() -> warmFiles(project, notFull))
|
||||
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
|
||||
.onFailure().recoverWithItem(false);
|
||||
}
|
||||
return Uni.createFrom().item(() -> warmFiles(project, pending))
|
||||
// Item 43: also re-warm FULL files whose source changed on disk. The per-file hash check
|
||||
// reads from disk (blocking), so build the pending set and warm on a worker thread.
|
||||
return Uni.createFrom().item(() -> {
|
||||
List<String> pending = new ArrayList<>(notFull);
|
||||
for (String sf : distinct) {
|
||||
if (full.contains(sf) && isSourceFileStale(project, sf)) {
|
||||
pending.add(sf);
|
||||
}
|
||||
}
|
||||
return pending.isEmpty() ? Boolean.FALSE : warmFiles(project, pending);
|
||||
})
|
||||
.runSubscriptionOn(Infrastructure.getDefaultWorkerPool())
|
||||
.onFailure().recoverWithItem(false);
|
||||
});
|
||||
@@ -223,6 +331,8 @@ public class DeepIngestCoordinator {
|
||||
try {
|
||||
LOG.infof("Fan-out warm: deep-ingesting %d result-set file(s) in project '%s'",
|
||||
sourceFiles.size(), project);
|
||||
// The completion line comes from bfsIngest, the shared worker behind both
|
||||
// ingestFiles and ingestModules — logging here too would only duplicate it.
|
||||
yield ingestService.ingestFiles(resolved.project(), sourceFiles, fanoutNodes).ingested() > 0;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOG.errorf(e, "Fan-out warm of %d file(s) in project '%s' failed", sourceFiles.size(), project);
|
||||
@@ -267,7 +377,7 @@ public class DeepIngestCoordinator {
|
||||
if (state.isFull()) {
|
||||
return;
|
||||
}
|
||||
if (!state.exists()) {
|
||||
if (!state.ingested()) {
|
||||
// No coarse node to carry a durable status; the monitor still prevents an intra-JVM
|
||||
// duplicate. Ingest best-effort and stop.
|
||||
ingestClaimed(project, module);
|
||||
@@ -314,6 +424,8 @@ public class DeepIngestCoordinator {
|
||||
}
|
||||
case ProjectRootResolver.Resolved resolved -> {
|
||||
LOG.infof("Auto deep-ingesting module '%s' in project '%s' (query trigger)", module, project);
|
||||
// Completion is logged by bfsIngest (the shared deep-ingest worker), so this path
|
||||
// needs no line of its own.
|
||||
ingestService.ingestModule(resolved.project(), module);
|
||||
// ingestModule marks the ingested tree FULL/INGESTED. If the entry module did not
|
||||
// become FULL (e.g. its name did not resolve to a file), roll the claim back so it
|
||||
|
||||
@@ -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,11 @@ import org.jspecify.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
@@ -40,17 +46,34 @@ public class ProjectIngestService {
|
||||
private final int defaultDeepDepth;
|
||||
private final int maxDeepDepth;
|
||||
private final int defaultDeepNodes;
|
||||
private final boolean autoInvalidateEnabled;
|
||||
// Item 126: stamped onto the project shell with every whole-root ingest, so a graph can be
|
||||
// attributed to the server release that wrote it.
|
||||
private final VersionInfo versionInfo;
|
||||
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
|
||||
// report "clean" about a graph whose refresh has just started.
|
||||
private final ProjectMetadataCache projectMetadata;
|
||||
// Item 192: builds the per-ingest TypeScript context (workspaces, packages, sidecar facts).
|
||||
private final TypeScriptSidecarService typescript;
|
||||
|
||||
public ProjectIngestService(AstIngestService astIngestService,
|
||||
VersionInfo versionInfo,
|
||||
ProjectMetadataCache projectMetadata,
|
||||
TypeScriptSidecarService typescript,
|
||||
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes) {
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-nodes", defaultValue = "300") int defaultDeepNodes,
|
||||
@ConfigProperty(name = "agenticcode.auto-invalidate.enabled", defaultValue = "true") boolean autoInvalidateEnabled) {
|
||||
this.astIngestService = astIngestService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.projectMetadata = projectMetadata;
|
||||
this.typescript = typescript;
|
||||
this.batchSize = Math.max(1, batchSize);
|
||||
this.maxDeepDepth = Math.max(1, maxDeepDepth);
|
||||
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
|
||||
this.defaultDeepNodes = Math.max(1, defaultDeepNodes);
|
||||
this.autoInvalidateEnabled = autoInvalidateEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +131,53 @@ public class ProjectIngestService {
|
||||
return new ParseResult(nodes, result.edges());
|
||||
}
|
||||
|
||||
/**
|
||||
* The path-component names skipped when walking a project's root: its {@code excludeDirs} plus its
|
||||
* {@code userExitDir} (item 47) — user-exit files are annotated onto the generated twin, not
|
||||
* ingested as standalone modules (they would collide by name), so the walk skips them.
|
||||
*/
|
||||
private static List<String> ingestExcludeDirs(ProjectInfo project) {
|
||||
if (project.userExitDir() == null || project.userExitDir().isBlank()) {
|
||||
return project.excludeDirs();
|
||||
}
|
||||
List<String> dirs = new ArrayList<>(project.excludeDirs());
|
||||
dirs.add(project.userExitDir());
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 47: annotates a generated module/data-structure node with its user-exit twin's LoC/SLoC
|
||||
* ({@code userExitLoc}/{@code userExitSloc}). A node qualifies when the project has a
|
||||
* {@code generatedDir}, the node's {@code sourceFile} lies under it, and a same-named file was
|
||||
* scanned under the {@code userExitDir} ({@code userExitByStem}). Applied to both the coarse
|
||||
* (Tier-1) and deep parse results, so the split is correct at any ingest depth.
|
||||
*/
|
||||
private static ParseResult withUserExitMetrics(ParseResult result, @Nullable String generatedDir,
|
||||
Map<String, LocMetrics> userExitByStem) {
|
||||
if (generatedDir == null || generatedDir.isBlank() || userExitByStem.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<AstNode> nodes = result.nodes().stream().map(node -> {
|
||||
if (!isRealModuleOrStructure(node)
|
||||
|| !UserExitMetrics.pathHasComponent(node.sourceFile(), generatedDir)) {
|
||||
return node;
|
||||
}
|
||||
LocMetrics twin = userExitByStem.get(node.name().toUpperCase(Locale.ROOT));
|
||||
if (twin == null) {
|
||||
return node;
|
||||
}
|
||||
Map<String, String> props = new HashMap<>();
|
||||
if (node.properties() != null) {
|
||||
props.putAll(node.properties());
|
||||
}
|
||||
props.put("userExitLoc", Integer.toString(twin.loc()));
|
||||
props.put("userExitSloc", Integer.toString(twin.sloc()));
|
||||
return new AstNode(node.id(), node.type(), node.name(), node.sourceFile(), node.language(),
|
||||
node.startLine(), node.endLine(), node.dataType(), node.value(), props);
|
||||
}).toList();
|
||||
return new ParseResult(nodes, result.edges());
|
||||
}
|
||||
|
||||
/**
|
||||
* The identities a file contributes to cross-file duplicate detection — the file's own
|
||||
* identity, never the nodes nested inside it:
|
||||
@@ -133,6 +203,23 @@ public class ProjectIngestService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 24: reads and parses one candidate file, applying the shell/user-exit metric enrichment.
|
||||
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
|
||||
*/
|
||||
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
|
||||
CopycodeLibrary copycodes, TypeScriptProject ts, Map<String, LocMetrics> userExit,
|
||||
ProjectInfo project) throws IOException {
|
||||
String content = SourceFiles.read(candidate.file());
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
ParseResult result = coarse
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ts)
|
||||
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
|
||||
content, candidate.kind().language());
|
||||
result = withUserExitMetrics(result, project.generatedDir(), userExit);
|
||||
return new Parsed(candidate, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity used for cross-file duplicate detection: the fully-qualified name for Java modules
|
||||
* (so {@code com.a.Foo} and {@code com.b.Foo} are distinct), the bare name otherwise.
|
||||
@@ -153,9 +240,12 @@ public class ProjectIngestService {
|
||||
* Build-output directories always skipped when scanning a root (J6: their generated sources
|
||||
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
|
||||
*/
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
|
||||
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
|
||||
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
|
||||
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs) throws IOException {
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
|
||||
throws IOException {
|
||||
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
|
||||
for (String dir : DEFAULT_EXCLUDE_DIRS) {
|
||||
if (!effectiveExcludes.contains(dir)) {
|
||||
@@ -172,7 +262,7 @@ public class ProjectIngestService {
|
||||
continue;
|
||||
}
|
||||
SourceFiles.Kind kind = SourceFiles.classify(file);
|
||||
if (kind != null) {
|
||||
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
|
||||
candidates.add(new Candidate(file, kind));
|
||||
}
|
||||
}
|
||||
@@ -212,6 +302,18 @@ public class ProjectIngestService {
|
||||
return new IngestSummary.Truncation(reason, depthLimit, nodeLimit, hint);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code file}'s content hash, or {@code ""} when it cannot be read — which never equals a
|
||||
* stored hash, so an unreadable file is re-parsed (and fails loudly there) rather than skipped.
|
||||
*/
|
||||
private static String hashOf(Path file) {
|
||||
try {
|
||||
return SourceHash.of(SourceFiles.read(file));
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the entire project root and ingests every ingestible file. {@code deep} selects the
|
||||
* enrichment level: {@code deep=false} (default) runs the fast call-graph pass (placeholder
|
||||
@@ -222,7 +324,7 @@ public class ProjectIngestService {
|
||||
* {@link IngestDepth#FULL}).
|
||||
*/
|
||||
public IngestSummary ingestAll(ProjectInfo project, boolean deep) throws IOException {
|
||||
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false);
|
||||
return ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,7 +337,7 @@ public class ProjectIngestService {
|
||||
* resolve; field-level dataflow is deferred to the on-demand deep ingest.
|
||||
*/
|
||||
public IngestSummary scanTier1(ProjectInfo project) throws IOException {
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true);
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +347,7 @@ public class ProjectIngestService {
|
||||
* caller to deep-ingest a program first. Intended as the fast first pass after project creation.
|
||||
*/
|
||||
public IngestSummary ingestCallGraph(ProjectInfo project) throws IOException {
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false);
|
||||
return ingestRoot(project, EnrichmentLevel.CALL_GRAPH, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,7 +360,70 @@ public class ProjectIngestService {
|
||||
* wipe.)
|
||||
*/
|
||||
public IngestSummary refreshProject(ProjectInfo project, boolean deep) throws IOException {
|
||||
return deep ? ingestAll(project, true) : ingestCallGraph(project);
|
||||
return refreshProject(project, deep, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: {@code changedOnly} re-parses only files whose content hash differs from the graph's
|
||||
* (plus files with no stored hash). <b>Opt-in, and deliberately not the default</b> — three
|
||||
* whole-walk behaviours are reduced in this mode:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Natural copycodes are inlined at parse time</b>, so a module whose {@code .cpy} changed
|
||||
* parses differently while its own hash is unchanged. A changed copycode therefore
|
||||
* <b>disables skipping for the whole run</b> (logged) rather than silently keeping stale
|
||||
* expansions — the one case where "incremental" would corrupt the graph outright.</li>
|
||||
* <li><b>Duplicate identity detection</b> groups the files it parsed; with a subset it can only
|
||||
* confirm duplicates among changed files. Existing markers are never cleared (the query only
|
||||
* MERGEs), so this loses discovery, not recorded facts.</li>
|
||||
* <li><b>User-exit LoC annotation</b> (item 47) is re-stamped only on files that were re-parsed:
|
||||
* a changed user-exit twin does not refresh an unchanged generated module's metrics.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Enrichment is project-wide and still runs in full, so this cuts parse+persist time only.
|
||||
*/
|
||||
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly) throws IOException {
|
||||
return refreshProject(project, deep, changedOnly, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param profile diagnostic (2026-09-05): profile the slow enrichment steps of this run. Costs
|
||||
* 10-30% on its own, so it is opt-in per run and never a default.
|
||||
*/
|
||||
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly,
|
||||
boolean profile) throws IOException {
|
||||
long startedAt = System.nanoTime();
|
||||
IngestSummary summary = ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH,
|
||||
false, changedOnly, profile);
|
||||
sweepDeletedFileOrphans(project);
|
||||
LOG.infof("Refresh finished: project='%s', mode=%s, files=%d, modules=%d, failed=%d, %d s",
|
||||
project.name(), deep ? "deep" : "call-graph", summary.examinedFiles().size(),
|
||||
summary.ingested(), summary.failed().size(),
|
||||
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 43: after a whole-project refresh has re-ingested the current files, removes nodes for files
|
||||
* that were <em>deleted</em> from disk (a refresh MERGEs current files and per-file reconciles them,
|
||||
* but never touches files it no longer walks). Reconciles against the filesystem rather than the
|
||||
* walked-candidate list, so on-demand dependency files (PDAs/LDAs/copybooks) that exist on disk but
|
||||
* aren't top-level candidates are never wrongly swept.
|
||||
*/
|
||||
private void sweepDeletedFileOrphans(ProjectInfo project) {
|
||||
if (!autoInvalidateEnabled) {
|
||||
return;
|
||||
}
|
||||
Path root = Path.of(project.root());
|
||||
Set<String> tracked = astIngestService.distinctSourceFiles(project.name()).await().indefinitely();
|
||||
List<String> gone = tracked.stream()
|
||||
.filter(sf -> !Files.exists(root.resolve(sf)))
|
||||
.toList();
|
||||
if (!gone.isEmpty()) {
|
||||
LOG.infof("Refresh of '%s': removing nodes for %d deleted file(s): %s",
|
||||
project.name(), gone.size(), gone);
|
||||
astIngestService.deleteNodesForSourceFiles(project.name(), gone).await().indefinitely();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,31 +438,105 @@ public class ProjectIngestService {
|
||||
return ingestModule(project, moduleName, maxDepth, maxNodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: re-ingests <b>only the named files</b> (relative paths), for the common case of a code
|
||||
* change touching a handful of files where a whole-root refresh re-examines thousands.
|
||||
*
|
||||
* <p>Deep by construction: it runs the same BFS deep ingest the fan-out warm uses, so the named
|
||||
* files and their dependencies land {@code FULL}. Two things it deliberately does <b>not</b> do,
|
||||
* because they are only meaningful for a whole-root walk: the deleted-file sweep (item 43 —
|
||||
* nothing here says which files disappeared) and the project-shell ingest stamp (item 126 — this
|
||||
* walks a fraction of the tree, and moving {@code ingestedAt} would advertise the project as
|
||||
* freshly walked).
|
||||
*
|
||||
* <p>Paths that match no file under the root are returned in the summary's {@code unresolved} list
|
||||
* rather than dropped: "I ingested 2 of your 3 files" must be visible, or a typo'd path reads as a
|
||||
* successful refresh.
|
||||
*/
|
||||
public IngestSummary refreshPaths(ProjectInfo project, Collection<String> sourceFiles) throws IOException {
|
||||
Path root = Path.of(project.root()).toAbsolutePath().normalize();
|
||||
List<String> requested = sourceFiles.stream()
|
||||
.map(String::trim)
|
||||
.filter(sf -> !sf.isEmpty())
|
||||
.distinct()
|
||||
.toList();
|
||||
// Same guard as the source endpoints: these paths are client-supplied, so a '..' or an
|
||||
// absolute path must not reach outside the project root.
|
||||
List<String> unresolved = requested.stream()
|
||||
.filter(sf -> !root.resolve(sf).normalize().startsWith(root)
|
||||
|| !Files.isRegularFile(root.resolve(sf).normalize())
|
||||
// A path that exists but is not an ingestible source file (pom.xml, a README)
|
||||
// must be reported, not accepted: it was silently listed as examined while
|
||||
// nothing about it could ever be ingested, which is precisely the "I ingested
|
||||
// 2 of your 3 files" invisibility this list exists to prevent.
|
||||
|| SourceFiles.classify(root.resolve(sf).normalize()) == null)
|
||||
.toList();
|
||||
List<String> ingestable = requested.stream().filter(sf -> !unresolved.contains(sf)).toList();
|
||||
if (ingestable.isEmpty()) {
|
||||
return new IngestSummary(0, unresolved, List.of(), List.of(), List.of(), null);
|
||||
}
|
||||
IngestSummary summary = ingestFiles(project, ingestable, null);
|
||||
LOG.infof("Targeted refresh of '%s': %d file(s) requested, %d ingested, %d unresolved",
|
||||
project.name(), requested.size(), summary.ingested(), unresolved.size());
|
||||
return new IngestSummary(summary.ingested(), unresolved, summary.duplicates(), summary.failed(),
|
||||
ingestable, summary.truncation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared whole-root ingest. {@code level} selects how far enrichment goes, and the
|
||||
* {@link IngestDepth} the ingested modules are tagged with ({@code FULL} only when field-level
|
||||
* resolution ran, else {@code CALL_GRAPH}).
|
||||
*/
|
||||
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse) throws IOException {
|
||||
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
|
||||
boolean changedOnly) throws IOException {
|
||||
return ingestRoot(project, level, coarse, changedOnly, false);
|
||||
}
|
||||
|
||||
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
|
||||
boolean changedOnly, boolean profile) throws IOException {
|
||||
long startedAt = System.nanoTime();
|
||||
// Item 129: mark the pass in flight before touching anything. If it never reaches the record
|
||||
// call at the end — crash, container stop, an aborted deep refresh — the marker stays set and
|
||||
// every later answer can say the graph is half-updated instead of looking clean.
|
||||
markIngestStarted(project, coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT));
|
||||
Path root = Path.of(project.root());
|
||||
List<Candidate> candidates = walk(root, project.excludeDirs());
|
||||
List<String> excludeDirs = ingestExcludeDirs(project);
|
||||
List<Candidate> allCandidates = walk(root, excludeDirs, project.language());
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, excludeDirs);
|
||||
// Item 129: opt-in skip of files whose content is byte-identical to what the graph holds.
|
||||
List<Candidate> candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates)
|
||||
: allCandidates;
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
|
||||
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
|
||||
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
|
||||
// Tier-1, so the refresh completes and the response says what is missing.
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
|
||||
|
||||
List<String> examinedFiles = candidates.stream()
|
||||
.map(c -> relativeSourceFile(root, c.file()))
|
||||
.toList();
|
||||
// Item 24: parse+read each file on its own virtual thread. Reading source is IO-bound and
|
||||
// parsing is CPU-bound but per-file independent (the JavaParser/NaturalParser instances hold no
|
||||
// mutable state; copycodes/userExit are read-only), so this scales the parse phase across cores.
|
||||
// Results are collected in candidate order (futures list is parallel to candidates), so the
|
||||
// downstream duplicate detection and persist order stay deterministic.
|
||||
List<Parsed> parsed = new ArrayList<>();
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
for (Candidate candidate : candidates) {
|
||||
try {
|
||||
String content = SourceFiles.read(candidate.file());
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
ParseResult result = coarse
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content)
|
||||
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content),
|
||||
content, candidate.kind().language());
|
||||
parsed.add(new Parsed(candidate, result));
|
||||
} catch (RuntimeException | IOException e) {
|
||||
failed.add(new IngestSummary.Failure(candidate.file().toString(), e.toString()));
|
||||
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
List<Future<Parsed>> futures = candidates.stream()
|
||||
.map(candidate -> parseExecutor.submit(
|
||||
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project)))
|
||||
.toList();
|
||||
for (int i = 0; i < futures.size(); i++) {
|
||||
try {
|
||||
parsed.add(futures.get(i).get());
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause() != null ? e.getCause() : e;
|
||||
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), cause.toString()));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +555,15 @@ public class ProjectIngestService {
|
||||
keyToIdentity.forEach((key, id) -> {
|
||||
Set<String> paths = keyToPaths.get(key);
|
||||
if (paths != null && paths.size() > 1) {
|
||||
duplicates.add(new IngestSummary.Duplicate(id.displayName(), id.kind(), List.copyOf(paths)));
|
||||
// Item 114: reported relative to the project root, like every other path the API hands
|
||||
// out (node sourceFile, candidates, call sites). They used to be absolute server paths —
|
||||
// inconsistent with all of those, unusable as a key against them, and a needless
|
||||
// disclosure of where the root happens to be mounted.
|
||||
List<String> relative = paths.stream()
|
||||
.map(p -> relativeSourceFile(Path.of(project.root()), Path.of(p)))
|
||||
.sorted()
|
||||
.toList();
|
||||
duplicates.add(new IngestSummary.Duplicate(id.displayName(), id.kind(), relative));
|
||||
conflictingPaths.addAll(paths);
|
||||
}
|
||||
});
|
||||
@@ -324,6 +571,14 @@ public class ProjectIngestService {
|
||||
List<Parsed> toPersist = parsed.stream()
|
||||
.filter(p -> !conflictingPaths.contains(p.candidate().file().toString()))
|
||||
.toList();
|
||||
// Item 114: persist what was skipped. The list was already computed here and reported in the
|
||||
// ingest response, then lost — after a refresh that takes minutes nobody still holds that body,
|
||||
// and the endpoints answered 404 for an identity that exists twice. Written from the whole-root
|
||||
// walk only; a scoped ingest sees a fraction of the tree and must not clear what it never saw.
|
||||
astIngestService.markDuplicateIdentities(project.name(), duplicates.stream()
|
||||
.map(d -> Map.of(
|
||||
"name", d.name(), "type", d.kind().name(), "paths", d.paths()))
|
||||
.toList()).await().indefinitely();
|
||||
// Persist in chunked transactions (each aggregates many files' nodes/edges into batched
|
||||
// UNWIND writes) rather than one transaction per file, collapsing thousands of round trips
|
||||
// into a handful. Enrichment still runs once at the end over the fully-persisted graph.
|
||||
@@ -333,7 +588,9 @@ public class ProjectIngestService {
|
||||
List<ParseResult> results = chunk.stream().map(Parsed::result).toList();
|
||||
LOG.infof("Ingesting files %d-%d of %d into project '%s'",
|
||||
from + 1, from + chunk.size(), toPersist.size(), project.name());
|
||||
astIngestService.persistBatch(project.name(), results).await().indefinitely();
|
||||
// Reconcile (delete stale nodes) only for a full parse — a coarse Tier-1 scan emits a
|
||||
// subset of nodes and must not delete deep detail (item 58).
|
||||
astIngestService.persistBatch(project.name(), results, !coarse).await().indefinitely();
|
||||
ingested += chunk.size();
|
||||
}
|
||||
// Project-wide enrichment (placeholder resolution + dataflow + polymorphic fan-out) runs
|
||||
@@ -342,16 +599,136 @@ public class ProjectIngestService {
|
||||
if (ingested > 0) {
|
||||
LOG.infof("Finalizing project '%s' (%d files persisted, %s)", project.name(), ingested,
|
||||
level.name().toLowerCase(Locale.ROOT));
|
||||
astIngestService.finalizeProject(project.name(), level).await().indefinitely();
|
||||
astIngestService.finalizeProject(project.name(), level, profile).await().indefinitely();
|
||||
// Tag ingest depth: only a field-resolving (FULL) pass marks modules FULL; the fast
|
||||
// whole-root passes mark CALL_GRAPH (so field queries still hint a deep ingest), without
|
||||
// downgrading any already-FULL module.
|
||||
astIngestService.markIngestDepth(project.name(),
|
||||
level.resolveFields() ? IngestDepth.FULL : IngestDepth.CALL_GRAPH, null).await().indefinitely();
|
||||
}
|
||||
String mode = coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT);
|
||||
long durationSeconds = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt);
|
||||
LOG.infof("Project ingest finished: project='%s', mode=%s, files=%d, persisted=%d, failed=%d, "
|
||||
+ "duplicates=%d, %d s",
|
||||
project.name(), mode, examinedFiles.size(),
|
||||
ingested, failed.size(), duplicates.size(), durationSeconds);
|
||||
// Item 126: this is the only place that stamps the project shell, and it is reached only by the
|
||||
// three whole-root passes (Tier-1 scan, call-graph refresh, deep refresh). By-name and fan-out
|
||||
// ingests deliberately do not come through here — they walk a fraction of the tree, and moving
|
||||
// ingestedAt for them would report the project as freshly walked when one module was deepened.
|
||||
// Item 129: filesExamined counts what this pass actually walked. In changedOnly mode that is
|
||||
// the changed subset, and the log line above says so — a smaller number here is the point, not
|
||||
// a sign of a short walk.
|
||||
recordIngest(project, mode, examinedFiles.size(), ingested, failed, durationSeconds);
|
||||
return new IngestSummary(ingested, List.of(), duplicates, failed, examinedFiles, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: the subset of {@code candidates} whose on-disk content differs from the hash stored in
|
||||
* the graph (or that has no stored hash at all — a new file, or one ingested before hashes existed).
|
||||
*
|
||||
* <p>Returns <b>every</b> candidate — i.e. skips nothing — when a Natural copycode has changed.
|
||||
* Copycode text is inlined into the including module at parse time, so those modules parse
|
||||
* differently while their own hashes are unchanged; skipping them would leave stale expansions in
|
||||
* the graph with nothing to indicate it. Reading the copycodes' own hashes is not enough to know
|
||||
* <em>which</em> modules include them at this point in the walk, and guessing wrong is silent
|
||||
* corruption, so the whole optimisation stands down for that run.
|
||||
*/
|
||||
private List<Candidate> changedCandidates(ProjectInfo project, Path root, List<String> excludeDirs,
|
||||
List<Candidate> candidates) {
|
||||
Map<String, String> stored = astIngestService.sourceHashes(project.name()).await().indefinitely();
|
||||
// Only Natural inlines copycodes at parse time, so only Natural needs the stand-down. Running
|
||||
// the check for a Java project was actively harmful: `ac` carries .cpy files as Natural *test
|
||||
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
|
||||
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
|
||||
// (a legacy project) keeps the conservative behaviour.
|
||||
String declared = project.language() == null ? "" : project.language();
|
||||
boolean inlinesCopycodes = declared.isEmpty() || "natural".equalsIgnoreCase(declared);
|
||||
if (inlinesCopycodes && copycodeChanged(root, excludeDirs, stored)) {
|
||||
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
|
||||
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
|
||||
project.name());
|
||||
return candidates;
|
||||
}
|
||||
List<Candidate> changed = new ArrayList<>();
|
||||
for (Candidate candidate : candidates) {
|
||||
String relative = relativeSourceFile(root, candidate.file());
|
||||
@Nullable String known = stored.get(relative);
|
||||
if (known == null || !known.equals(hashOf(candidate.file()))) {
|
||||
changed.add(candidate);
|
||||
}
|
||||
}
|
||||
LOG.infof("changedOnly refresh of '%s': %d of %d files changed since the last ingest",
|
||||
project.name(), changed.size(), candidates.size());
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether any {@code .cpy} member's content differs from the hash the graph holds for it.
|
||||
* A copycode with no stored hash counts as changed: it may never have been ingested, and assuming
|
||||
* otherwise is the unsafe direction.
|
||||
*/
|
||||
private boolean copycodeChanged(Path root, List<String> excludeDirs, Map<String, String> stored) {
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.filter(f -> f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".cpy"))
|
||||
.filter(f -> !SourceFiles.isExcluded(root, f, excludeDirs))
|
||||
.anyMatch(f -> {
|
||||
@Nullable String known = stored.get(relativeSourceFile(root, f));
|
||||
return known == null || !known.equals(hashOf(f));
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not scan copycodes under '%s' for changes (%s); re-parsing everything",
|
||||
root, e.toString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 129: stamps the in-flight marker. Never fatal — a project whose bookkeeping cannot be written
|
||||
* must still be ingestable; the cost of failing here is only that an interruption would go unmarked.
|
||||
*/
|
||||
private void markIngestStarted(ProjectInfo project, String mode) {
|
||||
try {
|
||||
astIngestService.markProjectIngestStarted(project.name(), mode,
|
||||
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()).await().indefinitely();
|
||||
projectMetadata.invalidate(project.name());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warnf(e, "Could not mark ingest start for project '%s'; an interrupted run will not be flagged",
|
||||
project.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 126: writes the whole-root ingest's outcome onto the {@code (:Project)} shell. Failure
|
||||
* <em>paths</em> are capped at {@link ProjectIngestInfo#MAX_FAILURES} while the count stays exact,
|
||||
* so a truncated list can never be read as "these were all of them".
|
||||
*
|
||||
* <p>Never fatal: the graph is already written, and losing the bookkeeping must not turn a
|
||||
* successful ingest into a failed request. A warning is logged instead — the missing metadata then
|
||||
* shows up as {@code ingest: null}, which reads as "not recorded" rather than as a false fact.
|
||||
*/
|
||||
private void recordIngest(ProjectInfo project, String mode, int filesExamined, int filesPersisted,
|
||||
List<IngestSummary.Failure> failed, long durationSeconds) {
|
||||
List<String> failurePaths = failed.stream()
|
||||
.map(f -> relativeSourceFile(Path.of(project.root()), Path.of(f.path())))
|
||||
.limit(ProjectIngestInfo.MAX_FAILURES)
|
||||
.toList();
|
||||
ProjectIngestInfo ingest = new ProjectIngestInfo(
|
||||
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(), mode,
|
||||
filesExamined, filesPersisted, failed.size(), failurePaths,
|
||||
failed.size() > failurePaths.size(), durationSeconds, versionInfo.version(),
|
||||
// Reaching here means the pass completed; the write clears the in-flight marker.
|
||||
false, null);
|
||||
try {
|
||||
astIngestService.recordProjectIngest(project.name(), ingest).await().indefinitely();
|
||||
projectMetadata.invalidate(project.name());
|
||||
} catch (RuntimeException e) {
|
||||
LOG.warnf(e, "Could not record ingest metadata for project '%s'; it will report ingest=null",
|
||||
project.name());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests {@code moduleName} and its transitive dependencies using the configured default depth
|
||||
* and node budget. See {@link #ingestModule(ProjectInfo, String, Integer, Integer)}.
|
||||
@@ -373,18 +750,37 @@ public class ProjectIngestService {
|
||||
*/
|
||||
public IngestSummary ingestModule(ProjectInfo project, String moduleName,
|
||||
@Nullable Integer maxDepth, @Nullable Integer maxNodes) throws IOException {
|
||||
return ingestModules(project, List.of(moduleName), maxDepth, maxNodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-seed variant of {@link #ingestModule}: deep-ingests every module in {@code moduleNames}
|
||||
* (and each one's transitive dependency tree) in a <b>single</b> bounded BFS over one filesystem
|
||||
* walk. Used by the neighbourhood refresh (item 51 M3 follow-up), which seeds the walk with a
|
||||
* module together with its transitive callers and callees so the whole call-graph neighbourhood —
|
||||
* and, via each module's own USING/INCLUDE fan-out, its data structures — is deep-ingested at once.
|
||||
* The {@code maxNodes} budget bounds the union across all seeds.
|
||||
*/
|
||||
public IngestSummary ingestModules(ProjectInfo project, Collection<String> moduleNames,
|
||||
@Nullable Integer maxDepth, @Nullable Integer maxNodes) throws IOException {
|
||||
int depthLimit = Math.min(Math.max(1, maxDepth != null ? maxDepth : defaultDeepDepth), maxDeepDepth);
|
||||
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
|
||||
Path root = Path.of(project.root());
|
||||
List<Candidate> candidates = walk(root, project.excludeDirs());
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
|
||||
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
|
||||
// Reverse index (Java): interface/base simple name -> files that implement/extend it, so a
|
||||
// targeted ingest also pulls in the implementations/subclasses of any interface it reaches.
|
||||
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
|
||||
List<QueuedRef> seeds = List.of(
|
||||
new QueuedRef(new DependencyRef(moduleName.toUpperCase(Locale.ROOT), NodeType.MODULE), 0, null));
|
||||
return bfsIngest(project, root, index, implementorsByBase, seeds, depthLimit, nodeLimit,
|
||||
"'" + moduleName + "'");
|
||||
List<QueuedRef> seeds = moduleNames.stream()
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.map(n -> n.toUpperCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.map(n -> new QueuedRef(new DependencyRef(n, NodeType.MODULE), 0, null))
|
||||
.toList();
|
||||
String label = seeds.size() == 1
|
||||
? "'" + moduleNames.iterator().next() + "'"
|
||||
: seeds.size() + " modules";
|
||||
return bfsIngest(project, root, index, implementorsByBase, seeds, depthLimit, nodeLimit, label);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -404,7 +800,7 @@ public class ProjectIngestService {
|
||||
@Nullable Integer maxNodes) throws IOException {
|
||||
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
|
||||
Path root = Path.of(project.root());
|
||||
List<Candidate> candidates = walk(root, project.excludeDirs());
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
|
||||
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
|
||||
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
|
||||
Map<String, Candidate> byRelPath = new HashMap<>();
|
||||
@@ -427,6 +823,48 @@ public class ProjectIngestService {
|
||||
seeds.size() + " module(s)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 62 (summary surface): records, from one parsed file, how each placeholder call target is
|
||||
* reached — an inferred {@code CALLNAT_DYNAMIC}/{@code INCLUDE_MACRO} edge vs a trusted static
|
||||
* {@code CALLNAT 'X'} — which is gate 4 of the data-literal test below. Names are upper-cased
|
||||
* because the BFS upper-cases its refs.
|
||||
*/
|
||||
private static void collectCallProvenance(ParseResult result,
|
||||
Set<String> inferredCallTargets,
|
||||
Set<String> staticCallTargets) {
|
||||
Map<UUID, String> placeholderModules = new HashMap<>();
|
||||
for (AstNode node : result.nodes()) {
|
||||
if (node.sourceFile().isEmpty() && node.type() == NodeType.MODULE) {
|
||||
placeholderModules.put(node.id(), node.name().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
for (AstEdge edge : result.edges()) {
|
||||
if (edge.type() != EdgeType.CALLS) {
|
||||
continue;
|
||||
}
|
||||
String target = placeholderModules.get(edge.targetId());
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> props = edge.properties();
|
||||
String callKind = props == null ? null : props.get("callKind");
|
||||
if (CallKind.CALLNAT_DYNAMIC.name().equals(callKind) || CallKind.INCLUDE_MACRO.name().equals(callKind)) {
|
||||
inferredCallTargets.add(target);
|
||||
} else {
|
||||
staticCallTargets.add(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether {@code name} carries a Natural sigil — {@code #} (user variable), {@code &} (AIV)
|
||||
* or {@code +} (GDA). A genuine dynamic-dispatch target always does, so a sigil'd name is never
|
||||
* treated as a data literal.
|
||||
*/
|
||||
private static boolean isSigilled(String name) {
|
||||
return !name.isEmpty() && (name.charAt(0) == '#' || name.charAt(0) == '&' || name.charAt(0) == '+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded breadth-first deep ingest shared by the by-name walk ({@link #ingestModule}) and the
|
||||
* by-path fan-out warm ({@link #ingestFiles}). Ingests {@code seeds} and, up to {@code depthLimit}
|
||||
@@ -439,10 +877,20 @@ public class ProjectIngestService {
|
||||
Map<NameKey, List<Candidate>> index,
|
||||
Map<String, List<Candidate>> implementorsByBase,
|
||||
List<QueuedRef> seeds, int depthLimit, int nodeLimit, String label) {
|
||||
int ingested = 0;
|
||||
List<String> unresolved = new ArrayList<>();
|
||||
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
|
||||
long startedAt = System.nanoTime();
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
|
||||
// workspace, run once for this call).
|
||||
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
|
||||
int ingested = 0;
|
||||
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
|
||||
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
|
||||
List<DependencyRef> unresolvedRefs = new ArrayList<>();
|
||||
Set<String> inferredCallTargets = new HashSet<>();
|
||||
Set<String> staticCallTargets = new HashSet<>();
|
||||
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
|
||||
List<String> examinedFiles = new ArrayList<>();
|
||||
Set<String> ingestedModuleNames = new HashSet<>();
|
||||
|
||||
@@ -476,7 +924,7 @@ public class ProjectIngestService {
|
||||
} else {
|
||||
List<Candidate> matches = index.getOrDefault(new NameKey(ref.type(), ref.name()), List.of());
|
||||
if (matches.isEmpty()) {
|
||||
unresolved.add(ref.type() + " " + ref.name());
|
||||
unresolvedRefs.add(ref);
|
||||
continue;
|
||||
}
|
||||
if (matches.size() > 1) {
|
||||
@@ -490,16 +938,19 @@ public class ProjectIngestService {
|
||||
String content = SourceFiles.read(candidate.file());
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
examinedFiles.add(sourceFile);
|
||||
ParseResult result = withShellMetrics(
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content),
|
||||
content, candidate.kind().language());
|
||||
ParseResult result = withUserExitMetrics(withShellMetrics(
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
|
||||
content, candidate.kind().language()), project.generatedDir(), userExit);
|
||||
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);
|
||||
astIngestService.persist(project.name(), result).await().indefinitely();
|
||||
// Full deep parse of this module — reconcile so a by-name refresh purges its stale
|
||||
// nodes (renamed/removed fields) rather than shadowing them (item 58).
|
||||
astIngestService.persist(project.name(), result, true).await().indefinitely();
|
||||
ingested++;
|
||||
result.nodes().stream()
|
||||
.filter(ProjectIngestService::isRealModuleOrStructure)
|
||||
.filter(node -> node.type() == NodeType.MODULE)
|
||||
.forEach(node -> ingestedModuleNames.add(node.name()));
|
||||
collectCallProvenance(result, inferredCallTargets, staticCallTargets);
|
||||
List<DependencyRef> deps = AstIngestService.dependencies(result);
|
||||
// If the just-ingested module is an interface/base type, also ingest its
|
||||
// implementations/subclasses (keyed by the uppercased simple name).
|
||||
@@ -536,8 +987,60 @@ public class ProjectIngestService {
|
||||
// (and others stay CALL_GRAPH / unresolved with a deep-ingest hint).
|
||||
astIngestService.markIngestDepth(project.name(), IngestDepth.FULL, names).await().indefinitely();
|
||||
}
|
||||
return new IngestSummary(ingested, unresolved, duplicates, failed, examinedFiles,
|
||||
truncation(label, depthLimit, nodeLimit, depthTruncated, nodeTruncated));
|
||||
List<String> unresolved = filterDataLiteralRefs(project.name(), unresolvedRefs,
|
||||
inferredCallTargets, staticCallTargets);
|
||||
IngestSummary.@Nullable Truncation truncated =
|
||||
truncation(label, depthLimit, nodeLimit, depthTruncated, nodeTruncated);
|
||||
// One line per operation, not per file: the walk logs "Ingesting <module> …" for each file it
|
||||
// reaches, so `files` is what those N start lines add up to.
|
||||
LOG.infof("Deep ingest finished: project='%s', seed=%s, files=%d, failed=%d, unresolved=%d, "
|
||||
+ "truncated=%s, %d s",
|
||||
project.name(), label, ingested, failed.size(), unresolved.size(), truncated != null,
|
||||
TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAt));
|
||||
return new IngestSummary(ingested, unresolved, duplicates, failed, examinedFiles, truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 62 (summary surface): drops the unresolved refs that are really <em>data values</em> — a
|
||||
* browse key reaching a {@code CALLNAT} through a copycode/macro argument — so the summary agrees
|
||||
* with the graph, where {@code DELETE_DATA_LITERAL_CALL_PLACEHOLDERS} reaps the same nodes. Without
|
||||
* this, a deep ingest reports e.g. {@code MODULE CO-TABLA} as a missing dependency although
|
||||
* {@code CO-TABLA} is an {@code (A8)} field and no such module exists anywhere.
|
||||
*
|
||||
* <p>Mirrors the graph reap's four gates: the ref is a {@code MODULE}, carries no Natural sigil
|
||||
* ({@code #}/{@code &}/{@code +} — a genuine dispatch variable always does), names a real data field,
|
||||
* and is reached <em>only</em> by inferred edges. That last gate is what keeps the real external
|
||||
* modules {@code RPC-CNTX}/{@code USIX081X} reported: their names collide with field names, but a
|
||||
* static {@code CALLNAT 'X'} is trusted. (The "no real MODULE" gate is implicit — a ref that matched
|
||||
* a file never reaches this list.) Applied after the whole tree is walked, so the verdict can't
|
||||
* depend on BFS visit order.
|
||||
*
|
||||
* <p>{@code dataFieldNames} is asked of the <em>graph</em>, not of this tree: a browse key reaching a
|
||||
* {@code CALLNAT} through a macro argument is often declared elsewhere in the project (e.g.
|
||||
* {@code NAME-DESC-SP}, reported by a {@code WGEAGB0S} ingest but declared in {@code BMTABBN2.nat}),
|
||||
* so a tree-local check silently misses it.
|
||||
*/
|
||||
private List<String> filterDataLiteralRefs(String project, List<DependencyRef> refs,
|
||||
Set<String> inferredCallTargets,
|
||||
Set<String> staticCallTargets) {
|
||||
List<DependencyRef> candidates = refs.stream()
|
||||
.filter(ref -> ref.type() == NodeType.MODULE)
|
||||
.filter(ref -> !isSigilled(ref.name()))
|
||||
.filter(ref -> inferredCallTargets.contains(ref.name().toUpperCase(Locale.ROOT)))
|
||||
.filter(ref -> !staticCallTargets.contains(ref.name().toUpperCase(Locale.ROOT)))
|
||||
.toList();
|
||||
Set<String> dataFields = candidates.isEmpty()
|
||||
? Set.of()
|
||||
: astIngestService.dataFieldNames(project,
|
||||
candidates.stream().map(DependencyRef::name).collect(Collectors.toSet()))
|
||||
.await().indefinitely();
|
||||
Set<DependencyRef> dataLiterals = candidates.stream()
|
||||
.filter(ref -> dataFields.contains(ref.name()))
|
||||
.collect(Collectors.toSet());
|
||||
return refs.stream()
|
||||
.filter(ref -> !dataLiterals.contains(ref))
|
||||
.map(ref -> ref.type() + " " + ref.name())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,8 +17,16 @@ import java.util.Locale;
|
||||
* the file's primary node carries, and provides the filename-stem used as a module/data-area key.
|
||||
*
|
||||
* <p>Java classes carry {@link NodeType#MODULE}; Natural {@code .nat}/{@code .nsn} programs are
|
||||
* {@code MODULE}s and {@code .lda}/{@code .pda} data areas are {@link NodeType#DATA_STRUCTURE}s.
|
||||
* Files with any other extension are not ingestible and classify to {@code null}.
|
||||
* {@code MODULE}s and {@code .lda}/{@code .pda}/{@code .gda} data areas (local/parameter/global) are
|
||||
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
|
||||
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
|
||||
* ingestible and classify to {@code null}.
|
||||
*
|
||||
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
|
||||
* project of a given declared language picks a file up. Java and Natural files are ingested by every
|
||||
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
|
||||
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
|
||||
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
|
||||
*/
|
||||
final class SourceFiles {
|
||||
|
||||
@@ -37,12 +45,30 @@ final class SourceFiles {
|
||||
if (name.endsWith(".nat") || name.endsWith(".nsn")) {
|
||||
return new Kind(Language.NATURAL, NodeType.MODULE);
|
||||
}
|
||||
if (name.endsWith(".lda") || name.endsWith(".pda")) {
|
||||
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
|
||||
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
|
||||
}
|
||||
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
|
||||
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
|
||||
}
|
||||
if (name.endsWith(".css")) {
|
||||
return new Kind(Language.CSS, NodeType.MODULE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
|
||||
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
|
||||
* — see the class comment.
|
||||
*/
|
||||
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
|
||||
return switch (language) {
|
||||
case JAVA, NATURAL -> true;
|
||||
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the uppercased filename stem (name without extension), used as the case-insensitive
|
||||
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
|
||||
@@ -83,7 +109,10 @@ final class SourceFiles {
|
||||
return false;
|
||||
}
|
||||
|
||||
enum Language {JAVA, NATURAL}
|
||||
/**
|
||||
* The parser a file goes to. {@code CSS} shares the TypeScript parser but has its own line counter.
|
||||
*/
|
||||
enum Language {JAVA, NATURAL, TYPESCRIPT, CSS}
|
||||
|
||||
/**
|
||||
* Classification of an ingestible source file.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* One regex hit in a module's source text (item 54): the module, its source file, the 1-based line
|
||||
* number, and the matching line's text (trimmed of trailing whitespace).
|
||||
*/
|
||||
@Schema(description = "A regex match in a module's source: module, file, line number, line text.")
|
||||
public record SourceMatch(String module, String sourceFile, int lineNo, String line) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of a source-text regex search (item 54): the (echoed) pattern, whether the match {@code
|
||||
* limit} was hit (so more matches exist), and the matches found.
|
||||
*/
|
||||
@Schema(description = "Regex search over module source text: matches with file+line, plus a truncated flag.")
|
||||
public record SourceSearchResponse(String regex, boolean truncated, List<SourceMatch> matches) {
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.ModuleInfo;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Regex search over module source text (item 54), read from the project root on disk with the same
|
||||
* UTF-8/ISO-8859-1 fallback decoding the parsers use ({@link SourceFiles#read}). No index: files are
|
||||
* grepped on demand, bounded by a match {@code limit} so a common pattern stops early. Each module's
|
||||
* source file is read once (deduped), so inner classes / inline data areas don't re-scan it.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class SourceSearchService {
|
||||
|
||||
private static String stripTrailing(String s) {
|
||||
int end = s.length();
|
||||
while (end > 0 && (s.charAt(end - 1) == ' ' || s.charAt(end - 1) == '\t' || s.charAt(end - 1) == '\r')) {
|
||||
end--;
|
||||
}
|
||||
return s.substring(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root the project's absolute root folder
|
||||
* @param modules the modules to search (their {@code sourceFile}s, deduped)
|
||||
* @param pattern the compiled regex (already carries CASE_INSENSITIVE if requested)
|
||||
* @param limit maximum number of matches to return; when hit, {@code truncated} is set
|
||||
*/
|
||||
public SourceSearchResponse search(String root, List<ModuleInfo> modules, Pattern pattern, int limit) {
|
||||
Path rootPath = Path.of(root);
|
||||
List<SourceMatch> matches = new ArrayList<>();
|
||||
Set<String> seenFiles = new HashSet<>();
|
||||
boolean truncated = false;
|
||||
|
||||
outer:
|
||||
for (ModuleInfo module : modules) {
|
||||
String sourceFile = module.sourceFile();
|
||||
if (sourceFile == null || sourceFile.isBlank() || !seenFiles.add(sourceFile)) {
|
||||
continue; // unresolved placeholder, or already scanned via another node
|
||||
}
|
||||
String content;
|
||||
try {
|
||||
content = SourceFiles.read(rootPath.resolve(sourceFile));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
continue; // unreadable file — skip rather than fail the whole search
|
||||
}
|
||||
String[] lines = content.split("\n", -1);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (pattern.matcher(lines[i]).find()) {
|
||||
if (matches.size() >= limit) {
|
||||
truncated = true;
|
||||
break outer;
|
||||
}
|
||||
matches.add(new SourceMatch(module.name(), sourceFile, i + 1, stripTrailing(lines[i])));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SourceSearchResponse(pattern.pattern(), truncated, matches);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,30 @@ public class SourceSnippetService {
|
||||
*/
|
||||
public SourceSnippet read(String root, String sourceFile, int startLine, int endLine,
|
||||
@Nullable String expectedHash) throws IOException, StaleSourceException {
|
||||
List<String> allLines = readAndVerify(root, sourceFile, expectedHash).lines().toList();
|
||||
int from = Math.max(1, startLine);
|
||||
int to = Math.min(allLines.size(), endLine);
|
||||
List<String> slice = from > to ? List.of() : allLines.subList(from - 1, to);
|
||||
return new SourceSnippet(sourceFile, startLine, endLine, slice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the <b>whole file</b> (M1: the source-viewer needs the full module, not a line range),
|
||||
* with the same stale-hash check as {@link #read}. The returned {@code startLine}/{@code endLine}
|
||||
* report the file's true bounds ({@code 1..lineCount}), so no sentinel leaks to the caller.
|
||||
*/
|
||||
public SourceSnippet readWholeFile(String root, String sourceFile, @Nullable String expectedHash)
|
||||
throws IOException, StaleSourceException {
|
||||
List<String> allLines = readAndVerify(root, sourceFile, expectedHash).lines().toList();
|
||||
return new SourceSnippet(sourceFile, allLines.isEmpty() ? 0 : 1, allLines.size(), allLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a source file with the parsers' UTF-8/ISO-8859-1 fallback decoding and enforces the
|
||||
* item-41 stale-source check when {@code expectedHash} is non-null.
|
||||
*/
|
||||
private String readAndVerify(String root, String sourceFile, @Nullable String expectedHash)
|
||||
throws IOException, StaleSourceException {
|
||||
Path file = Path.of(root).resolve(sourceFile);
|
||||
String content = SourceFiles.read(file);
|
||||
if (expectedHash != null) {
|
||||
@@ -40,10 +64,6 @@ public class SourceSnippetService {
|
||||
throw new StaleSourceException(sourceFile, expectedHash, actual);
|
||||
}
|
||||
}
|
||||
List<String> allLines = content.lines().toList();
|
||||
int from = Math.max(1, startLine);
|
||||
int to = Math.min(allLines.size(), endLine);
|
||||
List<String> slice = from > to ? List.of() : allLines.subList(from - 1, to);
|
||||
return new SourceSnippet(sourceFile, startLine, endLine, slice);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.LocMetrics;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Builds the item-47 user-exit LoC/SLoC index: for a project configured with a {@code userExitDir},
|
||||
* scans the files under that directory name (a hand-written user exit) and computes each one's
|
||||
* {@link LocMetrics} with the <em>same</em> per-language line counter the rest of ingest uses, keyed
|
||||
* by uppercased module stem.
|
||||
*
|
||||
* <p>A generated module already contains its user-exit twin, so at ingest a generated module whose
|
||||
* name has an entry here is annotated with that twin's LoC/SLoC ({@code userExitLoc}/{@code
|
||||
* userExitSloc}), letting {@code /loc} report total / user-exit / generated-exclusive. User-exit files
|
||||
* are otherwise <em>not</em> ingested as standalone modules (they would collide by name with the
|
||||
* generated twin), which the ingest walk enforces by excluding {@code userExitDir}.
|
||||
*/
|
||||
final class UserExitMetrics {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(UserExitMetrics.class);
|
||||
|
||||
private UserExitMetrics() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return module stem (upper-case) → the user-exit file's LoC/SLoC, for every ingestible file
|
||||
* under a {@code userExitDir} path component. Empty when {@code userExitDir} is {@code null} or the
|
||||
* root cannot be walked. When two user-exit trees hold the same stem, the first in sorted walk
|
||||
* order wins (deterministic; narrow the scope via {@code excludeDirs} if undesired).
|
||||
*/
|
||||
static Map<String, LocMetrics> scan(Path root, @org.jspecify.annotations.Nullable String userExitDir,
|
||||
List<String> excludeDirs, AstIngestService astIngestService) {
|
||||
Map<String, LocMetrics> byStem = new HashMap<>();
|
||||
if (userExitDir == null || userExitDir.isBlank()) {
|
||||
return byStem;
|
||||
}
|
||||
// Honor other excludes (e.g. target) but never the userExitDir itself — a user who also lists it
|
||||
// in excludeDirs (to keep it out of module ingest the old way) must still have it scanned here.
|
||||
List<String> otherExcludes = excludeDirs.stream()
|
||||
.filter(d -> !d.equalsIgnoreCase(userExitDir)).toList();
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
List<Path> files = stream.filter(Files::isRegularFile).sorted().toList();
|
||||
for (Path file : files) {
|
||||
if (!hasComponent(root, file, userExitDir) || SourceFiles.isExcluded(root, file, otherExcludes)) {
|
||||
continue;
|
||||
}
|
||||
SourceFiles.Kind kind = SourceFiles.classify(file);
|
||||
if (kind == null) {
|
||||
continue;
|
||||
}
|
||||
String stem = SourceFiles.stem(file);
|
||||
if (byStem.containsKey(stem)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
LocMetrics metrics = astIngestService.count(kind.language(), SourceFiles.read(file));
|
||||
byStem.put(stem, metrics);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOG.warnf("Could not count user-exit file '%s': %s", file, e.toString());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not scan user-exit dir '%s' under '%s': %s", userExitDir, root, e.toString());
|
||||
}
|
||||
return byStem;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether any component of {@code file}'s path relative to {@code root} equals
|
||||
* {@code component} (case-insensitive) — the same matching {@link SourceFiles#isExcluded} uses.
|
||||
*/
|
||||
static boolean hasComponent(Path root, Path file, String component) {
|
||||
for (Path part : root.relativize(file)) {
|
||||
if (part.toString().equalsIgnoreCase(component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether {@code relativeSourceFile} (a {@code /}-separated relative path) has a path
|
||||
* component equal to {@code component} (case-insensitive).
|
||||
*/
|
||||
static boolean pathHasComponent(String relativeSourceFile, String component) {
|
||||
for (String part : relativeSourceFile.split("/")) {
|
||||
if (part.equalsIgnoreCase(component)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,17 +1,29 @@
|
||||
# HTTP server port
|
||||
quarkus.http.port=8787
|
||||
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the MCP
|
||||
# 'version' tool/server-info (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=11
|
||||
|
||||
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
|
||||
quarkus.mcp.server.server-info.name=agenticcode
|
||||
quarkus.mcp.server.server-info.version=${agenticcode.version}
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
|
||||
# info version (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=334
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
mp.openapi.extensions.smallrye.info.version=${agenticcode.version}
|
||||
mp.openapi.extensions.smallrye.info.description=Agent-optimized REST API for querying the AgenticCode unified AST graph (Natural / Java).
|
||||
quarkus.smallrye-openapi.path=/q/openapi
|
||||
quarkus.swagger-ui.always-include=false
|
||||
# CORS (item 50) — the web UI is served from a separate dev origin (Vite) and, later, its own
|
||||
# host. Restrict to explicit origins; never ship a wildcard. Extend the list per deployment.
|
||||
quarkus.http.cors.enabled=true
|
||||
quarkus.http.cors.origins=http://localhost:5173,http://localhost:4173
|
||||
quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS
|
||||
quarkus.http.cors.headers=accept,content-type
|
||||
|
||||
# HTTP access log (INFO) for every API call
|
||||
quarkus.http.access-log.enabled=true
|
||||
quarkus.http.access-log.pattern=%{METHOD} %{REQUEST_URL} -> %{RESPONSE_CODE} (%{RESPONSE_TIME}ms)
|
||||
# %{RESPONSE_TIME} renders as '-' unless the start time is recorded (off by default), which is why
|
||||
# every access-log line used to end in '(-ms)'. Runtime property, so it needs no test-side repeat.
|
||||
quarkus.http.record-request-start-time=true
|
||||
# Number of files whose nodes/edges are persisted per transaction during `ingest all`
|
||||
# (larger = fewer round trips but bigger transactions)
|
||||
agenticcode.ingest.batch-size=200
|
||||
@@ -28,3 +40,18 @@ agenticcode.ingest.batch-size=200
|
||||
%prod.quarkus.neo4j.uri=${NEO4J_URI:bolt://neo4j:7687}
|
||||
%prod.quarkus.neo4j.authentication.username=${NEO4J_USER:neo4j}
|
||||
%prod.quarkus.neo4j.authentication.password=${NEO4J_PASSWORD}
|
||||
# Item 179 (DIAGNOSTIC): set to false to drop COMMENT nodes and their DOCUMENTS edges just before
|
||||
# persist. Comments are 45.8 % of the upms node population; this exists to measure what they cost.
|
||||
# Not a feature — no REST parameter and no CLI flag. Leave true for normal operation.
|
||||
agenticcode.ingest.comments.enabled=true
|
||||
# Item 192: the TypeScript Tier-2 sidecar (ac-parser-typescript/sidecar/extract.mjs). A `typescript`
|
||||
# project's deep pass runs `node --max-old-space-size=<max-heap-mb> extract.mjs` once per npm
|
||||
# workspace, sequentially (measured ~0.5 GB heap, 3-5 s per workspace on the pur frontend). A bare
|
||||
# `node` is looked up on PATH; dev mode runs from ac-code-server/, hence the relative script path.
|
||||
# The image copies node and the sidecar to /usr/local/bin/node and /work/sidecar (Dockerfile.jvm).
|
||||
agenticcode.typescript.node=node
|
||||
agenticcode.typescript.sidecar-script=../ac-parser-typescript/sidecar/extract.mjs
|
||||
agenticcode.typescript.max-heap-mb=1024
|
||||
agenticcode.typescript.timeout-seconds=600
|
||||
%prod.agenticcode.typescript.node=/usr/local/bin/node
|
||||
%prod.agenticcode.typescript.sidecar-script=/work/sidecar/extract.mjs
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class AnalysisResourceIT {
|
||||
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then()
|
||||
.statusCode(201);
|
||||
@@ -218,6 +218,63 @@ class AnalysisResourceIT {
|
||||
.body("sourceFile", everyItem(equalTo("BAREPARM.lda")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIdentifierIsLeadingSigilInsensitive() {
|
||||
// The declared name is "#P-CLIENT"; searching without the '#' sigil must still find it.
|
||||
given()
|
||||
.queryParam("name", "P-CLIENT").queryParam("type", "VARIABLE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("#P-CLIENT"));
|
||||
|
||||
// And searching with the sigil still works (both sides are normalised).
|
||||
given()
|
||||
.queryParam("name", "#P-CLIENT").queryParam("type", "VARIABLE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("#P-CLIENT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void functionCallersListsPerformSites() {
|
||||
// In DYNAIDX the DISPATCH subroutine PERFORMs INIT-TBL — so INIT-TBL's function-caller is DISPATCH.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/DYNAIDX/functions/INIT-TBL/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("DISPATCH"))
|
||||
.body("items.find { it.name == 'DISPATCH' }.edgeKind", equalTo("PERFORM"));
|
||||
|
||||
// A subroutine nobody PERFORMs has no function-callers.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/DYNAIDX/functions/DISPATCH/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items", hasSize(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchIdentifierCanBeScopedToAModule() {
|
||||
// #P-CLIENT is declared in DF_CALLEE. module= scopes the search to that module's file...
|
||||
given()
|
||||
.queryParam("name", "#P-CLIENT").queryParam("module", "DF_CALLEE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("#P-CLIENT"))
|
||||
.body("sourceFile", everyItem(equalTo("DF_CALLEE.nat")));
|
||||
|
||||
// ...and scoping to a module that doesn't declare it yields nothing.
|
||||
given()
|
||||
.queryParam("name", "#P-CLIENT").queryParam("module", "DF_CALLER")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/identifier")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("$", hasSize(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataflowTracesVariableAcrossCallnatBoundary() {
|
||||
// Forward: #CLIENT in DF_CALLER flows into #P-CLIENT in DF_CALLEE (position 0).
|
||||
@@ -239,21 +296,12 @@ class AnalysisResourceIT {
|
||||
.body("find { it.variable == '#CLIENT' }.depth", equalTo(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaDataflowTracesArgumentToParameter() {
|
||||
// placeOrder(quantity) calls register(quantity); quantity flows into register's 'count'.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/quantity/flow-forward?module=OrderService")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.variable == 'count' }.module", equalTo("OrderService"))
|
||||
.body("find { it.variable == 'count' }.depth", equalTo(1));
|
||||
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/count/flow-backward?module=OrderService")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("variable", hasItem("quantity"));
|
||||
private static String sampleLegacyEntityNodeId() {
|
||||
return given()
|
||||
.queryParam("name", "Entity").queryParam("type", "MODULE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then().statusCode(200).extract()
|
||||
.path("find { it.name == 'com.example.sample.SampleLegacyEntity' }.id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -271,6 +319,23 @@ class AnalysisResourceIT {
|
||||
.body("function", hasItem("placeOrder"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaDataflowTracesArgumentToParameter() {
|
||||
// placeOrder(quantity) calls register(quantity); quantity flows into register's 'count'.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/quantity/flow-forward?module=OrderService")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.variable == 'count' }.module", equalTo("com.example.sample.OrderService"))
|
||||
.body("find { it.variable == 'count' }.depth", equalTo(1));
|
||||
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/count/flow-backward?module=OrderService")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("variable", hasItem("quantity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaCrossClassCallGraphSpansFiles() {
|
||||
// OrderController.handle() calls service.placeOrder() (typed field) -> CALLS to OrderService.
|
||||
@@ -278,9 +343,9 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/callees?scope=external")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("OrderService", "AuditEntry", "AuditLog"))
|
||||
.body("items.name", hasItems("com.example.sample.OrderService", "AuditEntry", "AuditLog"))
|
||||
// edgeKind is language-aware: Java method calls vs constructor invocations.
|
||||
.body("items.find { it.name == 'OrderService' }.edgeKind", equalTo("METHOD_CALL"))
|
||||
.body("items.find { it.name == 'com.example.sample.OrderService' }.edgeKind", equalTo("METHOD_CALL"))
|
||||
.body("items.find { it.name == 'AuditLog' }.edgeKind", equalTo("METHOD_CALL"))
|
||||
.body("items.find { it.name == 'AuditEntry' }.edgeKind", equalTo("CONSTRUCTOR"));
|
||||
|
||||
@@ -289,14 +354,14 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderService/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("OrderController"));
|
||||
.body("items.name", hasItem("com.example.sample.OrderController"));
|
||||
|
||||
// call-tree reaches OrderService transitively from OrderController.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/call-tree?depth=2")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("OrderService"));
|
||||
.body("items.name", hasItem("com.example.sample.OrderService"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -308,56 +373,26 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/CheckoutService/callees?scope=external")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("PaymentGateway", "CardGateway", "WireGateway"));
|
||||
.body("items.name", hasItems("com.example.pay.PaymentGateway", "com.example.pay.CardGateway", "com.example.pay.WireGateway"));
|
||||
|
||||
// Each implementation now lists CheckoutService among its callers (resolved via the interface).
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/CardGateway/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("CheckoutService"));
|
||||
.body("items.name", hasItem("com.example.pay.CheckoutService"));
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/WireGateway/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("CheckoutService"));
|
||||
.body("items.name", hasItem("com.example.pay.CheckoutService"));
|
||||
|
||||
// call-tree from CheckoutService reaches the implementations transitively.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/CheckoutService/call-tree?depth=2")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("CardGateway", "WireGateway"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void callersSurfaceIncomingInheritanceEdges() {
|
||||
// "Who extends this class?" — OrderService extends BaseService, so BaseService's callers
|
||||
// must include OrderService tagged edgeKind=EXTENDS (previously /callers matched CALLS only
|
||||
// and silently returned no subtypes).
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItem("OrderService"))
|
||||
.body("items.find { it.name == 'OrderService' }.edgeKind", equalTo("EXTENDS"));
|
||||
|
||||
// "Who implements this interface?" — CardGateway/WireGateway implement PaymentGateway, so
|
||||
// they appear among PaymentGateway's callers tagged edgeKind=IMPLEMENTS.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/PaymentGateway/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("CardGateway", "WireGateway"))
|
||||
.body("items.find { it.name == 'CardGateway' }.edgeKind", equalTo("IMPLEMENTS"))
|
||||
.body("items.find { it.name == 'WireGateway' }.edgeKind", equalTo("IMPLEMENTS"));
|
||||
|
||||
// scope=internal (PERFORM into own subroutines) must NOT include inheritance edges.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers?scope=internal")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.findAll { it.name == 'OrderService' }", empty());
|
||||
.body("items.name", hasItems("com.example.pay.CardGateway", "com.example.pay.WireGateway"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -418,15 +453,33 @@ class AnalysisResourceIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchAnnotationFindsAnnotatedClassAndField() {
|
||||
// SampleLegacyEntity is @Entity(...); its 'vid' field is @Id + @Column.
|
||||
void callersSurfaceIncomingInheritanceEdges() {
|
||||
// "Who extends this class?" — OrderService extends BaseService, so BaseService's callers
|
||||
// must include OrderService tagged edgeKind=EXTENDS (previously /callers matched CALLS only
|
||||
// and silently returned no subtypes).
|
||||
given()
|
||||
.queryParam("name", "Entity").queryParam("type", "MODULE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("SampleLegacyEntity"))
|
||||
.body("find { it.name == 'SampleLegacyEntity' }.annotations", equalTo("Entity"));
|
||||
.body("items.name", hasItem("com.example.sample.OrderService"))
|
||||
.body("items.find { it.name == 'com.example.sample.OrderService' }.edgeKind", equalTo("EXTENDS"));
|
||||
|
||||
// "Who implements this interface?" — CardGateway/WireGateway implement PaymentGateway, so
|
||||
// they appear among PaymentGateway's callers tagged edgeKind=IMPLEMENTS.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/PaymentGateway/callers")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("com.example.pay.CardGateway", "com.example.pay.WireGateway"))
|
||||
.body("items.find { it.name == 'com.example.pay.CardGateway' }.edgeKind", equalTo("IMPLEMENTS"))
|
||||
.body("items.find { it.name == 'com.example.pay.WireGateway' }.edgeKind", equalTo("IMPLEMENTS"));
|
||||
|
||||
// scope=internal (PERFORM into own subroutines) must NOT include inheritance edges.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/BaseService/callers?scope=internal")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.findAll { it.name == 'com.example.sample.OrderService' }", empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -459,12 +512,16 @@ class AnalysisResourceIT {
|
||||
.body("code", equalTo("INVALID_TYPE"));
|
||||
}
|
||||
|
||||
private static String sampleLegacyEntityNodeId() {
|
||||
return given()
|
||||
@Test
|
||||
void searchAnnotationFindsAnnotatedClassAndField() {
|
||||
// SampleLegacyEntity is @Entity(...); its 'vid' field is @Id + @Column.
|
||||
given()
|
||||
.queryParam("name", "Entity").queryParam("type", "MODULE")
|
||||
.when().get("/api/projects/" + PROJECT + "/search/annotation")
|
||||
.then().statusCode(200).extract()
|
||||
.path("find { it.name == 'SampleLegacyEntity' }.id");
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("com.example.sample.SampleLegacyEntity"))
|
||||
.body("find { it.name == 'com.example.sample.SampleLegacyEntity' }.annotations", equalTo("Entity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -474,7 +531,7 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/nodes/{id}", id)
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", equalTo("SampleLegacyEntity"))
|
||||
.body("name", equalTo("com.example.sample.SampleLegacyEntity"))
|
||||
.body("type", equalTo("MODULE"))
|
||||
.body("annotations", equalTo("Entity"));
|
||||
}
|
||||
@@ -511,8 +568,25 @@ class AnalysisResourceIT {
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleSourceRequiresLineRange() {
|
||||
void moduleSourceWithoutRangeReturnsWholeFile() {
|
||||
// M1: omitting both bounds returns the entire file, with true 1..lineCount bounds (no sentinel).
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/SampleLegacyEntity/source")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("sourceFile", equalTo("SampleLegacyEntity.java"))
|
||||
.body("startLine", equalTo(1))
|
||||
.body("endLine", greaterThan(1))
|
||||
.body("lines.size()", greaterThan(1))
|
||||
// spans from the package/class declaration through the file
|
||||
.body("lines", hasItem(containsString("class SampleLegacyEntity")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleSourceWithHalfOpenRangeIsRejected() {
|
||||
// Exactly one bound is ambiguous — must 400 (whole file requires neither, a range requires both).
|
||||
given()
|
||||
.queryParam("startLine", 5)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/SampleLegacyEntity/source")
|
||||
.then()
|
||||
.statusCode(400)
|
||||
@@ -604,7 +678,7 @@ class AnalysisResourceIT {
|
||||
String p2 = PROJECT + "-fast";
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + p2)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + p2 + "/refresh").then().statusCode(200);
|
||||
@@ -643,7 +717,7 @@ class AnalysisResourceIT {
|
||||
String pw = PROJECT + "-warm";
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + pw)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + pw + "/refresh").then().statusCode(200);
|
||||
@@ -678,7 +752,7 @@ class AnalysisResourceIT {
|
||||
String pf = PROJECT + "-flowpath";
|
||||
given()
|
||||
.contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + pf)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + pf + "/refresh").then().statusCode(200);
|
||||
@@ -771,6 +845,82 @@ class AnalysisResourceIT {
|
||||
.body("items.size()", equalTo(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphOutReturnsModuleNeighbourhoodWithEdges() {
|
||||
// Item 49: the ego graph is module-granularity. OrderController calls OrderService (a real
|
||||
// module) — both must appear as nodes, the root at distance 0, with the CALLS edge between them.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/graph?direction=out&depth=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("root", equalTo("com.example.sample.OrderController"))
|
||||
.body("direction", equalTo("out"))
|
||||
.body("nodes.name", hasItems("com.example.sample.OrderController", "com.example.sample.OrderService"))
|
||||
.body("nodes.find { it.name == 'com.example.sample.OrderController' }.distance", equalTo(0))
|
||||
.body("nodes.find { it.name == 'com.example.sample.OrderService' }.distance", equalTo(1))
|
||||
.body("edges.find { it.from == 'com.example.sample.OrderController' && it.to == 'com.example.sample.OrderService' }.edgeKind",
|
||||
equalTo("METHOD_CALL"))
|
||||
// module-granularity: internal subroutines/methods are not nodes.
|
||||
.body("nodes.name", not(hasItem("placeOrder")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphInReturnsCallers() {
|
||||
// direction=in is the reverse: OrderService's neighbourhood contains its caller OrderController,
|
||||
// with the edge still directed caller -> callee.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderService/graph?direction=in&depth=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("nodes.name", hasItems("com.example.sample.OrderService", "com.example.sample.OrderController"))
|
||||
.body("edges.find { it.to == 'com.example.sample.OrderService' }.from", equalTo("com.example.sample.OrderController"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphMarksUnresolvedTargets() {
|
||||
// YADDRBN0_SAMPLE CALLNATs CDRANGE, whose source file is not under the root, so it stays an
|
||||
// unresolved placeholder (empty sourceFile, unresolved=true) — surfaced for dispatch styling.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/graph?direction=out&depth=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("nodes.name", hasItem("CDRANGE"))
|
||||
.body("nodes.find { it.name == 'CDRANGE' }.unresolved", equalTo(true))
|
||||
.body("nodes.find { it.name == 'CDRANGE' }.sourceFile", equalTo(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphLimitCapsNodesAndReportsTruncation() {
|
||||
// limit=1 admits only the root; any neighbour is dropped and truncated flips to true.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/OrderController/graph?direction=out&depth=2&limit=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("nodes.size()", equalTo(1))
|
||||
.body("nodes[0].name", equalTo("com.example.sample.OrderController"))
|
||||
.body("truncated", equalTo(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphUnknownProjectReturns404() {
|
||||
given()
|
||||
.when().get("/api/projects/no-such-project/modules/OrderController/graph")
|
||||
.then()
|
||||
.statusCode(404)
|
||||
.body("code", equalTo("PROJECT_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void egoGraphModuleListCarriesIngestStatus() {
|
||||
// Item 50: the modules list surfaces ingestStatus so the UI can badge rows without a per-module
|
||||
// round trip. After a deep whole-root refresh, real modules are INGESTED.
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules?sourceFile=OrderController.java")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'com.example.sample.OrderController' }.ingestStatus", equalTo("INGESTED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void naturalCallTreeIsQueryable() {
|
||||
given()
|
||||
@@ -783,6 +933,14 @@ class AnalysisResourceIT {
|
||||
.body("items.find { it.name == 'CDRANGE' }.edgeKind", equalTo("CALLNAT"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 67: {@code depth} counts module hops, so this test's expectations changed on purpose.
|
||||
*
|
||||
* <p>{@code INITIALIZATIONS} and {@code R-ADDRESS_SP} are both {@code DEFINE SUBROUTINE}s of
|
||||
* {@code YADDRBN0_SAMPLE.nat} itself; {@code R-ADDRESS_SP} merely happens to be {@code PERFORM}ed
|
||||
* from inside {@code INITIALIZATIONS}. Neither crosses a module boundary, so both are depth 0 —
|
||||
* where the raw-hop count called them 1 and 2. Only {@code CALLNAT 'CDRANGE'} leaves the module.
|
||||
*/
|
||||
@Test
|
||||
void naturalCallTreeIsTransitive() {
|
||||
given()
|
||||
@@ -790,15 +948,17 @@ class AnalysisResourceIT {
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE", "R-ADDRESS_SP"))
|
||||
.body("items.find { it.name == 'INITIALIZATIONS' }.depth", equalTo(1))
|
||||
.body("items.find { it.name == 'R-ADDRESS_SP' }.depth", equalTo(2));
|
||||
.body("items.find { it.name == 'INITIALIZATIONS' }.depth", equalTo(0))
|
||||
.body("items.find { it.name == 'R-ADDRESS_SP' }.depth", equalTo(0))
|
||||
.body("items.find { it.name == 'CDRANGE' }.depth", equalTo(1));
|
||||
|
||||
given()
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/YADDRBN0_SAMPLE/call-tree?depth=1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE"))
|
||||
.body("items.name", not(hasItem("R-ADDRESS_SP")));
|
||||
// R-ADDRESS_SP used to be absent here — asking for depth=1 hid a subroutine of the very
|
||||
// module being asked about, because it sat two raw CALLS edges away.
|
||||
.body("items.name", hasItems("INITIALIZATIONS", "CDRANGE", "R-ADDRESS_SP"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1159,6 +1319,39 @@ class AnalysisResourceIT {
|
||||
.body("find { it.function == 'R-ADDRESS_SP' }.sourceFile", equalTo("YADDRBN0_SAMPLE.nat"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 109: a write reports what was written, not only where. Without it, answering "which values
|
||||
* does this module put into field X" — the dispatcher question — meant opening the file at the line
|
||||
* numbers this very endpoint had just returned.
|
||||
*
|
||||
* <p>The value is the right-hand side as written, not a normalized literal: {@code #MAX-ATTEMPTS}
|
||||
* here, {@code 'WREQUD0S'} for a literal, {@code *PROGRAM} for a system variable. Filtering to
|
||||
* literals would drop the majority of real write sites.
|
||||
*/
|
||||
@Test
|
||||
void variableWritesReportTheAssignedValue() {
|
||||
given()
|
||||
.pathParam("name", "#COUNTER")
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes?module=YADDRBN0_SAMPLE")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
// MOVE #MAX-ATTEMPTS TO #COUNTER
|
||||
.body("assignedValue", hasItem("#MAX-ATTEMPTS"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A read has no assigned value — the field must be null there, not absent or borrowed.
|
||||
*/
|
||||
@Test
|
||||
void variableReadsCarryNoAssignedValue() {
|
||||
given()
|
||||
.pathParam("name", "#MAX-ATTEMPTS")
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/reads")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("assignedValue", everyItem(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void variableReadsCanBeScopedToAModuleAndItsCallTree() {
|
||||
given()
|
||||
@@ -1231,7 +1424,7 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItems("SampleRequest", "YADDRBN0_SAMPLE"))
|
||||
.body("name", hasItems("com.example.sample.SampleRequest", "YADDRBN0_SAMPLE"))
|
||||
.body("find { it.name == 'YADDRBN0_SAMPLE' }.sourceFile", equalTo("YADDRBN0_SAMPLE.nat"));
|
||||
}
|
||||
|
||||
@@ -1243,7 +1436,7 @@ class AnalysisResourceIT {
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("YADDRBN0_SAMPLE"))
|
||||
.body("name", not(hasItem("SampleRequest")));
|
||||
.body("name", not(hasItem("com.example.sample.SampleRequest")));
|
||||
|
||||
given()
|
||||
.queryParam("sourceFile", "NO_SUCH_FILE.nat")
|
||||
@@ -1260,8 +1453,8 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'PaymentGateway' }.moduleKind", equalTo("INTERFACE"))
|
||||
.body("find { it.name == 'OrderService' }.moduleKind", equalTo("CLASS"));
|
||||
.body("find { it.name == 'com.example.pay.PaymentGateway' }.moduleKind", equalTo("INTERFACE"))
|
||||
.body("find { it.name == 'com.example.sample.OrderService' }.moduleKind", equalTo("CLASS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1283,8 +1476,8 @@ class AnalysisResourceIT {
|
||||
.when().get("/api/projects/" + PROJECT + "/modules")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body("name", hasItem("PaymentGateway"))
|
||||
.body("name", not(hasItem("OrderService")));
|
||||
.body("name", hasItem("com.example.pay.PaymentGateway"))
|
||||
.body("name", not(hasItem("com.example.sample.OrderService")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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,88 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Item 43 (change-invalidation): a module that is already deeply ({@code FULL}) ingested but whose
|
||||
* source file changed on disk is auto-invalidated and re-ingested by the next field-level query — no
|
||||
* manual {@code refresh} needed. Contrast with {@link StaleSourceIT}, where a manual {@code refresh}
|
||||
* is what clears the staleness.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class AutoInvalidationIT {
|
||||
|
||||
private static final String PROJECT = "item43-autoinvalidate";
|
||||
private static final String MODULE = "AUTOINV";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@Inject
|
||||
GraphRepository graphRepository;
|
||||
|
||||
private static void write(String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(MODULE + ".nat"), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void deepIngestMakesModuleFull() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write("DEFINE DATA\nLOCAL\n1 #X (A4)\nEND-DEFINE\n*\nEND\n");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
|
||||
// Deep-ingest the module so it is FULL (the case item 43 must invalidate).
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh/" + MODULE).then().statusCode(200);
|
||||
assertTrue(graphRepository.moduleIngestState(PROJECT, MODULE).await().indefinitely().isFull(),
|
||||
"module should be FULL after a per-module refresh");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void editedFullModuleIsStaleUntilQueried() {
|
||||
// Edit the file on disk without a manual refresh: the stored hash no longer matches.
|
||||
write("DEFINE DATA\nLOCAL\n1 #X (A4)\n1 #Y (A4)\nEND-DEFINE\n*\nEND\n");
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/" + MODULE + "/source?startLine=1&endLine=2")
|
||||
.then().statusCode(409)
|
||||
.body("code", equalTo("STALE_SOURCE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void fieldLevelQueryAutoReingestsTheChangedModule() {
|
||||
// A field-level query routed through DeepIngestCoordinator.ensureDeep detects the changed hash,
|
||||
// invalidates the FULL module and re-ingests it (re-stamping the hash) — without a manual refresh.
|
||||
given().when()
|
||||
.get("/api/projects/" + PROJECT + "/variables/X/flow-forward?module=" + MODULE)
|
||||
.then().statusCode(200);
|
||||
|
||||
// The auto re-ingest re-hashed the file, so the source read no longer reports STALE_SOURCE.
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/" + MODULE + "/source?startLine=1&endLine=2")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.path.json.JsonPath;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Item 77: bare-field resolution must reason about <em>one module's</em> includes.
|
||||
*
|
||||
* <p>{@code resolveBareIncludedFieldTargets} promises to redirect a bare reference onto a real field
|
||||
* "only when exactly one field of that name is reachable through <em>the module's</em> resolved
|
||||
* {@code INCLUDES}". It did not: an unresolved bare field is a single node per {@code (name, project)}
|
||||
* (item 76 — {@code MERGE_NODES} keys on {@code sourceFile}, which is {@code ""} here), so the query
|
||||
* matched it once per owning module, then aggregated with {@code WITH ph, collect(...)} — dropping
|
||||
* {@code m} — and left {@code src} bound to nothing. The uniqueness test ran across all owners at once,
|
||||
* and every owner's edges were redirected onto whatever single field the others agreed on.
|
||||
*
|
||||
* <p>Both fixtures below are built so the defect shows up as a <b>fabricated dataflow</b>: {@code
|
||||
* field-flow} pairs a producer with a consumer only when both touch the <em>same</em> node, so wrongly
|
||||
* sharing a placeholder makes it claim a field travels between two modules that share nothing but a name.
|
||||
* That is the dangerous direction — an agent reading the dossier sees a data dependency that does not
|
||||
* exist. Measured in {@code upms} before the fix: 38 placeholders where owners disagreed, and 28 (199
|
||||
* module-field pairs) where a module was credited with a data area it does not include — e.g.
|
||||
* {@code BMTABBP0} writing {@code ##MSG-NR} of {@code CDPDA-M.pda}.
|
||||
*
|
||||
* <p>Note {@code variables/{name}/reads|writes} cannot see any of this: it matches every node with the
|
||||
* name and reports only the accessing side, so it collapses all targets into one answer either way.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class BareFieldModuleScopeIT {
|
||||
|
||||
private static final String PROJECT = "nat-bare-field-scope";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
for (String f : List.of("BARESH1.lda", "BARESH2.lda", "BAREONE.lda",
|
||||
"BAREWRIT.nat", "BAREREAD.nat", "BAREHAS.nat", "BARELACK.nat")) {
|
||||
copyFixture("fixtures/natural/barefield/" + f);
|
||||
}
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void copyFixture(String classpathResource) {
|
||||
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
|
||||
try (InputStream in = BareFieldModuleScopeIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Resource not found: " + classpathResource);
|
||||
}
|
||||
Files.write(root.resolve(fileName), in.readAllBytes());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonPath fieldFlow(String field) {
|
||||
return given().pathParam("name", field)
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/field-flow")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two modules include <em>different</em> data areas that happen to declare the same field name.
|
||||
* They are unrelated fields, so no value flows from one to the other.
|
||||
*
|
||||
* <p>Before the fix the two candidates were collected under one shared placeholder, {@code
|
||||
* size(matches) = 1} failed, and <em>neither</em> module resolved — leaving both pointing at the
|
||||
* shared node, which is precisely what makes {@code field-flow} pair them up.
|
||||
*/
|
||||
@Test
|
||||
void twoModulesIncludingDifferentAreasWithTheSameFieldNameDoNotShareAField() {
|
||||
List<String> producers = fieldFlow("SHARED-FLD").getList("producer");
|
||||
|
||||
assertTrue(producers.isEmpty(),
|
||||
"BAREWRIT writes BARESH1.SHARED-FLD and BAREREAD reads BARESH2.SHARED-FLD — different "
|
||||
+ "data areas, no flow. Reported: " + fieldFlow("SHARED-FLD").getList("$"));
|
||||
}
|
||||
|
||||
/**
|
||||
* A module whose {@code USING} include is unresolvable ({@code BARENOPE} does not exist) keeps its
|
||||
* bare field unresolved — it must not be credited with a data area it never includes just because
|
||||
* another module resolved the same name. This is the {@code BMTABBP0} shape from {@code upms}.
|
||||
*/
|
||||
@Test
|
||||
void aModuleWithoutTheIncludeIsNotGivenAnotherModulesField() {
|
||||
List<String> producers = fieldFlow("LONE-FLD").getList("producer");
|
||||
|
||||
assertTrue(producers.isEmpty(),
|
||||
"BAREHAS includes BAREONE and resolves; BARELACK's include is missing, so its LONE-FLD "
|
||||
+ "stays unresolved and the two do not share a node. Reported: "
|
||||
+ fieldFlow("LONE-FLD").getList("$"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The fix must not stop resolution working where it always did: a module with an unambiguous include
|
||||
* still resolves, and does so to <em>its own</em> data area. Without this the two tests above would
|
||||
* pass for the wrong reason — resolution failing everywhere also yields no flow.
|
||||
*/
|
||||
@Test
|
||||
void anUnambiguousBareFieldStillResolvesToItsOwnDataArea() {
|
||||
JsonPath body = given().pathParam("name", "BAREONE")
|
||||
.when().get("/api/projects/" + PROJECT + "/data-structures/{name}/fields")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
|
||||
assertEquals(List.of("LONE-FLD"), body.getList("name"),
|
||||
"BAREONE must still expose its field; full response: " + body.getList("$"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||
import io.quarkus.test.junit.TestProfile;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.path.json.JsonPath;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.hasItem;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Item 67: when the call-tree traversal stops at its raw-hop budget, the response must say so.
|
||||
*
|
||||
* <p>{@code depth} counts module hops, but the traversal still walks raw {@code CALLS} edges, so it
|
||||
* carries a budget of raw hops per module hop as a safety cap. Whenever a cap can cut a result, the
|
||||
* question is what the caller is told: items 65 and 68 were bugs precisely because a bounded traversal
|
||||
* returned a short list that read as a complete answer ("no DB access", "nothing consumes this field").
|
||||
* A cap that announces itself is a documented limit; a silent one is the same bug again.
|
||||
*
|
||||
* <p>The budget is driven down to 2 rather than nesting the fixture absurdly deep, because real Natural
|
||||
* nesting does not reach the default of 20 (measured on {@code upms}: at most 9 internal hops for a
|
||||
* single module hop). {@code DEPTHLEAF} sits 5 raw edges behind {@code DEEPCHAIN}'s {@code PERFORM} chain.
|
||||
*
|
||||
* <p><b>Item 94 changed what the budget can cut.</b> The budget used to bound the whole traversal, so an
|
||||
* internal {@code PERFORM} chain longer than it also hid the <em>module</em> at the end of that chain —
|
||||
* {@code DEPTHLEAF} was dropped although it is one module hop away, and {@code db-accesses?depth=1},
|
||||
* which has always taken its module set from the same BFS, reported it. The two endpoints contradicted
|
||||
* each other. Module rows now come from that BFS and are exact, so the budget bounds only the
|
||||
* intra-module subroutine walk. What gets cut here is therefore {@code S3}/{@code S4}, not
|
||||
* {@code DEPTHLEAF} — and that cut still has to announce itself, which is the point the test protects.
|
||||
*/
|
||||
@QuarkusTest
|
||||
@TestProfile(CallTreeTruncationIT.TinyBudgetProfile.class)
|
||||
class CallTreeTruncationIT {
|
||||
|
||||
private static final String PROJECT = "nat-call-tree-truncation";
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
copyFixture("fixtures/natural/moduledepth/DEEPCHAIN.nat");
|
||||
copyFixture("fixtures/natural/moduledepth/DEPTHLEAF.nat");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void copyFixture(String classpathResource) {
|
||||
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
|
||||
try (InputStream in = CallTreeTruncationIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Resource not found: " + classpathResource);
|
||||
}
|
||||
Files.write(root.resolve(fileName), in.readAllBytes());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTraversalThatHitsItsBudgetSaysSoInsteadOfReturningAShortList() {
|
||||
JsonPath body = given().pathParam("name", "DEEPCHAIN")
|
||||
.queryParam("depth", 1)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
|
||||
// The near end of the chain is still reported, so the response is a plausible-looking partial
|
||||
// answer rather than an obvious failure — which is exactly why it has to flag itself.
|
||||
assertEquals(0, body.getInt("items.find { it.name == 'S1' }.depth"),
|
||||
"a subroutine of the root module crosses no module boundary");
|
||||
body.setRootPath("");
|
||||
assertTrue(body.getBoolean("truncated"),
|
||||
"the intra-module walk stopped at its budget, so the caller must be told the list may be "
|
||||
+ "incomplete. Full response was: " + body.getList("items.name"));
|
||||
org.hamcrest.MatcherAssert.assertThat(
|
||||
"S3/S4 sit past the budget of 2 — the subroutine chain is what got cut",
|
||||
body.getList("items.name"), not(hasItem("S3")));
|
||||
org.hamcrest.MatcherAssert.assertThat(
|
||||
body.getList("items.name"), not(hasItem("S4")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 94: the budget bounds the intra-module walk only. A module one hop away stays in the tree
|
||||
* however deep inside the caller its call site sits — otherwise {@code call-tree} would keep
|
||||
* disagreeing with {@code db-accesses}/{@code sql-statements}, which derive their module set from the
|
||||
* same BFS and have always reported it.
|
||||
*/
|
||||
@Test
|
||||
void aModuleOneHopAwayIsReportedEvenWhenItsCallSiteSitsPastTheBudget() {
|
||||
given().pathParam("name", "DEEPCHAIN")
|
||||
.queryParam("depth", 1)
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/call-tree")
|
||||
.then().statusCode(200)
|
||||
.body("items.find { it.name == 'DEPTHLEAF' }.depth", org.hamcrest.Matchers.is(1));
|
||||
}
|
||||
|
||||
public static class TinyBudgetProfile implements QuarkusTestProfile {
|
||||
@Override
|
||||
public Map<String, String> getConfigOverrides() {
|
||||
return Map.of("agenticcode.call-tree.internal-budget", "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class CoalescingIT {
|
||||
copyFixture("fixtures/natural/DF_CALLER.nat");
|
||||
copyFixture("fixtures/natural/DF_CALLEE.nat");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null))
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ class CoalescingIT {
|
||||
assertTrue(state.isFull(), "the module is FULL after concurrent deep ingests");
|
||||
assertEquals(IngestStatus.INGESTED.name(), state.status());
|
||||
|
||||
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", 50, 0)
|
||||
List<IdentifierMatch> realModules = graphRepository.searchIdentifier(PROJECT, MODULE, "MODULE", null, null, null, false, 50, 0)
|
||||
.await().indefinitely().stream()
|
||||
.filter(m -> !m.sourceFile().isEmpty())
|
||||
.toList();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import io.restassured.path.json.JsonPath;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* Acceptance test for general copycode ({@code .cpy}) expansion (roadmap item 46a): a host module
|
||||
* ({@code COPYHOST}) whose only DB access and call live inside a copycode member
|
||||
* ({@code MYTABLECOPY.cpy}) it pulls in with a statement-level {@code INCLUDE}. Without expansion the
|
||||
* host's {@code db-accesses}/{@code callees} are empty; with it, the copycode's {@code READ MYTABLE}
|
||||
* and {@code CALLNAT 'COPYSUB'} surface on the host. {@code .cpy} is <em>not</em> an ingestible module
|
||||
* (see {@code SourceFiles}); it enters the graph only via the copycode library the ingester builds.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class CopycodeExpansionIT {
|
||||
|
||||
private static final String PROJECT = "nat-copycode";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
copyFixture("fixtures/natural/copycode/MYTABLECOPY.cpy");
|
||||
copyFixture("fixtures/natural/copycode/COPYHOST.nat");
|
||||
copyFixture("fixtures/natural/copycode/MYLDA.lda");
|
||||
copyFixture("fixtures/natural/copycode/QUALCOPY.cpy");
|
||||
copyFixture("fixtures/natural/copycode/QUALHOST.nat");
|
||||
copyFixture("fixtures/natural/copycode/BROWSECPY.cpy");
|
||||
copyFixture("fixtures/natural/copycode/BROWSEHOST.nat");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void copyFixture(String classpathResource) {
|
||||
String fileName = classpathResource.substring(classpathResource.lastIndexOf('/') + 1);
|
||||
try (InputStream in = CopycodeExpansionIT.class.getClassLoader().getResourceAsStream(classpathResource)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("Resource not found: " + classpathResource);
|
||||
}
|
||||
Files.write(root.resolve(fileName), in.readAllBytes());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** The copycode's READ MYTABLE surfaces as a direct DB access of the including module. */
|
||||
@Test
|
||||
void copycodeDbAccessSurfacesOnHost() {
|
||||
given().pathParam("name", "COPYHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/db-accesses")
|
||||
.then().statusCode(200)
|
||||
.body("name", hasItem("MYTABLE"))
|
||||
.body("findAll { it.name == 'MYTABLE' }.mode", hasItem("READS"));
|
||||
}
|
||||
|
||||
/** The copycode's CALLNAT surfaces as a callee of the including module. */
|
||||
@Test
|
||||
void copycodeCallSurfacesOnHost() {
|
||||
given().pathParam("name", "COPYHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("COPYSUB"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 66: the call site must name the file its line is really in. The {@code CALLNAT} is on line 8
|
||||
* of {@code MYTABLECOPY.cpy}; line 8 of {@code COPYHOST.nat} is {@code END-DEFINE}. Before item 66 the
|
||||
* response carried a bare {@code lineNos: [8]} with no way to tell the two apart.
|
||||
*/
|
||||
@Test
|
||||
void copycodeCallSiteNamesTheCopycodeFileNotTheHost() {
|
||||
JsonPath body = given().pathParam("name", "COPYHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
|
||||
Map<String, Object> site = body.getMap("items.find { it.name == 'COPYSUB' }.sites[0]");
|
||||
assertEquals("MYTABLECOPY", site.get("viaCopycode"));
|
||||
assertEquals(11, site.get("includedAt"), "the host INCLUDE is on line 11 of COPYHOST.nat");
|
||||
assertEquals(8, site.get("lineNo"), "the CALLNAT is on line 8 of the copycode");
|
||||
Object fileIndex = site.get("callSiteFileIndex");
|
||||
assertNotNull(fileIndex, "a call site must name the file its line is in");
|
||||
assertEquals("MYTABLECOPY.cpy", body.getList("sourceFiles").get((Integer) fileIndex),
|
||||
"line 8 belongs to the copycode — line 8 of COPYHOST.nat is END-DEFINE");
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 66: a copycode-origin variable access must not be reported against the host file.
|
||||
*
|
||||
* <p>{@code #KEY} is written from two different files: by {@code MOVE 'Y' TO #KEY} on line 10 of
|
||||
* {@code COPYHOST.nat}, and by the copycode's two {@code MOVE ... TO &1&} statements on lines 10 and
|
||||
* 11 of {@code MYTABLECOPY.cpy}. All three used to be reported against {@code COPYHOST.nat} — and
|
||||
* line 11 of the host is the {@code INCLUDE} itself, not a write. Only the file each line belongs to
|
||||
* tells them apart.
|
||||
*/
|
||||
@Test
|
||||
void copycodeVariableAccessNamesTheCopycodeFile() {
|
||||
JsonPath body = given().pathParam("name", "#KEY")
|
||||
.queryParam("module", "COPYHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
|
||||
.then().statusCode(200)
|
||||
// Assert the copycode write exists before asserting what it says — everyItem() on an
|
||||
// empty list passes vacuously, which would make this test prove nothing.
|
||||
.body("findAll { it.viaCopycode == 'MYTABLECOPY' }", not(empty()))
|
||||
.extract().jsonPath();
|
||||
|
||||
assertEquals(List.of("MYTABLECOPY.cpy", "MYTABLECOPY.cpy"),
|
||||
body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.sourceFile"),
|
||||
"the MOVEs are written in the copycode, so their lines are the copycode's lines");
|
||||
// Sorted: the endpoint promises no ordering between the two writes, so asserting one would
|
||||
// pin down behaviour it does not guarantee.
|
||||
assertEquals(List.of(10, 11),
|
||||
body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.lineNo").stream().sorted().toList());
|
||||
assertEquals(List.of(11, 11), body.getList("findAll { it.viaCopycode == 'MYTABLECOPY' }.includedAt"),
|
||||
"and they point back at the INCLUDE that pulled them in");
|
||||
|
||||
// The host's own MOVE — same line number, different file, no copycode marker.
|
||||
assertEquals(List.of("COPYHOST.nat"),
|
||||
body.getList("findAll { it.viaCopycode == null }.sourceFile"),
|
||||
"full response was: " + body.getList("$"));
|
||||
assertEquals(List.of(10), body.getList("findAll { it.viaCopycode == null }.lineNo"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 69: two edges that differ only in the file their line belongs to must both survive.
|
||||
*
|
||||
* <p>{@code #KEY} is written on <b>line 10 of two different files</b> — {@code MOVE 'Y' TO #KEY} in
|
||||
* {@code COPYHOST.nat} and the copycode's {@code MOVE 'Z' TO &1&} in {@code MYTABLECOPY.cpy}. Both
|
||||
* writes are made by the same module to the same variable, so before item 69 they shared the entire
|
||||
* edge MERGE key — {@code (source, target, type, lineNo)}, with no file — and the second
|
||||
* {@code SET r += e.properties} silently overwrote the first: the endpoint returned a single row and
|
||||
* one real write was gone from the graph.
|
||||
*
|
||||
* <p>This is the collision the item-66 fixture was originally shaped to avoid (its copycode write was
|
||||
* parked on line 11 for exactly this reason). Note that corpus incidence is unmeasurable after the
|
||||
* fact: the collision destroys the very evidence one would count.
|
||||
*/
|
||||
@Test
|
||||
void twoWritesOnTheSameLineInDifferentFilesBothSurvive() {
|
||||
JsonPath body = given().pathParam("name", "#KEY")
|
||||
.queryParam("module", "COPYHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
|
||||
.then().statusCode(200)
|
||||
.extract().jsonPath();
|
||||
|
||||
assertEquals(List.of("COPYHOST.nat", "MYTABLECOPY.cpy"),
|
||||
body.getList("findAll { it.lineNo == 10 }.sourceFile").stream().sorted().toList(),
|
||||
"line 10 exists in both files and each holds a real write — collapsing them loses one. "
|
||||
+ "Full response was: " + body.getList("$"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Items 120/121/123 in one fixture, because in the corpus they occur in one statement.
|
||||
*
|
||||
* <p>{@code BROWSEHOST} pulls in a browse copycode whose {@code CALLNAT} target exists only after
|
||||
* substitution. Three separate defects each broke it on their own:
|
||||
* <ul>
|
||||
* <li><b>120</b> — the arguments run onto line 12, and only line 11 was read, so {@code &2&}
|
||||
* survived substitution verbatim and the call was dropped;</li>
|
||||
* <li><b>121</b> — {@code '''AGNT-CHG-CMP-SP'''} is <em>one</em> literal, but split into three
|
||||
* arguments, shifting every later position by one;</li>
|
||||
* <li><b>123</b> — the target is written {@code '"YAGCHBN0"'}, and the {@code CALLNAT} pattern
|
||||
* accepted only {@code '} as a string delimiter.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Measured over {@code upms}: fixing 120 alone recovers <b>0</b> edges — its own motivating
|
||||
* example is a 123 case. All three together recover 2672 copycode-derived call pairs (+49%), 0
|
||||
* lost. That is why the fixture asserts the call surfaces rather than asserting three mechanisms.
|
||||
*/
|
||||
@Test
|
||||
void aBrowseIncludeWithMultiLineEscapedArgumentsResolvesItsCall() {
|
||||
JsonPath body = given().pathParam("name", "BROWSEHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/callees")
|
||||
.then().statusCode(200)
|
||||
.body("items.name", hasItem("YAGCHBN0"))
|
||||
// Item 121's phantom: the sort key bound to &2& and became a MODULE node of its own,
|
||||
// so the endpoint reported a call that does not exist while hiding the one that does.
|
||||
.body("items.name", not(hasItem("AGNT-CHG-CMP-SP")))
|
||||
.extract().jsonPath();
|
||||
|
||||
Map<String, Object> site = body.getMap("items.find { it.name == 'YAGCHBN0' }.sites[0]");
|
||||
assertEquals("BROWSECPY", site.get("viaCopycode"));
|
||||
assertEquals(8, site.get("lineNo"), "the CALLNAT is on line 8 of BROWSECPY.cpy");
|
||||
assertEquals(11, site.get("includedAt"),
|
||||
"the INCLUDE is on line 11 of BROWSEHOST.nat — line 12 is its argument continuation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 70: placeholder resolution must not throw the copycode provenance away.
|
||||
*
|
||||
* <p>A field reference is resolved in two different ways depending on how it is written. A
|
||||
* <em>bare</em> one ({@code #W-OPTIONS}) goes through {@code resolveBareIncludedFieldTargets}, which
|
||||
* copies the whole edge with {@code SET r2 += properties(r)}. A <em>qualified</em> one
|
||||
* ({@code MYLDA.Q-FIELD}) goes through {@code resolvePlaceholderFieldTargets}, which used to copy
|
||||
* only {@code value} and {@code lineNo} — silently dropping {@code originFile}/{@code viaCopycode}/
|
||||
* {@code includedAt}, so item 66's provenance was reconstructed as the host file again.
|
||||
*
|
||||
* <p>That asymmetry is why the item-66 work looked complete against {@code upms}: the field it was
|
||||
* verified on happened to be bare. This fixture takes the qualified path on purpose.
|
||||
*/
|
||||
@Test
|
||||
void qualifiedFieldResolutionKeepsCopycodeProvenance() {
|
||||
JsonPath body = given().pathParam("name", "Q-FIELD")
|
||||
.queryParam("module", "QUALHOST")
|
||||
.when().get("/api/projects/" + PROJECT + "/variables/{name}/writes")
|
||||
.then().statusCode(200)
|
||||
// Prove the write survives resolution at all before asserting what it says.
|
||||
.body("$", not(empty()))
|
||||
.extract().jsonPath();
|
||||
|
||||
assertEquals(List.of("QUALCOPY.cpy"), body.getList("sourceFile"),
|
||||
"the MOVE is written in the copycode, not in QUALHOST.nat. "
|
||||
+ "Full response was: " + body.getList("$"));
|
||||
assertEquals(List.of("QUALCOPY"), body.getList("viaCopycode"));
|
||||
assertEquals(List.of(3), body.getList("lineNo"), "the MOVE is on line 3 of QUALCOPY.cpy");
|
||||
assertEquals(List.of(6), body.getList("includedAt"), "the INCLUDE is on line 6 of QUALHOST.nat");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user