Cascade

A code-to-column change-impact knowledge graph for AI coding agents.

View the Project on GitHub alexsoft-hq/Cascade

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

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:

# 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:

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)
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)
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)
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)
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)
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:

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:

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

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:

@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

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

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 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):

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:

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

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)

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). 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:

@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). A builder a project adds is a pack entry.

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

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:

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:

{ "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:

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 for the page end of it.

Known limits (measured on macrozheng/mall)

The key a service asks a generator for

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

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

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

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

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:

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:

{ "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

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:

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

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

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

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.

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

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

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