# Java lane (source) setup

The Java lane lifts the answer from the SQL statement level up to the **HTTP
endpoint** level: it parses a Spring/MyBatis Java source tree and stitches
`endpoint → controller → service → mapper` onto the SQL lane's
`mapper → statement → table → column`. A column-impact query then reaches the
endpoints that touch the column.

## What it needs

- **A JDK (javac + java), version 17 or newer.** The lane uses the JDK's own
  compiler Tree API (`com.sun.source`) in **parse-only** mode — it never runs a
  build, never resolves your project's dependencies, and needs no Gradle/Maven.
  It parses your `.java` files with Spring/MyBatis absent and resolves app-local
  types itself. No JDK jars beyond the standard `jdk.compiler` module.
- Nothing else. No classpath, no `pom.xml`/`build.gradle` resolution.

### Point the CLI at a JDK

The CLI looks for `javac`/`java` in this order:

1. `$JAVA_HOME/bin`
2. `/opt/homebrew/opt/openjdk/bin`, `/usr/local/opt/openjdk/bin` (Homebrew keg)
3. `javac`/`java` on `PATH`

Install one if you have none:

```bash
# macOS
brew install openjdk
export JAVA_HOME=/opt/homebrew/opt/openjdk   # or add its bin to PATH

# Debian/Ubuntu
sudo apt-get install default-jdk

# Windows
winget install EclipseAdoptium.Temurin.21.JDK
```

## Run it

Add `--java-src <dir>` (repeatable) to `cascade analyze`. Point it at your
backend source roots:

```bash
node bin/cascade.mjs analyze \
  --ddl      ../target-examples/mall/document/sql/mall.sql \
  --mappers  ../target-examples/mall/mall-mbg/src/main/resources \
  --mappers  ../target-examples/mall/mall-admin/src/main/resources/dao \
  --java-src ../target-examples/mall/mall-admin/src/main/java \
  --java-src ../target-examples/mall/mall-mbg/src/main/java \
  --root ../target-examples/mall --out .cascade/pack --project mall
```

The first run compiles the worker (`adapters/java/JavaFacts.java`) into
`.java-build/` (gitignored) and reuses it thereafter. The pack's `meta.lanes`
records `["sql","java"]` when the Java lane ran.

Then ask, over MCP:

```
endpoint_impact { "column": "pms_product.price" }
→ POST /product/create, POST /product/update/{id}, …  [SOUND_SET]
```

## What it does — and its honest grade

| Relation | Grade | Why |
|---|---|---|
| `endpoint → handler` (HANDLES) | **EXACT** | a Spring mapping annotation on a **concrete controller** method *is* its handler — definitional. So is a path written with a constant the tree states, and a mapping annotation the project declares itself ([below](#a-path-written-with-a-constant-and-a-mapping-annotation-the-project-writes)) |
| `endpoint → handler` (HANDLES), handlers a request condition splits | **SOUND_SET** | two or more handlers declare one route and one of them narrows it by `params`, `headers`, `consumes` or `produces`: Spring picks one per request, by what the request carries |
| `endpoint → handler` (HANDLES), a `method` that is no HTTP method | **HEURISTIC** | the mapping's `method` names something that is not a verb (a constant this lane does not read), so the route is placed on `ANY`, which may be wider than the truth |
| `endpoint → implementer` (HANDLES) | **SOUND_SET** | a mapping on an interface/abstract *declaration* is a route **contract**; the handler is the implementer matched through `implements` by name (and arity) — a resolution, not a definition |
| `endpoint → handler` (HANDLES), a functional route | **EXACT**, **SOUND_SET** or **HEURISTIC** | a route built with calls in a method that returns a `RouterFunction`: EXACT where the source names the class and the method and nothing overrides it, SOUND_SET through a declared type whose every member the tree shows, HEURISTIC through a type the tree cannot close, and where only an OpenAPI document places the route and the profile does not say the document is generated from this code ([below](#routes-built-with-calls-functional-endpoints)) |
| `endpoint → handler` (HANDLES), a contract-first controller | **HEURISTIC**, or **EXACT** when declared | a controller implements an interface a code generator writes at build time; only the generator's naming pairs the method with the document's route, unless the profile says the build generates the interfaces from that document (`openapi.generatesCode`) ([below](#a-controller-that-implements-a-generated-interface)) |
| `clientMethod → endpoint` (CALLS_HTTP) | **SOUND_SET** | an HTTP client call reaches a route this pack also serves — the internal HTTP hop. Two ways of writing one: a `@FeignClient`/`@HttpExchange` method, and an imperative `WebClient`/`RestClient`/`RestTemplate` call |
| `clientMethod → endpoint` (CALLS_HTTP) | **UNRESOLVED** | …or one it does not serve: the target is outside the pack, so no walk follows the edge and it is counted instead (`httpCallsUnresolved`) |
| `mapperMethod → statement` (IMPLEMENTS_STMT) | **EXACT** | a MyBatis statement id *is* the mapper interface FQN + method — definitional |
| `daoMethod → statement` (IMPLEMENTS_STMT) | **EXACT** | …or the DAO **names the statement outright** in the call, and that literal *is* the key MyBatis looks it up by — definitional the same way ([below](#a-dao-that-names-its-statement-in-the-call)) |
| `caller → callee`, `interface → impl` (MAY_CALL) | **SOUND_SET** | calls are resolved from the parse tree (receiver → field → declared type, method-by-name) and interface dispatch is a class-hierarchy over-approximation — a sound candidate set, **not** compiler-verified |
| `repositoryMethod → statement` (IMPLEMENTS_STMT) | **EXACT** | a Spring Data repository method *is* the statement Spring Data generates for it — definitional, same rule as MyBatis |

So an endpoint reached **through the call graph** carries the weakest link on
its path — **SOUND_SET** — and is reported as a candidate, never as confirmed.
This is deliberate: parse-only resolution without dependency binding cannot
honestly claim EXACT for a call, and narrowing a candidate set to one member
would not make it one.

### Which calls the lane can see

Every resolved call shape, all by NAME, all graded SOUND_SET:

| written as | `evidence.rule` | resolved to |
|---|---|---|
| `repo.save(x)` | `field-receiver` | the declared type of the field `repo` |
| `this.repo.save(x)` | `this-field` | the same field — the `this.` spelling is not a different call |
| `helper(x)` (unqualified) | `unqualified-enclosing` | a method of the **enclosing type** (or one it inherits) |
| `this.helper(x)` | `unqualified-enclosing` | the same thing — one call, one rule |
| `super.exportXls(…)` | `super-enclosing` | the first ancestor up the `extends` chain that **declares** the method |
| `service.list(…)` where `service` is typed by a **type parameter** | `type-param-binding` | the binding **that subclass** makes (`class C extends B<A, IAService>`), one edge from the subclass's own copy of the method, with `evidence.boundThrough` naming where the binding was spelled and `evidence.inheritedFrom` naming the shared body the call site is in |
| an interface method | `interface-dispatch` | every implementor (a class-hierarchy over-approximation) |
| `cmmUseService.selectCodes(…)` where the field carries `@Resource(name = "EgovCmmUseService")` | `spring-bean-name` | the one class in this pack that answers to that bean name, when exactly one does ([below](#a-field-injected-by-name)) |
| `log.info(…)` in a `@Slf4j` class | `generated-field` | the logger type that Lombok's annotation generates (`org.slf4j.Logger` …). The field is real at run time and in no parse tree, so nothing but the annotation can explain the receiver |
| `ringData.computeIfAbsent(…)` where the field's type came in through `import java.util.*` | `wildcard-jdk` | `<that package>.<Simple>`. The JDK is a closed world this lane never reads, so the call leaves the project |

#### Which name means which type

A simple name is resolved the way the language resolves it, in this order:

1. a **member type in scope** — this type's own nested types, then each
   enclosing type's. A nested class has no compilation unit of its own, so its
   imports are the top-level type's;
2. a **single-type import** of the file;
3. the **same package**;
4. an **on-demand import** whose package holds a type this lane analyzed;
5. **`java.lang`**, which every compilation unit imports whether it says so or
   not (`String`, `System`, `Integer`, `Math`, `Thread` …);
6. a globally **unique** simple name — this engine's own last resort, and the
   only step the language does not have.

Step 5 sits where it does because that is the language's precedence: a project
class called `Process` in another package is not what `Process.x()` means in a
file that never imported it.

When a name is left over and the file carries wildcard imports, which one it
came from is decided by the **project's own import lines**: somewhere in a tree
this size another file writes `import org.jeecg.common.util.RedisUtil;`, and
that line proves which package holds that type. Only when nothing witnesses the
name does the category decide (a package of this project first, then the JDK).

#### What is left, and why

Three of the rules can fail in their own way, and the failure is named for what
failed rather than folded into the field rule:

| `unresolvedCallsByRule` key | means |
|---|---|
| `super-enclosing` | `super.m()` whose base class this lane neither parsed nor could name (measured on jeecg-boot: 3 of 73 `super.` calls; 40 more name a base the imports do name, and those become an edge that leaves the project) |
| `type-param-unbound` | no subclass in this pack binds that type parameter, so the call site has no callee here |
| `inherited-field` | a receiver no field, no type and no annotation explains |

…and beside the rule that failed, `unresolvedCallsByReason` says what was
**missing**, which is the half a reader can act on:

| `unresolvedCallsByReason` key | means |
|---|---|
| `project-type-outside-roots` | a wildcard import names a package of this project and no analyzed source root holds the type. **Pass `--java-src <module>/src/main/java`** and the calls resolve — or the module is a dependency, and the chain really does end there. `laneStats.typesOutsideRoots` names the packages |
| `superclass-outside-roots` | the `extends` chain ended at a name this lane could not resolve at all |
| `type-param-unbound` | as above |
| `unknown` | a third-party type behind a wildcard, a constant somebody static-imported, a field a code generator adds after parsing |

`type-param-binding` is an over-approximation of **one call site**, not a guess,
and it is resolved **once per subclass**. A generic base writes
`service.list(…)` once and thirty controllers run it, so the answer is thirty
separate edges, each leaving the controller that runs that body and naming the
one service that controller binds. Pooling them on the base method instead is
the same set of targets attached to the wrong symbol, and it reads as every
controller touching every sibling's tables: on jeecg-boot that was 9748 of
25376 endpoint-to-column pairs.

Which subclass carries the edge is read from the code, never assumed:

- a subclass that **inherits** the method outright has no body of its own, so
  the ancestor's is what runs. The member is instantiated for that class (see
  `inherited-member-call`) and the copy carries the call;
- a subclass that **overrides** it carries the call where it writes
  `super.exportXls(…)`, which is also what says WHICH base method runs —
  jeecg-boot's `JeecgDemoController#exportXls` calls `super.exportXlsSheet(…)`,
  a different method with a different body;
- an override that never calls `super` replaced the body, so the ancestor's
  call site is not in it and no edge is made.

**Every MAY_CALL edge carries its rule** in `evidence.rule`, with a one-sentence
`evidence.basis` saying what that rule actually did. They share one grade
(SOUND_SET — none is compiler-verified) but they do not share one failure mode,
and a reader deciding how far to trust a chain is entitled to know which one it
rested on.

The unqualified rule is what keeps a chain alive through a controller's private
helper: `processFindForm` → `findPaginatedForOwnersLastName` → `owners.find…`.
A name brought in by an `import static` is **not** treated as a call on the
enclosing type — it is skipped rather than mis-attributed.

**How often does the unqualified rule misattribute?** It resolves to the
enclosing type, so a method that type INHERITS is attributed to the subclass
rather than to the class that declares it. Measured over every one of the 8753
unqualified call sites in macrozheng/mall (8752 bare + the one written
`this.loadDataSource()`, whose method its own class declares), by checking
whether the method is declared in the enclosing type's own file:
**153 misattributed, 1.7%** — 152 of
them `getClass()`/`hashCode()` inherited from `java.lang.Object`, and one real
case (`CommentGenerator#addFieldJavaDoc → addJavadocTag()`, inherited from
MyBatis-generator's `DefaultCommentGenerator`). In hand-written code — the 141
sites outside generated `…Example`/`.model.` classes — the rate is **1 of 141**.

### A DAO that names its statement in the call

MyBatis has two shapes and the lane reads both. The one the documentation leads
with is a mapper **interface**, where the statement id is the interface's FQN
plus the method name, so the binding is definitional. The other has no interface
at all:

```java
@Repository("CmmnCodeManageDAO")
public class CmmnCodeManageDAO extends EgovAbstractMapper {
    public List<?> selectCmmnCodeList(ComDefaultVO vo) {
        return selectList("CmmnCodeManageDAO.selectCmmnCodeList", vo);
    }
}
```

That literal **is** the key MyBatis looks the statement up by at run time, and
the mapper XML declares the same key as its `namespace` plus the statement's
`id`. There is nothing between the two to resolve, so the edge is EXACT, with
`evidence.rule` `mybatis-statement-id`. An `id` that already starts with the
namespace is not prefixed again, which is what MyBatis itself does
(`MapperBuilderAssistant.applyCurrentNamespace`): `<mapper namespace="loginDAO">`
with `<select id="loginDAO.actionLogin">` is the statement `loginDAO.actionLogin`.
Measured, 112 of the 1,435 statements of the eGovFrame enterprise business
template and 43 of the common components are written that way, and until this was
read their DAO calls bound nothing.

This is the whole persistence layer of eGovFrame, which is the framework Korean
public sector projects are required to build on: 1,288 call sites in
`eGovFramework/egovframe-common-components` and not one `@Mapper` interface.
Before the rule existed, that repository connected **1,193 routes to 1,256
statements with zero edges between them**.

What the rule reads, and what it refuses:

- the receiver has to be a MyBatis **session**. The `extends` chain is walked by
  simple name for `SqlSession`, `SqlSessionTemplate`, `SqlSessionDaoSupport`,
  `EgovAbstractMapper` or `EgovComAbstractDAO` — by name, because that base
  almost always ships in a jar this run never parsed, and the `extends` clause in
  the source is the whole evidence. A field of one of those types answers the
  same question for a DAO that holds a session rather than extending one;
- the method name has to be one of `selectList`, `selectOne`, `selectMap`,
  `select`, `selectCursor`, `list`, `selectByPk`, `insert`, `update`, `delete`.
  The name alone decides nothing: a service's own `insert(vo)` has it too, and
  the receiver test is what keeps the rule off it;
- the first argument has to be a **string literal**, or a `static final String`
  of the same class initialised with one, shaped like `<namespace>.<id>`.
  Measured: 1,289 of 1,298 sites in the common components are plain literals;
- a literal naming a statement **this pack does not hold** gets no edge and is
  listed by name in `laneStats.statementIds.unknownSamples`. A mapper XML left
  outside the run and a typo look the same from here, and only a reader can tell
  them apart;
- a first argument no single file can read (a parameter, a concatenation) is
  counted in `unreadable`, with the expression as it was written.

`laneStats.statementIds` carries the whole census: `sites`, `bound`, `unknown`,
`unreadable`, `repeated`, `fromConstant`.

A constant of **another** class (`CmmnConstants.SELECT_CODES`) is one of the
`unreadable`, on purpose: nothing in the corpus writes one, and a resolver with
no repository behind it is a rule nobody has checked.

For the same reason a session fetched and used in one expression
(`getSqlSession().selectList("X.y", vo)`) is not read: the shape has **zero
sites** in all seventeen corpus repositories. The two `getSqlSession()` calls
that are there both fetch a JDBC connection to read table metadata, which names
no statement at all.


#### The same DAO one generation earlier: iBATIS 2

The shape above is what a project written on MyBatis 3 looks like. A project
written before it looks like this:

```java
@Repository("userDAO")
public class UserDAO extends NexacroIbatisAbstractDAO {
    public List<UserVO> selectUserVoList(UserVO searchVO) {
        return (List<UserVO>) list("selectUserVOList", searchVO);
    }
}
```

Two things are different, and the rule reads both.

**The receivers.** `SqlMapClient`, `SqlMapClientTemplate`,
`SqlMapClientDaoSupport` and `EgovAbstractDAO` join the session list above, and
so does any class whose `extends` chain names a type ending in
`IbatisAbstractDAO`. That suffix is there because a framework vendor writes its
own base on top of one of those and ships it in a jar, so the tree says
`extends NexacroIbatisAbstractDAO` and nothing more; naming the vendor instead
would be a rule that works on one product. `queryForList`, `queryForObject` and
`queryForMap` join the method list.

**The id.** iBATIS defaults `useStatementNamespaces` to false, so the runtime
key is a **bare word** with no namespace on it. A bare word is a weaker witness
than `Namespace.id` — it could be anything — so the worker records it under its
own field and the bridge binds it only when the pack holds exactly one statement
under that id, which is what the SQL lane produces when the configuration leaves
namespaces off (`docs/setup/sql-lane.md`). A bare word never finds a namespaced
statement by looking like the end of it: `selectCodeList` does not bind
`codeDAO.selectCodeList`. A word that answers to no statement changes nothing at
all and stays in the `unreadable` census with the expression as written.

### A field injected by name

```java
@Resource(name = "EgovCmmUseService")
private EgovCmmUseService cmmUseService;
```

The declared type is the interface, so the dispatch rule alone reaches every
implementor. The annotation says which object the container really puts there,
and when exactly one class in this pack answers to that bean name the call site
goes straight to it: `evidence.rule` `spring-bean-name`, `candidateCount: 1`,
the bean name and the interface on the evidence.

A class answers to a bean name in one of two ways, and the evidence says which
(`nameFrom`):

- `declared` — its own stereotype annotation names it: `@Service("x")`,
  `@Repository("x")`, `@Component("x")`;
- `default-name` — it carries a stereotype with no value, and its decapitalised
  simple name is that name, which is the name Spring gives it.

**The grade does not move.** It stays SOUND_SET, because this reads the name a
field asks for and the names classes declare, without the container that would
settle a `@Primary`, a profile, or a bean an XML or a `@Bean` method declares.
Narrowing a candidate set to one member does not make it a proof.

Two things the rule declines, both counted in `laneStats.beanNames`:

- a name **no class in this pack answers to** (`unknownName`), which is usually
  a bean an XML declares. Measured on
  `eGovFramework/egovframe-enterprise-business-template`: 87 of 145 named fields
  match a `@Service("…")` outright and 16 more by the default name, and every
  one of the rest is typed by something this run never parsed (`typeNotRead`),
  which is a different answer from "not an interface" and is counted as one;
- an interface method that **is a transaction boundary**
  (`transactionBoundary`). Some projects write `@Transactional` on the service
  INTERFACE, where there is no body; the engine marks that member and computes
  the transaction's footprint by walking forward from it. Routing the caller past
  it would leave the boundary on the graph with nothing under it.

### A mapping annotation is not always a handler

`@GetMapping("/sys/api/getUserById")` on a method is not, by itself, evidence
that this project *serves* that route. The lane classifies it by the **enclosing
type** first:

| enclosing type | what the mapping means |
|---|---|
| a concrete class annotated `@RestController` / `@Controller` | it **serves** the route — HANDLES, EXACT |
| an interface annotated `@FeignClient` (or a class-level `@HttpExchange`) | it **calls** the route over HTTP — CALLS_HTTP, never HANDLES |
| a plain interface or abstract class | it **declares** the route for an implementer to serve — a route *contract* |
| anything else (a concrete class with no controller annotation) | unchanged: it serves the route, so a controller marked by a meta-annotation this engine does not know still gets its routes |

Why this matters, measured on jeecgboot/JeecgBoot: it ships a `local` and a
`cloud` flavour of the same API, and the cloud flavour is `@FeignClient`
interfaces whose methods carry the *same* mapping annotations as the controllers
that answer them. Read as declarations, **107 routes came back with two
handlers** — one of which was the caller. Classified, every route has exactly
one handler, and the 116 mapped methods across the 6 `@FeignClient` interfaces
become 116 CALLS_HTTP edges: 107 to a route this pack also serves, 9 to a route
it does not.

### One mapping, several routes

A mapping annotation can name more than one HTTP method or more than one path:
`method = {RequestMethod.PUT, RequestMethod.POST}`,
`@GetMapping({"/list-all-simple", "simple-list"})`, or a list of paths on the
class's own `@RequestMapping`. Spring serves the handler at every one of them,
so the Java worker records one route for each class path, method and path the
annotations name, and two spellings of one route are one. Each is a route the
source states, so it is classified as above and its `HANDLES` edge is EXACT
like any other mapping's. The worker used to read only the first entry of each
list, which left routes Spring serves off the graph and a frontend call to one
of them with nothing to land on.

A `method` on the class's own `@RequestMapping` joins the method's: the handler
answers the verbs of both. A `method` element that is not an HTTP method (a
constant this lane does not read) leaves the verb unknown, so the route is
placed on `ANY` and its `HANDLES` edge is HEURISTIC, with what was written in
`evidence.methodUnread` (rule `route-method-not-read`). The verbs it does name
are routes of their own, as before.

### A path written with a constant, and a mapping annotation the project writes

Two ways of writing a mapping leave something one file cannot settle, and the
lane reads both against the whole tree (`src/adapters/java/mapping_reads.mjs`,
`javafacts/22`):

- **A path written with a constant.** `@PostMapping(RpcConstants.RPC_API_PREFIX
  + "/iot/device/auth")` names a constant of another type. The worker reads a
  literal and a `static final String` of the same class; a constant of another
  class it hands on in parts, and the bridge reads it from that type's own
  record, or a type above it, when the tree gives it one value. The route is
  then EXACT, rule `route-path-constant`, with what was written in
  `evidence.written`. A path that stays unread is no route: nothing is placed
  at an address the source does not state. It is said instead
  (`path-not-literal`). ruoyi-vue-pro's five `IoTDeviceApiImpl` methods moved
  from `POST /` to `POST /rpc-api/iot/...` this way.
- **A mapping annotation the project declares.** eladmin writes
  `@AnonymousPostMapping("/login")`, an annotation of its own that carries
  `@RequestMapping(method = POST)`. A controller method with an annotation the
  worker does not know, whose type the tree declares with a meta
  `@RequestMapping` or one of its shortcuts, serves a route: the meta
  annotation's methods, and the path the annotation's own `value` or `path`
  gives when `@AliasFor` points it at the meta mapping's (else the meta
  mapping's own path). Its `HANDLES` edge is EXACT, rule
  `route-composed-mapping`, with the annotation in `evidence.annotation`. An
  attribute the annotation does not alias is not what Spring reads, so it is
  said (`composed-mapping-attribute-not-aliased`), and an annotation this run
  did not read on a method with no mapping is said too
  (`mapping-annotation-not-read`). eladmin gains `POST /auth/login` and five
  more routes.

**One route, handlers a condition splits.** Two or more methods can declare one
route when one of them narrows it with `params`, `headers`, `consumes` or
`produces`. Spring runs the one whose condition the request meets, which is a
choice made per request. So each of their `HANDLES` edges is SOUND_SET, with the
conditions and the number of candidates on the evidence (rule
`route-condition-split`). Two declarations with no condition keep the grade
they had: two modules that each serve a route are two deployables, not one
choice.

`meta.laneStats.mappingAnnotations` counts all of this (`pathsFromConstants`,
`pathsUnread`, `composedRoutes`, `composedNotRead`, `methodsUnread`,
`conditionSplits`) with up to 20 samples, and `cascade analyze` prints one line
and the first five samples as `JAVA_MAPPING_NOT_READ`:

```
Java lane: mapping annotations: <n> path(s) read through a constant, <n> route(s) from a composed mapping annotation, ...
  [warn] JAVA_MAPPING_NOT_READ <handler>:<line> path-not-literal: <what was written>
```

The line is printed only when one of the counts is not zero.

Measured: jeecg-boot serves 47 more routes (40 handlers mapped with
`method = {PUT, POST}` now answer POST too, and `OpenApiController#call` all
eight methods), and 29 of its frontend calls now reach a route, `POST
/sys/user/edit` among them. ruoyi-vue-pro serves 16 more: 14 `simple-list`
paths, and the catch-all for its disabled trade and promotion modules, which
151 frontend calls now reach, as calls to its product module already did.
ruoyi-vue serves one more. mall, jpetstore-6, halo, spring-petclinic, litemall,
eladmin and spring-petclinic-microservices give the pack they gave before.

### A prefix set in configuration code (`pathPrefixes`)

Spring can serve controllers under a prefix that no mapping writes:
`RequestMappingHandlerMapping.setPathPrefixes(...)`, a map from a prefix to a
predicate over the controller class, or `PathMatchConfigurer.addPathPrefix(prefix,
predicate)` in a `WebMvcConfigurer` or a `WebFluxConfigurer`. The predicate is a
lambda and the prefix is usually a property. This engine does not read either (a
prefix written as a constant is readable in principle, and is not read either),
so such a controller's routes were recorded without their prefix, and a
frontend call to the real address named nothing. The profile says what the
prefixes are:

```json
{ "pathPrefixes": [
  { "prefix": "/admin-api", "packages": "**.controller.admin.**", "annotation": "RestController" },
  { "prefix": "/app-api",   "packages": "**.controller.app.**",   "annotation": "RestController" } ] }
```

- `prefix` is the prefix as Spring puts it before a route, with any property it
  names already resolved. A `${...}` or a space in it is refused.
- `packages` is an Ant pattern over the controller class's package, with `.`
  between segments, read as Spring's `AntPathMatcher(".")` reads it: `*` within
  one segment, `?` one character, `**` any number of whole segments, none
  included. A project that hands such a pattern to Spring copies it across as
  it is.
- `annotation` is the simple name of an annotation the class itself carries.
  One it inherits, or a meta-annotation, is not read.
- `from` is a note of where the value was read, and rides on the endpoint.

Either test may be left out, and an entry with neither applies to every
controller. Any other key is refused, because a misspelt test would put the
prefix on every controller.

The lane tests the class that serves a route, and the first entry the class
passes wins, as it does in Spring. The prefix goes before the path before the
endpoint is keyed, so the route is the address a frontend calls. A route
contract is served under its implementer's prefix, because that is the class
Spring tests. A contract nobody implements, a `@FeignClient` route (the
address it calls, written at the call) and a functional route (Spring applies
these prefixes to the annotation handler mapping, not to a router function)
take none. A prefixed endpoint carries `pathPrefix` (the value, the declaration
`pathPrefixes[i]`, its tests and its `from`) and `apiGroup`, the first path
segment after the prefix, so the views that group routes read the group below
the prefix instead of putting every route in one. Its HANDLES edge names the
rule `route-path-prefix` and keeps the grade of the mapping.

`cascade analyze` says what came of it, and three warnings say what may be
wrong:

```
Java lane: 2 declared path prefix(es) (pathPrefixes): /admin-api on 3101 route(s), /app-api on 153 route(s)
```

- `SETTING_IN_CODE`: the Java facts show a call to Spring's `setPathPrefixes`
  or `addPathPrefix` on a receiver declared as that Spring type, or as a type of
  the tree that extends or implements it however far up, and `pathPrefixes` is
  empty. It names the file, the line and this key. The type name is read the
  way javac reads it in that file: a type the file declares, a single-type
  import and a type of the file's own package all come before a package
  imported whole. A call on `this`, on a field a superclass declares, and on a
  `var` whose `new` names the type is read the same way. A call whose
  receiver's type the file does not state, in a file where that type's name
  means Spring's, is said at severity `info`, since nothing proves it is
  Spring's; a receiver declared as another type is not the setting. The calls
  are named by the `spring-mvc` rule pack, through the
  `java.code-setting` kind ([rules.md](../rules.md#a-setting-made-in-code)).
- `PATH_PREFIX_UNUSED`: an entry no controller class passed. A typo, or a
  package that moved.
- `PREFIX_NOT_ON_CALLS`: frontend calls that named no route would name a served
  one with a declared prefix before them, on a method that route serves. This
  happens when the frontend's base URL carries the prefix and the web lane did
  not read or apply it, and the warning names the `gatewayRoutes` entry that
  settles it (`{"*": "/admin-api"}`). It counts exact paths only, so the number
  is a floor, and it says apart how many of those calls were traced to no
  client.

While `SETTING_IN_CODE` stands, a frontend call that only `/**` catch-all routes
match may be meant for a concrete route behind the prefix nobody declared.
Where dropping the call's leading segments finds one, the web lane makes that
route a HEURISTIC candidate too, and the catch-all edge HEURISTIC
(`catchAll`, `prefixShift`; see
[the web lane](web-lane.md#matching-a-call-to-a-route)). On ruoyi-vue-pro, 2,388
of the 2,400 calls onto `DefaultController`'s catch-alls moved from SOUND_SET
to HEURISTIC and gained 2,390 candidates on the concrete controllers.
Declaring the key settles them.

The key needs `spring-mvc` in `frameworkPacks` (or a `--java-src` run);
otherwise `RECORDED_NOT_ACTED` says an unflagged run reads no controller to put
the prefixes on. It is left
out of the profile digest until it is set, so a project that declares none
keeps its digest, and `cascade init` run again keeps a declared list. The
working-tree overlay applies the same prefixes.

Measured on ruoyi-vue-pro, which serves its admin and app controllers under
`/admin-api` and `/app-api` from `setPathPrefixes`: with nothing declared the
pack is unchanged and `SETTING_IN_CODE` names `YudaoWebAutoConfiguration.java:53`.
Declared, 3,101 routes go under `/admin-api` and 153 under `/app-api`,
endpoints rise from 3,213 to 3,270 (before, an admin route and an app route
with the same path were one node with two handlers), and endpoints reaching SQL
from 2,626 to 2,663.

### Routes built with calls: functional endpoints

A method declared to return a `RouterFunction` builds its routes with calls,
not annotations:

```java
@Bean RouterFunction<ServerResponse> routes(OwnerHandler handler) {
  return route().nest(path("/owners"), b -> b.GET("/{id}", handler::show)).build();
}
```

The worker records such a method's body (and that of a method returning a
`Supplier` of one) as a tree of the calls it is written with, and decides
nothing. The `spring-functional` rule pack says which call starts a builder,
adds a route, nests, joins or keeps routes, and which argument is the path, the
predicate, the handler or springdoc's operation, for Spring's own builders on
WebFlux and WebMvc and for springdoc's `SpringdocRouteBuilder`
([rules.md](../rules.md#routes-built-with-calls)). A builder a project adds is
a pack entry.

- **Where a route is served.** A `@Bean` method's routes are served at the
  paths their calls compose. A route-building method of the same class that the
  bean calls with no argument is read there, under the bean's prefix. Any other
  one is mounted by code elsewhere, which decides its prefix, so its route is
  placed only where an OpenAPI document declares the operation id the route
  names, with its verb, at a path that ends with the route's own. The route
  then has the document's full path, its HANDLES edge is HEURISTIC (no mount
  the source states puts it there, only a document that may be stale), and the
  evidence names the document. When the profile says the build writes that
  document from this code as it is now (`openapi.generatedFromCode`, springdoc
  does this), the document says where the code serves the route, and the link
  is graded as its handler is read below; the evidence names the declaration
  (`declared: {key, document}`). Two routes of the code that name one operation
  are placed by neither. The operation id is the one the builder was given
  last. A later statement that may set it where this reader does not follow
  (under an `if`, in a loop, inside another call's argument) leaves it not
  known, and such a route, which only its id could place, is not placed
  (`unmounted` in `laneStats.functionalRoutes`).
- **One node per route.** A route gets the endpoint id every lane uses, so a
  route a document declares is corroborated, not duplicated, and a frontend
  call lands on it as on a mapped one. The operation id rides on the endpoint.
- **Which method runs.** HANDLES is EXACT where the source names the class and
  the method and nothing in the tree overrides it (`this::list`, or
  `OwnerHandler::list` of a type the tree declares with that method). Through
  a declared type (a field, a parameter, a local, or `this` with an override
  below it) it is SOUND_SET, over every method an object of that type may run:
  the type's own or inherited one, every override below it, an interface's
  default body where a class and the classes above it declare none (the default
  it inherits runs, not a method the class never declares), the interface's own
  default, which an implementor outside the tree may keep, and every anonymous
  class the tree writes from the type (`new Handler() { ... }`), and every class
  a method body declares from it, that overrides the method. The worker records
  which interface methods have a body, one record per anonymous class and one
  per class a method declares (`javafacts/22`). A lambda that calls one method
  names that method.
- **A set the tree cannot close is a guess.** The set is SOUND_SET only where
  the tree shows every object the type may hold. Where it cannot, the link is
  HEURISTIC and `evidence.openSet` names the gap: the type is an interface with
  one abstract method, so any lambda or method reference anywhere may be the
  object (`functional-interface`); the type was not read (`type-not-read`); or a
  class of the set inherits the method from a superclass this run did not read
  (`superclass-not-read`).
- **What is not read is said.** A handler this lane cannot name (a lambda that
  does more than call one method, a handler held in a variable) leaves the
  route as an endpoint node with `handlerUnread: true` and no HANDLES edge. A
  path in a variable, a predicate or a call the pack does not name, and a
  helper handed arguments are counted in `laneStats.functionalRoutes`, and the
  first few are printed as `JAVA_ROUTE_NOT_READ`. An operation id the code
  names that a document gives to another route is counted in
  `operationIdDisagreements` and printed as `OPERATION_ID_DISAGREES`.
- **No declared prefix.** `pathPrefixes` is not put on these routes; see above.

`--no-openapi` turns off the placement by operation id with the rest of the
OpenAPI lane. The working-tree overlay reads the same documents, so it places
the same routes.

Measured on halo, which builds its custom API this way: none of the 400 routes
its OpenAPI documents declare had a handler before; now 167 do, among 176
routes with a handler. 12 of those links are EXACT and 164 are HEURISTIC, every
one of them placed by operation id, so the default `conservative` mode walks 12
routes to a handler and `heuristic` walks all 176. None reaches a SQL
statement, because halo stores its data through its own extension store.
halo's documents are what springdoc writes from its code. Declared as such
(`openapi.generatedFromCode`, five documents), 168 of the links are EXACT and 8
SOUND_SET, so every route with a handler is walked at `conservative`. Without
the key, `cascade analyze` names the routes that rest on an undeclared document
(`ROUTE_MOUNT_FROM_DOCUMENT`), and the overview's fix for them is this key. The gateways of jeecg-boot and
spring-petclinic-microservices each serve one route this way, `GET /`,
from an inline lambda, so each is a node with `handlerUnread` and no HANDLES
edge.

### A controller that implements a generated interface

A contract-first project builds its API from an OpenAPI document with
openapi-generator, which writes one interface per group of operations
(`OwnersApi`) at build time. The project's `@RestController` implements it.
The interface is not in the source tree, so this lane sees a controller whose
methods carry no mapping, and serves nothing from it.

The `openapi-generator` rule pack pairs them after the document's routes are on
the graph: a method of a served class whose name is an operation's operationId,
where the class implements an interface outside the tree named as the generator
names that operation's group, handles that operation's route. The HANDLES edge
is HEURISTIC, because the pairing rests on a naming convention and neither the
interface nor the generator's configuration is read. The rule, what it links
and what it says instead are in [rules.md](../rules.md#a-contract-only-the-build-writes).

Measured on spring-petclinic-rest: 36 of the 37 routes its document declares
are linked (the 37th, `/oops`, has no implementer in the source), each on a
method the source marks `@Override`. Endpoints reaching SQL go from 0 of 38 to
13 of 38 at `mode=heuristic`, and stay at 0 at `conservative`, as a HEURISTIC
link should.

**Saying the build generates the interfaces.** What the tree cannot show is
that the build really generates this code's interfaces from that document. The
profile can say it: `openapi.generatesCode` lists the documents the build
generates from. A link on one of them is EXACT, the method its class declares,
when the interface's name does not depend on a setting of the generator this
engine does not read: openapi-generator groups operations by tag or by path,
and the link holds only if every scheme that gives some operation the
interface's name puts this operation into it. Each link records both
(`namedBy`, `nameFrom`); a link that depends on the scheme stays HEURISTIC and
its evidence says which grouping it depends on (`naming`). The evidence names
the declaration (`declared: {key, document}`). On spring-petclinic-rest,
declaring its document makes all 37 `HANDLES` edges EXACT, and 13 routes reach
SQL at `conservative`. Without the key, `cascade analyze` names the links that
rest on an undeclared document (`CONTRACT_FROM_DOCUMENT`) and the overview's
`contract-links` gap says to declare it.

### An imperative client call is an HTTP hop too

Most service-to-service traffic is not annotated at all. It is a fluent chain or
a `RestTemplate` request, where the verb is a method name and the url is an
argument somebody built:

```java
webClient.get().uri("http://customers-service.invalid/owners/{ownerId}", ownerId).retrieve()
restClient.post().uri(baseUri + "/owners/" + id + "/pets").retrieve()
restTemplate.exchange(url, HttpMethod.POST, body, Result.class)
```

The lane reads all three, and reads the url only as far as one file allows: a
literal, a literal with `{…}` placeholders, or a `+` whose literal halves are
kept and whose base is **named** rather than resolved. A `scheme://host` is
stripped off the front and the host is kept as evidence, because it is usually
a logical service name a discovery server resolves, not a machine. The path is then
matched against the routes this pack serves — exactly, then by template, so a
call that interpolates an id lands on the route whose `{ownerId}` stands where
the value went — and the profile's `gatewayRoutes` rewrites the prefix first, the
same declaration the web lane applies to a frontend call.

A url the lane could **not** reduce to a path (a bare variable, a fully computed
string) draws **no edge at all** and is counted (`httpCallsUrlUnreadable`). A
route nobody wrote is not put in the graph to stand in for one. A call whose
verb was an argument the lane could not read is matched on the path alone and
graded HEURISTIC, below the conservative floor.

Measured on spring-petclinic-microservices, five services in one repository with
no `@FeignClient` anywhere: **0 CALLS_HTTP edges before this rule, 6 after** —
the gateway to `GET /owners/{ownerId}` and `GET /pets/visits`, the genai service
to `GET /vets`, `GET /owners`, `POST /owners` and `POST /owners/{ownerId}/pets`,
every one of them SOUND_SET. The gateway's own route then reaches the customers
and visits tables through the hop instead of stopping at the service boundary.

Which deployable actually answers a client call is **not knowable from source**,
so it is never claimed. The annotation's service name and url ride as evidence —
with `evidence.serviceLiteral` saying whether the source spelled a string or
named a constant (`ServiceNameConstants.SERVICE_SYSTEM`, which a parse-only
worker cannot resolve) — and the grade says only what was checked: SOUND_SET
when a route with that method+path is in the pack, UNRESOLVED when none is.

A route contract nobody in the pack implements is still emitted, with
`contractOnly: true` on the endpoint node, so a census can say "N routes are
declared by an interface nobody implements here" instead of dropping them.

### The internal HTTP hop

A CALLS_HTTP edge to a route this pack *serves* is a real step of a real
request: `clientMethod --CALLS_HTTP--> route --HANDLES--> handler`. Both walks
cross it — `flow` in either direction, `map`, `coupling`, `overview` reach,
`endpoint_impact` and `changed_impact` — and every row reached that way is
marked `viaHttp: true` with `httpHops`, because the code on the far side belongs
to another deployable and must not read as one call stack. The hop costs two
real steps of depth; nothing teleports.

A route the pack only *calls* is marked `outbound: true` on the node. It is not
one of the pack's endpoints: the per-endpoint census skips it (it would be an
endpoint reaching nothing, which is somebody else's endpoint), `overview`
reports it as `reach.outboundEndpoints` and names it in `gaps`
(`http-calls-leaving-pack`), and the two numbers always add up to the endpoint
node count.

### The page a handler renders

A `@Controller` that is not a `@RestController` and carries no class-level
`@ResponseBody` answers a request by naming a **view**, and a template engine
turns that name into the page the browser gets. The worker records one `view`
record per handler:

```json
{ "kind": "view", "owner": "com.example.OwnerController", "method": "initFindForm",
  "paramCount": 1, "unresolved": 0,
  "views": [{ "name": "owners/findOwners", "kind": "view", "from": "literal" }] }
```

Four places a name is read from, and one honest gap:

| `from` | what it read |
|---|---|
| `literal` | a returned string literal, and every literal leaf of a returned ternary |
| `model-and-view` | `new ModelAndView("x", …)` |
| `set-view-name` | `mav.setViewName("x")` |
| `constant` | a `static final String` field of the **same class**, initialised with a literal |
| `helper` | a **private or package-private method of the same class** whose every return is such a literal or such a field. The method's name is on the record |

A `redirect:` or `forward:` prefix becomes the record's `kind` rather than part
of the name, because what it names is a ROUTE and not a page.

The last two rows are what makes this useful on real controllers, and neither
resolves anything across a file. `private static final String
VIEWS_OWNER_CREATE_OR_UPDATE_FORM = "owners/createOrUpdateOwnerForm";` has its
value on the line that declares it, and `return addPaginationModel(page, …)`
calls a method of the same class whose returns are right there. Measured on
jpetstore-6, where every controller uses the constant idiom: 10 view names
before this rule, 16 after, and zero unread returns. Two guards keep them
honest:

- the helper is read **one level deep**. A return inside it that is itself a
  call is not followed, and the whole helper is then unreadable rather than
  half-read;
- **two methods of one name** are read as neither. Which overload a call reaches
  depends on the argument types, and this worker does not resolve types.

A helper with several literal returns gives several views, exactly as a ternary
does. Anything else a returned name could be — a field of a superclass, a method
somebody overrides, a constant from another file, a local variable — is **not**
resolved. That is a data-flow or a cross-file question this parse-only worker
does not answer, so it is counted in `unresolved` and the run says how many
pages it could not name. The one exception is the ModelAndView idiom:
`ModelAndView mav = new ModelAndView("x"); … return mav;` names the page on the
line that builds the object, so the bare `return mav` is not counted as a gap.

A method carrying `@ResponseBody`, and every method of a `@RestController`,
answers with a body and has no view at all: no record, not an empty one.

Which FILE `owners/findOwners` means is not the worker's business. That depends
on the view resolver's prefix and suffix, which are the project's configuration;
`src/core/discover.mjs` reads them and `src/adapters/web_bridge.mjs` joins the
two into a screen. See [the web lane](web-lane.md) for the page end of it.

### Known limits (measured on macrozheng/mall)

- A method call whose receiver is a **local variable or parameter** (not an
  instance field) is not resolved — the worker counts these
  (`skippedLocalReceivers` on its own header, 4786 on litemall) and emits
  nothing rather than guessing. **A chain that passes through one of them is not
  in this graph.** The number is a per-run tally on the worker's header, which
  the per-file fact shards do not carry, so the pack cannot report it yet.
- A call to a type the source tree never contains (a third-party library) whose
  **name the file imports** is an edge that leaves the project, counted under
  `externalCalls`; the walks stop there, which is where the chain really stops.
  A call whose target this lane could not name at all is **unresolved**
  (`unresolvedCalls`, split by `unresolvedCallsByReason`) — no dependency
  classpath is consulted either way.
- An unqualified call to an **inherited** method is attributed to the enclosing
  type, not to the class that declares it — a name-resolution over-approximation,
  which is why the edge is SOUND_SET and its evidence says so.
- Reflection, AOP proxies, and runtime wiring are out of scope (that is the
  `RUNTIME_ONLY` axis, not built).

### The key a service asks a generator for

An eGovFrame service does not number its own rows. It asks a bean:

```java
@Resource(name = "egovIdGnrService")
private EgovIdGnrService egovIdGnrService;
...
String id = egovIdGnrService.getNextStringId();
```

The bean is an `EgovTableIdGnrServiceImpl` declared in a Spring XML, and it hands
the key out by reading and advancing a row of its own table, so the service reads
and writes that table and no mapper says so. A real run of the eGovFrame web
sample recorded it: `POST /addSample.do` touched `SAMPLE` and `IDS`, and the pack
knew only `SAMPLE`.

So the lane reads the bean (its `name` or `id`, and the `table`, `tableName`,
`tableNameFieldName` and `nextIdFieldName` properties, with the class's own
defaults `ids`, `id`, `table_name` and `next_id` where the bean sets none, read off
the class file of `egovframe-rte-fdl-idgnr` 5.0.1), writes the two statements the
class runs through the SQL lane like any other statement
(`SELECT next_id FROM IDS WHERE table_name = ? FOR UPDATE` and
`UPDATE IDS SET next_id = ? WHERE table_name = ?`), and binds a call on a field
injected by that bean's name, to one of the `getNext…Id` methods, to a symbol of
that bean, rule `egov-id-generator`, SOUND_SET. Each bean has its own symbol: the
interface method every generator shares would otherwise hand every generator's
table to every caller. A sequence or UUID generator touches no table and is not
read. Measured: the common components declare 115 generators and bind 116 of
the 119 calls that ask one by an injected field, and the business template
declares 111 and binds all 9, every one onto `COMTECOPSEQ` or `IDS`.

## JPA / Spring Data

When the Java lane runs and the profile declares the `jpa` framework pack (which
`cascade init` does for you the moment it counts an `@Entity` file), a second
bridge maps persistence that is **declared in the mapping** rather than written
as SQL.

### What it resolves

These naming grades remain subject to the uncertainties described below,
including version-dependent physical spellings and unread mapping annotations.

| From | To | Grade |
|---|---|---|
| `@Entity` + `@Table(name="owners")` | the written logical name, passed through the physical naming strategy | **EXACT** when that strategy is declared or all supported strategies agree on the spelling; otherwise **HEURISTIC** |
| `@Entity` with no `@Table` | the table the naming strategy gives, from the entity's name: the class's, or the one `@Entity(name = …)` gives it | **EXACT** with a usable physical naming declaration and no unmodelled implicit strategy; otherwise **HEURISTIC** |
| `@Column(name="visit_date")` | the written logical name, passed through the physical naming strategy | **EXACT** when that strategy is declared or all supported strategies agree on the spelling; otherwise **HEURISTIC** |
| a field with no `@Column` | the column the naming strategy gives | **EXACT** with a usable physical naming declaration and no unmodelled implicit strategy; otherwise **HEURISTIC** |
| `@Id` | the primary-key column | the grade of its column name |
| no `@Id` the tree declares (it may sit in a superclass outside the tree), or an `@EmbeddedId` whose type was not read | the assumed default key `id`, by the naming strategy | **HEURISTIC**, and so is every foreign key, join table column, subclass key and `JOINS` edge named after it. Said once as `primary-key-assumed`, and on a by-id lookup that reads it |
| `@EmbeddedId` | the embeddable's columns are the key columns, including nested embeddables | as a column |
| `@Embedded` | the embeddable's columns, including nested values and dotted attribute overrides on the embedding attribute | as a column |
| `@JoinColumns` and `@MapsId` | composite foreign keys and shared primary keys; explicit referenced-column names pair the columns | pairing only by position stays **HEURISTIC** and is reported |
| `@ManyToOne`/`@OneToOne` + `@JoinColumn(name=…)` | that foreign-key column, plus a `JOINS` edge | weakest of the two entity mappings and of the target's key |
| `@ManyToOne`/`@OneToOne` with no `@JoinColumn` | one foreign-key column per key column of the target, named `<attribute>_<key column>`, so a composite key gives several | as above |
| `@OneToMany(@JoinColumn)` | the foreign key on the **target** table, plus a `JOINS` edge | as above |
| `@ManyToMany`, and a `@OneToMany` with neither `@JoinColumn` nor `mappedBy` | a join table with one foreign-key column per key column on each side, plus `JOINS` edges. `@JoinTable` supplies logical names; defaults use the physical and implicit rules below | an unnamed table is **HEURISTIC** where the supported implicit strategies disagree and neither the profile nor usable configuration states one; other name and key uncertainties still apply |
| an inverse `@OneToMany`/`@ManyToMany(mappedBy = …)` | no column and no join table of its own: the owning side maps them | **EXACT** |
| `@MappedSuperclass` | its attributes are inherited by every subclass entity | as above |
| a derived query (`findByLastNameStartingWith`) | `select` reading the predicate and ordering columns | the statement's table by the table's name, each column by its own |
| `@Query` JPQL | the columns its aliases and paths name; `SET` targets are writes | as above |
| `@Query(nativeQuery = true)` | the SQL goes through the **SQL lane's own analyzer** (`lineage.py`), like a MyBatis statement — its JPA bind markers (`?1`, `:name`) are normalized to `?` first, exactly as MyBatis's `#{}` are | EXACT (a real SQL parse) |
| a called-but-undeclared `CrudRepository` method (`save`, `findById`, …) | the rows that method touches | as above |

`save*` writes **every** mapped column of the row (a JPA merge writes the whole
entity) and follows `cascade = ALL/PERSIST/MERGE` associations to the child
rows. `delete*` removes the row and follows `cascade = ALL/REMOVE` the same way.
A cascaded reach is capped at **SOUND_SET**: the cascade is declared in the
source, but whether a given call has a child to write or remove is a runtime
fact. `orphanRemoval` is not read.

A to-many's other side is its element type, the last type argument: a
`List<Pet>` and a `Map<PetType, Pet>` both hold pets. `targetEntity` wins over
either. `@Entity(name = "Hound")` renames the entity: its default table is
derived from that name, and a JPQL `FROM Hound` finds it. Re-measured with
these rules and the hierarchies below, nine JPA packs keep their digest (petclinic-rest,
polls-app, jhipster-sample-app, eladmin, spring-petclinic,
spring-petclinic-microservices, egovframe-msa-edu, mes4u, ngrinder), and
shopizer's four join table columns take JPA's default names
(`MERCHANT_LANGUAGE.stores_merchant_id` and `languages_language_id` among them).

### An entity that extends another entity

An entity can extend another entity, through any `@MappedSuperclass` between
them. The root's `@Inheritance` says where the hierarchy's rows live, and the
lane reads it the way JPA applies it:

| The root's strategy | Where a subclass's rows and columns are |
|---|---|
| `SINGLE_TABLE`, or no `@Inheritance` at all (JPA's default) | the root's table. The subclass has no table of its own, and a `@Table` on it is ignored, as Hibernate ignores it. The root's table also holds the column that tells the rows apart: with no `@DiscriminatorColumn` it is JPA's `DTYPE` by the naming strategy (`dtype` under spring-snake-case), drawn **HEURISTIC** (`discriminator-assumed`), read by every query on a subclass and written by every save. A declared `@DiscriminatorColumn` is not read and not drawn (`jpa-column-not-drawn`) |
| `JOINED` | each class's own table holds the columns that class declares, and an inherited column stays on its parent's table. The subclass's table carries the key that joins it to its parent's (the column `@PrimaryKeyJoinColumn` names, else the parent key's name), with a `JOINS` edge between the two, and a statement on the subclass reads or writes every table its row spans |
| `TABLE_PER_CLASS` | the subclass's own table, with every column, inherited ones too |
| anything else | not read. The run says so (`inheritance-strategy-unread`), and the subclass's table is **HEURISTIC** |

A statement on an entity that has subclass entities may read or write one of
their rows, which carry columns (and under `JOINED` or `TABLE_PER_CLASS`,
tables) of their own. Those are not followed, and the statement says so
(`polymorphic-subclasses-not-read`).

### The fetch plan: what a query really reads

A repository method names one entity and reads several tables. Nothing in the
source says so, and the database does it anyway: an association whose fetch is
EAGER comes back in the same round trip, and so does everything eager on the rows
it brought with it. In spring-petclinic, `OwnerRepository.findById` names
`owners` and the running query reads four tables, because `Owner.pets` is
`fetch = EAGER`, `Pet.type` is a `@ManyToOne` (eager unless the mapping says
otherwise) and `Pet.visits` is eager too.

So every statement whose result is an entity carries that closure:

| Rule (`evidence.rule`) | What it followed |
|---|---|
| `jpa-eager-fetch` | an association whose effective fetch is EAGER. `evidence.fetch` says `explicit` when the mapping wrote `fetch = EAGER`, and `default` when it wrote nothing and the JPA specification decided (a to-one is eager, a to-many is lazy) |
| `jpql-join-fetch` | a `JOIN FETCH` / `LEFT JOIN FETCH` in the method's own JPQL. The query overrides the mapping, so this is followed whatever `fetch =` says |
| `jpa-entity-graph` | an `@EntityGraph` attribute path on the method, written out or named through the entity's `@NamedEntityGraph`. Same override, same reason |
| `jpa-cascade` | a `save` or a `delete` reaching a child row through `cascade` |

Every followed edge carries `evidence.path`, the attribute path from the entity
the query returns (`Owner.pets.visits`), so a table nobody expected can be traced
back to the field that brought it. The rule itself is EXACT — the annotation and
the specification decide it, nothing was resolved. A table the fetch or a
cascade reaches is graded by that rule (a cascade is SOUND_SET) and by the
table's own name. A foreign key whose name the naming strategy derived keeps
that doubt on the key column's own `READS` or `WRITES` edge, and does not make
the table it leads to a guess.

Three things it deliberately does not do:

- **A LAZY association stays out.** A lazy collection a page touches after the
  query has run is a real read, and it happens where this lane cannot see it: in
  the template, not in the query. Following it would put reads in the answer that
  many requests never make. Each statement that skipped one says so in
  `jpaEvidence.limits`, and `overview` counts them all under
  `jpa.lazyAssociationsNotFollowed`.
- **A `save` follows cascade and not fetch.** A merge writes a row; it is not a
  query whose result somebody reads.
- **The plan stops at eight associations deep**, cycle-safe, with
  `fetch-depth-capped` on the statement when it hit the cap. A path that names no
  association of the entity is reported as `fetch-path-unresolved`, and an
  `@EntityGraph` naming a plan no entity in the pack declares as
  `entity-graph-unresolved`. A nested `@NamedSubgraph` is not read.

### `@ModelAttribute`: the call Spring makes and no line of source writes

Spring runs a controller's `@ModelAttribute` methods before **every** handler of
that controller. Nothing calls them, so nothing led to them, and
`GET /owners/{ownerId}/edit` in spring-petclinic answered no table while a real
agent capture of that request read four: the owner is loaded by
`OwnerController#findOwner`, a `@ModelAttribute("owner")` method.

The lane now draws that edge: `MAY_CALL` from every handler of a
`@Controller`/`@RestController` class to every `@ModelAttribute` method the
**same class** declares, rule `spring-model-attribute`, graded **EXACT** —
nothing was resolved, the framework's own contract says the method runs.

What it does not follow, and counts instead (`laneStats.modelAttribute`):

- a `@ControllerAdvice`'s model attributes (`onAdvice`), because which
  controllers an advice runs for is not a fact about one class;
- model attributes a controller INHERITS from a base class (`onSuperclass`);
- handlers a controller inherits rather than declares (`inheritedHandlers`).

A `@ModelAttribute` on a PARAMETER (`save(@ModelAttribute Owner owner)`) is a
binding, not a method Spring runs, and is never read as one.

### The naming-strategy rule, and how to declare it

Hibernate turns `Owner`/`lastName` into a physical `owners`/`last_name` with a
naming strategy the application configures. This engine does not run your
application, but it reads where the application declares the strategy:
`spring.jpa.hibernate.naming.physical-strategy` (or
`spring.jpa.properties.hibernate.physical_naming_strategy`) in the
`application*.properties`/`application*.yml` beside each Java source root the run
reads (`src/main/resources`, and its `config/` directory), in any spelling Spring
binds, whether the roots were named by flags or discovered. Another module's
configuration is not read for this one. A
recognized class there is a declaration only when the setting reaches the
factory that builds this persistence unit (see below), and the run says so
(`JPA_NAMING_FROM_CONFIGURATION`). A class this engine does not model, or two
configuration files that name different strategies, declare nothing it can
apply, and the run says that too. So does a strategy that only a profile-specific
file (`application-dev.yml`) or a document activated by a profile names, or a
profile that names another strategy than the base configuration: which profile
runs is not in the tree. Under `spring.jpa.properties` the key counts only as
Hibernate spells it (`hibernate.physical_naming_strategy`), because Spring binds
that map verbatim.

The physical default depends on who builds the EntityManagerFactory:

| Factory found in the tree | Assumed physical / implicit naming |
|---|---|
| no project-owned factory | Spring Boot: `spring-snake-case` / `spring` |
| built from Boot's `EntityManagerFactoryBuilder` | Boot's defaults, with an explicit caveat that the builder carries them only from Boot 3.4; earlier versions carry only `spring.jpa.properties` |
| built by hand with `new`, Spring XML, or a factory `@Bean` without Boot's builder | Hibernate: `identity` / `jpa-compliant` |

The lane reports the factory's file and line (`JPA_NAMING_FROM_FACTORY`). It
reads factory types, naming setters and property maps through the `jpa` rule
pack. It does not execute configuration code or determine the Boot version.
A setting found in code is reported as `SETTING_IN_CODE`; an explicit profile
declaration settles the corresponding naming dimension.

For both physical and implicit settings, Boot's own
`spring.jpa.hibernate.naming.*-strategy` key reaches Boot's factory, reaches a
builder-made factory only from 3.4 (therefore HEURISTIC here), and does not reach
a hand-built factory. The exact `spring.jpa.properties.hibernate.*_naming_strategy`
key reaches Boot's and builder-made factories in every version, but not a
hand-built one. Ignored settings are reported. `cascade init` declares neither
strategy automatically.

When neither the profile nor usable configuration declares a physical strategy,
the factory's default is an assumption. Names the supported physical strategies
spell differently stay HEURISTIC. `endpoint_impact` in conservative mode leaves
those links out and explains why in `limits`. Matching a catalog name does not
promote them.

That is not a bug to work around; it is the engine refusing to present a guess as
a fact. Declare the rule in the profile (it wins over the configuration) and the
supported mappings can become EXACT, subject to other mapping uncertainties:

```json
{
  "frameworkPacks": ["spring-mvc", "jpa"],
  "jpa": { "namingStrategy": "spring-snake-case" }
}
```

- `"spring-snake-case"` — Spring Boot's default, `CamelCaseToUnderscoresNamingStrategy`.
  Exactly Hibernate's rule: a `.` becomes `_`, and an underscore goes before a
  capital only when a lower-case letter is on both sides of it, never before the
  last character, so `lastName` is `last_name` but `userID` is `userid` and
  `myURLValue` is `myurlvalue`. Hibernate 7 (Spring Boot 4) also counts a digit as
  lower-case, so the two versions spell `address2Line` differently (`address2line`,
  `address2_line`). This class exists in both, so a name like that is graded
  HEURISTIC even when this strategy is declared: the project's Hibernate version
  decides it.
- `"snake-case-hibernate6"` — the same rule, letters only, fixed: Spring Boot 2's
  `SpringPhysicalNamingStrategy`.
- `"snake-case-hibernate7"` — the rule that counts digits, fixed:
  `PhysicalNamingStrategySnakeCaseImpl`, which exists from Hibernate 7.0 on.
- `"identity"` — the logical name is the physical name (Hibernate's
  `PhysicalNamingStrategyStandardImpl`, what you get with
  `spring.jpa.hibernate.naming.physical-strategy` set to it).
- `null` (the default) — take the strategy the project's Spring configuration
  names if it reaches the factory; otherwise assume that factory's default and grade uncertain names HEURISTIC.

Names written in `@Table`, `@Column`, `@JoinColumn` and `@JoinTable` also pass
through the physical strategy. With snake case, `@Column(name="createdBy")`
becomes `created_by`; `identity` keeps `createdBy`. A written name is EXACT
without a declaration only when the supported physical strategies agree.

**Implicit naming is a separate declaration.** `jpa.namingStrategy` states the
physical rule only. `jpa.implicitNamingStrategy` accepts:

- `"spring"`: a default join table uses the owning table and association
  attribute (`owners_special_tags`).
- `"jpa-compliant"`: it uses the two tables (`owners_tags`).
- `null`: read usable configuration, otherwise assume the factory's default.
  Where the two supported rules disagree, the derived table and its links stay
  HEURISTIC (`JPA_IMPLICIT_NAMING_ASSUMED`). Where they agree, this uncertainty
  alone does not lower the grade.

The configuration reader recognizes `SpringImplicitNamingStrategy` (including
its Boot 4 package) and `ImplicitNamingStrategyJpaCompliantImpl` under
`spring.jpa.hibernate.naming.implicit-strategy` or the exact passthrough key
`spring.jpa.properties.hibernate.implicit_naming_strategy`, with the factory
reach rules above. Custom, legacy-HBM, legacy-JPA and component-path classes
are not modelled; their differences extend beyond a join-table formula, so
they are reported rather than accepted as aliases. When such a setting can
reach the factory, derived names stay HEURISTIC; written names retain their
physical-strategy grading. Verify the actual strategy
before declaring one of the supported values.

For example, after verifying both runtime rules:

```json
{ "jpa": { "namingStrategy": "identity", "implicitNamingStrategy": "jpa-compliant" } }
```

Declaring one dimension settles only that dimension. The new implicit key is
left out of the profile digest while null. Collection tables remain unread.

**Invariant I-1, in this lane:** if the derived table name happens to exist in
the DB catalog, that is recorded on the node as `jpaCatalogMatch: true` and
**nothing else**. A coincidence is evidence for a human, not a promotion.

### What it does NOT resolve

- An embedded type the tree cannot resolve remains a reported gap. Nested
  embeddables and attribute overrides on the embedding attribute are read;
  recursive or unsupported mappings are not silently treated as complete.
- An `@ElementCollection` keeps its values in a table of its own, which this
  lane does not read. The attribute is no column of its owner, no statement
  here reaches that table, and the run says so (`element-collection-not-read`).
- A mapping annotation that can name or place a column otherwise than the
  plain reading is not read, and its column is not trusted: an attribute with
  a formula (`@Formula`, `@JoinFormula` and their kin), an unsupported override
  or `@AssociationOverride`; and on the class, `@SecondaryTable` or an override, which
  covers every column of the entity. Such a column keeps its plain reading,
  graded **HEURISTIC**, and the run names the annotation it did not read
  (`jpa-mapping-unread`).
- An annotation that adds a column this lane does not draw is said, and the
  grades stay: `@OrderColumn`, `@IndexColumn`, `@MapKeyColumn`,
  `@MapKeyJoinColumn(s)`, `@CollectionId`, `@Any`, `@ManyToAny`, and a to-many
  `Map` with no map-key annotation (`jpa-column-not-drawn`).
- An annotation on an attribute that this lane does not know at all is said
  (`jpa-annotation-unknown`), and the attribute's column is **HEURISTIC**: what
  it does to the column is not known. The lane keeps a list of the annotations
  it knows change nothing it draws. A marker annotation declared in the tree
  can also be recognized as inert when its metadata is inert and no Hibernate
  boot extension could interpret it. Implemented extension points and
  `META-INF/services` registrations block this inference. Resource files are
  read during discovery; an explicit-lane run that did not read them reports
  `jpa-resources-unread`, and the working-tree overlay retains the recorded inputs.
- Hibernate annotations that may change what a statement reaches are said and
  not followed (`jpa-reach-unread`): `@Fetch`, `@Where`, `@SQLRestriction`,
  `@Cascade` and their kin.
- `@Convert` is not modelled.
- Hibernate's `@BatchSize` is not read: it changes how a fetch is issued, not
  whether it happens.
- A named query (`@Query(name = "…")`, `@NamedQuery`) is not read.
- `flush()` gets no statement of its own: it writes what the other statements in
  the transaction already made pending.
- A derived name or a JPQL fragment the reader cannot attribute to a column
  leaves the statement **in the graph** with `hasUnresolved: true` and the reason
  on the node — never dropped, never silently empty. So does a statement on a
  mapping this lane does not read: a query on an entity with subclasses
  (`polymorphic-subclasses-not-read`) and a by-id lookup on an assumed key
  (`primary-key-assumed`). `overview` counts them
  (`jpa.unresolvedStatements`) and `cascade analyze` prints them. A mapping's
  own reasons are on no node: they are in `meta.laneStats.jpa.unresolved` with
  `statement: null`, printed as `JPA_UNRESOLVED (mapping)`.

## MyBatis-Plus

MyBatis writes its SQL down. JPA writes nothing down but at least declares a
repository *method* per query. **MyBatis-Plus writes down less than either**:

```java
sysUserDepartMapper.selectList(
    new LambdaQueryWrapper<SysUserDepart>().eq(SysUserDepart::getDepId, id));
```

There is no SQL text and no method name to read. The table comes from a global
naming rule applied to the class `SysUserDepart`, the column from the same rule
applied to the JavaBeans property behind `getDepId`, and the verb from the fact
that `selectList` is a method of `BaseMapper<T>` this project never wrote.

Measured on jeecgboot/JeecgBoot, **735 of 969 routes reached no statement at
all** before this lane existed — not because their code touches no table, but
because everything it touches is spelled this way. With it, 717 of 969 do.

Turn it on with `frameworkPacks: ["mybatis-plus"]`; `cascade init` writes that
itself when discovery sees `extends BaseMapper<` or `@TableName` in the tree.
It is **independent of `mybatis-xml`** — a project can have both, and jeecg-boot
does (80 mapper XML files for 65 mappers, generic CRUD for everything else).

### What it resolves

| from the source | to | grade |
|---|---|---|
| `@TableName("sys_user_depart")` | the table | **EXACT** |
| `SysUser` with no `@TableName` | `sys_user` by the naming rule | **EXACT** if `mybatisPlus.namingStrategy` is declared, **HEURISTIC** if assumed |
| `@TableField("dep_id")` / `@TableId("id")` | the column | **EXACT** |
| a field with no `@TableField` | its column by the naming rule | as above |
| `@TableField(exist = false)`, `static`, `transient` | **no column** — MyBatis-Plus does not persist them | EXACT (definitional) |
| a `BaseMapper` / `IService` / `ServiceImpl` built-in a caller reached | one statement per (owner, method) | IMPLEMENTS_STMT **EXACT** — the method *is* the statement MyBatis-Plus generates |
| `X::getY` in a wrapper op | the column the property `y` maps to | the mapping's grade |
| `eq("status", …)` on a `QueryWrapper` | the column `status`, as written | **EXACT** (it is the physical name MyBatis-Plus passes through) |
| `@TableLogic` + any `delete*`/`remove*` | an **UPDATE that writes the flag column** | the mapping's grade |
| `@TableLogic` + any query | a **READ** of the flag column (`evidence.implicitFilter`) | the mapping's grade |

The entity is found through the generic bases, **transitively and with type
substitution** — no project base-class name is hardcoded. jeecg-boot puts its own
`JeecgServiceImpl<M extends BaseMapper<T>, T extends JeecgEntity> extends
ServiceImpl<M, T>` in between, and 24 services extend that; the lane substitutes
`T` down the chain the same way it would for anyone else's base class.

A class is an entity when the source says so in exactly two ways: it carries
`@TableName`, or a `BaseMapper<T>` / `IService<T>` / `ServiceImpl<M, T>` in the
pack names it as `T`. A class that merely carries `@TableField` on a couple of
fields is **not** one — jeecg-boot has six of those, every one a DTO shaped for
a result map, and mapping them would have invented six tables no schema has.

### The verbs, and what each does to the row

| built-in | access | columns |
|---|---|---|
| `insert`, `save`, `saveBatch` | write | every mapped column — MyBatis-Plus writes the **non-null** fields, and which those are is a run-time fact, so all of them are candidates |
| `updateById`, `updateBatchById` | write | every mapped **non-id** column, plus a read of the key |
| `update(entity, wrapper)` | write | the wrapper's `set(...)` columns when one is visible, else every mapped column |
| `saveOrUpdate*` | write | every mapped column, plus a read of the key |
| `deleteById`, `removeById`, `removeByIds`, `deleteBatchIds` | delete (or **write**, under `@TableLogic`) | a read of the key |
| `delete(wrapper)`, `remove(wrapper)` | delete (or **write**, under `@TableLogic`) | the wrapper's predicate columns |
| `selectById`, `getById`, `listByIds` | read | the key |
| `selectList`, `list`, `getOne`, `count`, `page`, `selectPage`, `exists`, … | read | the wrapper's predicate/order columns **and the projection** — every mapped column, or the `select(...)` list when the wrapper narrows it |

A method that runs no SQL is deliberately absent: `getBaseMapper`,
`lambdaQuery()`, `lambdaUpdate()` get no statement, because giving them one would
invent a row-touching fact.

If the owning type **declares the method itself**, the call runs that method and
not the built-in: no generic statement is created, and the case is counted
(`builtinsOverridden`) and named in the diagnostics.

### Condition wrappers, and the columns nobody wrote down

The worker records, per enclosing method, what kind of wrapper was built, for
which entity type, which ops were called on it with which method references and
literals, and where it ended up. It **decides nothing**: `eq` is recorded as the
op named `eq`, and that `eq` is an equality predicate on a column is the bridge's
reading.

Every op falls into one of five readings, and the table is **total** — an op
outside it is counted under `opsUninterpreted` and named in the analyze output,
so a lane that quietly ignored one cannot pass for a lane that saw none:

| reading | ops | what it means |
|---|---|---|
| `column` | `eq ne gt ge lt le in notIn like notLike likeLeft likeRight notLikeLeft notLikeRight between notBetween isNull isNotNull` | names ONE column: its first string-literal argument (after MyBatis-Plus's optional leading `boolean condition`), or the method references it carries |
| `columns` | `select groupBy orderByAsc orderByDesc orderBy` | names several — every top-level string literal |
| `write` | `set setIncrBy setDecrBy` | the columns it names are WRITTEN |
| `sql` | `apply last setSql exists notExists having inSql notInSql` | its string argument is a raw SQL **fragment**, not a column name — it is run through the SQL analyzer, see below |
| `structural` | `and or not nested lambda func clone getCustomSqlSegment getSqlSegment` | names no column of its own — but a method reference **inside** it still does, because `X::getY` is unambiguous wherever it appears |

**A wrapper this lane cannot see through is the interesting case.** jeecg-boot
builds 64 of its wrappers with

```java
QueryWrapper<T> queryWrapper = QueryGenerator.initQueryWrapper(object, request.getParameterMap());
```

whose conditions come from the HTTP query string. No source line names those
columns. The lane does **not** guess and does **not** go quiet: the table stays a
fact, the statement carries `columnsRuntimeOnly: true` with the reason, and
`column_impact` on **any** column of that table adds a limit saying "N statements
touch this table with columns decided at run time — the list above is a lower
bound", naming them. That is the grade table staying total: RUNTIME_ONLY is a
grade, not a silence.

**A raw SQL fragment goes to the SQL analyzer.** `apply / last / setSql / inSql
/ notInSql / exists / notExists / having` hand MyBatis-Plus a piece of SQL TEXT,
and SQL belongs to the SQL lane. The op says how the fragment is written into a
statement — `apply(cond)` is a `WHERE`, `setSql(x)` an `UPDATE … SET`,
`last("limit 10")` a tail — the `FROM` is the table the wrapper filters, and
MyBatis-Plus's own `{0}` bind placeholder becomes the `?` a parser accepts. The
result is an ordinary statement run through the same `lineage.py`, the same
catalog, dialect and identifier rule, and the same content-addressed shard cache
as a mapper statement or a native `@Query`, under the id
`<owner>.<method>#frag<n>`. What comes back is attached to the wrapper's
statement with `evidence.fragmentOp` naming the op, so

```java
w.apply("date_format(create_time,'%Y-%m') = {0}", ym);
```

makes the statement READ `create_time` even though no method reference and no
projection names it. Two things stay honest about it: a fragment that does not
parse keeps the old `unresolved` note **with its text**, and a fragment on a
wrapper with no entity of its own (a `QueryWrapper<T>` in a generic base class,
whose table is decided per call site) is not written out at all and says so —
nothing is invented in either case. jeecg-boot uses none of these ops, so the
fixture behind this path is the synthetic project in
`test/mp_fragments.test.mjs`, driven through the real CLI and both workers.

### What it does NOT resolve

- **A wrapper re-assigned to an existing variable** (`wrapper = new
  LambdaQueryWrapper<>();`) is not a new wrapper record — the scan reads
  declarations and chains. In every case measured the variable already had a
  record, so its ops still land; the *origin* is what is lost.
- A wrapper handed to a helper (`this.addEasyQuery(queryWrapper, …)`) is recorded
  with that sink and produces no statement there — the helper is not a built-in.
- `@Version`, `@TableField(fill = …)`, `@KeySequence`, MyBatis-Plus's multi-tenant
  and dynamic-table-name interceptors are recorded or ignored, never modelled.
- `mybatisPlus.logicNotDeleteValue` is recorded and not acted on: the lane records
  that the flag column is read as a filter, not which value it is compared with.

### The profile keys

These are the MyBatis-Plus keys. `jpa.namingStrategy` is above, `pathPrefixes`
in *A prefix set in configuration code*, the `openapi` keys in *Routes built
with calls* and *A controller that implements a generated interface*, and every
key a profile can hold, with an example of each, in
[concepts.md](../concepts.md#9-the-profile-key-by-key).

```json
{
  "frameworkPacks": ["spring-mvc", "mybatis-xml", "mybatis-plus"],
  "mybatisPlus": {
    "namingStrategy": "underscore",
    "tablePrefix": null,
    "logicDeleteValue": "1",
    "logicNotDeleteValue": "0"
  }
}
```

- `"underscore"` — MyBatis-Plus's own default (`table-underline: true`,
  `map-underscore-to-camel-case: true`), `SysUser` → `sys_user`.
- `"identity"` — the project that turned it off; the logical name is the physical one.
- `null` (the default) — undeclared, assume `underscore`, grade HEURISTIC, and
  the `mybatisPlus` axis is **degraded** rather than shipped. A column named by
  that assumption is then not reached at `conservative`, since a walk counts
  only the `READS` and `WRITES` edges the mode admits: ruoyi-vue-pro reaches 15
  columns there, where it counted 8,010, until this is declared, and a
  `mode=heuristic` census counts them all.

**Invariant I-1, in this lane:** if the derived table name happens to exist in
the DB catalog, that is recorded as `mpCatalogMatch: true` and **nothing else**.

Node ids go through the **same identity fold** the SQL lane used
(`sqlIdentifierCase`). jeecg-boot's `sys_user_depart` spells its key `ID` in the
DDL while the entity derives `id`; keyed by string that is two nodes for one
column and every answer about it is half an answer.

## Test targets

`macrozheng/mall` (Apache-2.0) is the MyBatis reference target — 383 files, 160
endpoints, 887 mapper-method↔statement bindings. See
[sql-lane.md](sql-lane.md) for cloning it.

`spring-projects/spring-petclinic` (Apache-2.0) is the JPA reference target.
`test/petclinic.test.mjs` clones it at a pinned commit and checks the mapping
against `src/main/resources/db/mysql/schema.sql`:

```bash
git clone https://github.com/spring-projects/spring-petclinic ../target-examples/spring-petclinic
node --test test/petclinic.test.mjs      # or set CASCADE_PETCLINIC=<path>
```
