This commit is contained in:
Ingo Schnabel
2026-09-24 18:01:13 +02:00
104 changed files with 12300 additions and 648 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
# Build context for ac-code-server's Dockerfile.jvm is the repository root (item 192: the image also
# carries ac-parser-typescript/sidecar). Send only what the Dockerfile COPYs — never .git, target/
# trees, ac-ui/node_modules or the sidecar's own node_modules (npm ci installs those in the image).
*
!ac-code-server/target/quarkus-app/
!ac-parser-typescript/sidecar/package.json
!ac-parser-typescript/sidecar/package-lock.json
!ac-parser-typescript/sidecar/extract.mjs

View File

@@ -2,7 +2,8 @@
> AI-agent-optimized code analysis platform — parse, store, enrich, and query source code as a graph.
AgenticCode ingests source code (Natural/Software AG and Java), parses it into a unified AST, persists it in Neo4j,
AgenticCode ingests source code (Natural/Software AG, Java and TypeScript/React), parses it into a unified AST, persists
it in Neo4j,
enriches it with semantic information (call graphs, DB accesses, data structures, dynamic-dispatch resolution), and
exposes everything through an agent-ready **REST** API, a **CLI**, and a **web UI**.
@@ -172,15 +173,15 @@ JSON with `limit`/`offset` pagination where lists can be large.
### Projects & ingestion
| Endpoint | Description |
|----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
| `GET /api/projects` | List all projects |
| `POST /api/projects/{project}` | Create a project (body: `root`, `language`, optional `description`, `excludeDirs`, `generatedDir`, `userExitDir`) |
| `PUT /api/projects/{project}` | Update a project (unset fields left unchanged) |
| `DELETE /api/projects/{project}` | Delete a project and all its data |
| `POST /api/projects/{project}/refresh[?deep=true]` | (Re)scan the whole root — call-graph pass, or full field-level with `deep=true` |
| `POST /api/projects/{project}/refresh/{name}` | Deep-ingest one module + its dependency tree (`maxDepth`, `maxNodes`, `neighborhood`) |
| `POST /api/projects/{project}/recreate` | Drop and rebuild the project's graph from disk |
| Endpoint | Description |
|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
| `GET /api/projects` | List all projects |
| `POST /api/projects/{project}` | Create a project (body: `root`, `language`, optional `description`, `excludeDirs`, `generatedDir`, `userExitDir`, `counterparts`) |
| `PUT /api/projects/{project}` | Update a project (unset fields left unchanged) |
| `DELETE /api/projects/{project}` | Delete a project and all its data |
| `POST /api/projects/{project}/refresh[?deep=true]` | (Re)scan the whole root — call-graph pass, or full field-level with `deep=true` |
| `POST /api/projects/{project}/refresh/{name}` | Deep-ingest one module + its dependency tree (`maxDepth`, `maxNodes`, `neighborhood`) |
| `POST /api/projects/{project}/recreate` | Drop and rebuild the project's graph from disk |
### Call graph & structure
@@ -200,15 +201,18 @@ JSON with `limit`/`offset` pagination where lists can be large.
### Data, DB access & payload
| Endpoint | Description |
|--------------------------------------------------------------------------|--------------------------------------------|
| `GET .../modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
| `GET .../modules/{name}/sql-statements` | SQL/ADABAS statements |
| `GET .../modules/{name}/workfile-accesses` | Natural work-file reads/writes |
| `GET .../modules/{name}/data-structures` | Data structures used by the module |
| `GET .../modules/{name}/payload` | Parameter-data-area I/O contract |
| `GET .../modules/{name}/columns` · `.../modules/{name}/dispatch-table` | Entity columns · DECIDE dispatch table |
| `GET .../data-structures/{name}/fields` · `.../db-tables/{name}/columns` | Fields of a structure · columns of a table |
| Endpoint | Description |
|-----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `GET .../modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
| `GET .../store?slice=` · `GET .../store/{slice}/accesses?field=&mode=` | Frontend Redux store: slices, state keys, who reads/writes them (item 194) |
| `GET .../bindings?dto=&field=&mode=&module=` | DTO field bindings: which component reads/writes which backend field, with its Java counterpart (item 195) |
| `GET .../theme?unused=` · `GET .../theme/{token}/usages` · `GET .../styles` | MUI theme tokens with use counts, where a token is read, and the sx/style/styled/CSS inventory with hard-coded literals (item 196) |
| `GET .../modules/{name}/sql-statements` | SQL/ADABAS statements |
| `GET .../modules/{name}/workfile-accesses` | Natural work-file reads/writes |
| `GET .../modules/{name}/data-structures` | Data structures used by the module |
| `GET .../modules/{name}/payload` | Parameter-data-area I/O contract |
| `GET .../modules/{name}/columns` · `.../modules/{name}/dispatch-table` | Entity columns · DECIDE dispatch table |
| `GET .../data-structures/{name}/fields` · `.../db-tables/{name}/columns` | Fields of a structure · columns of a table |
### Search & dataflow
@@ -354,6 +358,7 @@ agenticcode> exit
ac project create upms /path/to/sources -l natural -d "UPMS legacy"
ac project list
ac project update upms -d "New description"
ac project rename upms upms_alt # every node, override and counterpart reference follows
ac project delete upms
# Ingest / refresh
@@ -390,15 +395,15 @@ ac dynamic-calls reset --file X.nat --line 403 -p upms
`ac --help` lists everything; `ac <command> --help` shows a command's options. The main commands:
| Group | Commands |
|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Project & ingest** | `project create\|update\|list\|delete`, `refresh [MODULE] [--deep] [--max-depth N] [--max-nodes N] [--neighborhood]`, `recreate` |
| **Call graph** | `callers`, `callees`, `call-tree`, `context`, `digest`, `functions`, `function-callers`, `function-overrides`, `ego-graph`, `modules`, `loc` |
| **Data & DB** | `db-accesses`, `sql-statements`, `workfile-accesses`, `payload`, `dispatch-table`, `module-data-structures`, `data-structure-fields`, `db-table-columns`, `entity-columns` |
| **Search & dataflow** | `search-identifier`, `search-value`, `search-source`, `search-annotation`, `variable-reads`, `variable-writes`, `flow-forward`, `flow-backward`, `field-flow` |
| **Source & nodes** | `module-source`, `file-source`, `node-source`, `inspect-node` |
| **Dynamic dispatch** | `dynamic-calls unresolved\|overrides\|set\|reset` |
| **Session / misc** | `connect`, `use`, `version` |
| Group | Commands |
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Project & ingest** | `project create\|update\|list\|delete`, `refresh [MODULE] [--deep] [--max-depth N] [--max-nodes N] [--neighborhood]`, `recreate` |
| **Call graph** | `callers`, `callees`, `call-tree`, `context`, `digest`, `functions`, `function-callers`, `function-overrides`, `ego-graph`, `modules`, `loc` |
| **Data & DB** | `db-accesses`, `sql-statements`, `workfile-accesses`, `payload`, `dispatch-table`, `module-data-structures`, `data-structure-fields`, `db-table-columns`, `entity-columns`, `store`, `store-accesses`, `bindings`, `theme`, `theme-usages`, `styles` |
| **Search & dataflow** | `search-identifier`, `search-value`, `search-source`, `search-annotation`, `variable-reads`, `variable-writes`, `flow-forward`, `flow-backward`, `field-flow` |
| **Source & nodes** | `module-source`, `file-source`, `node-source`, `inspect-node` |
| **Dynamic dispatch** | `dynamic-calls unresolved\|overrides\|set\|reset` |
| **Session / misc** | `connect`, `use`, `version` |
**Useful options.** Most list commands accept `--limit` / `--offset` for pagination. `callers` and `callees` take
`--scope external` (module-to-module `CALLNAT`/inheritance — the default for `callers`) or `--scope internal` (

View File

@@ -45,6 +45,13 @@ import java.util.concurrent.Callable;
SearchIdentifierCommand.class,
SearchReferencesCommand.class,
RestEndpointsCommand.class,
CounterpartsCommand.class,
StoreCommand.class,
StoreAccessesCommand.class,
BindingsCommand.class,
ThemeCommand.class,
ThemeUsagesCommand.class,
StylesCommand.class,
ModulesCommand.class,
LocCommand.class,
ModuleDataStructuresCommand.class,

View File

@@ -0,0 +1,58 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 195: which component reads/writes which DTO field, with the field's backend counterpart.
*/
@Command(name = "bindings", mixinStandardHelpOptions = true,
description = "List DTO field bindings (generated Fields path objects on SmartInput/SmartOutput/tables) with their backend counterpart")
final class BindingsCommand extends AbstractProjectCommand {
@Option(names = "--dto", description = "Only fields of this DTO (the declaring interface, e.g. Broker)")
@Nullable String dto;
@Option(names = "--field", description = "Only this field name")
@Nullable String field;
@Option(names = "--mode", description = "reads | writes (default: both)")
@Nullable String mode;
@Option(names = "--module", description = "Only bindings in this module (identity or short name)")
@Nullable String module;
@Option(names = "--partial", description = "true: only partial paths (rooted at a prop/local); false: only full paths")
@Nullable Boolean partial;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/bindings", "dto", dto);
path = appendQuery(path, "field", field);
path = appendQuery(path, "mode", mode);
path = appendQuery(path, "module", module);
if (partial != null) {
path = appendQuery(path, "partial", partial.toString());
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,52 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 193: lists a project's counterparts in another project — its outbound web-service calls with
* the backend handler serving them, its generated DTOs with the Java class they mirror, and their
* fields — or, with {@code --unmatched}, exactly those that have no twin yet.
*/
@Command(name = "counterparts", mixinStandardHelpOptions = true,
description = "List the project's COUNTERPART_OF twins in its counterpart project(s): web-service calls -> handlers, generated DTOs -> classes, fields -> fields")
final class CounterpartsCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Only the rows declared in this module (identity or short name)")
@Nullable String module;
@Option(names = "--kind", description = "rest | dto | field (default: all)")
@Nullable String kind;
@Option(names = "--unmatched", description = "Only rows without a counterpart (what is not served / mirrored yet)")
boolean unmatched;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/counterparts", "module", module);
path = appendQuery(path, "kind", kind);
if (unmatched) {
path = appendQuery(path, "unmatched", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -12,17 +12,18 @@ import java.util.Objects;
import java.util.concurrent.Callable;
/**
* Groups project management subcommands: create, update, delete, recreate, show, list.
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
*/
@Command(
name = "project",
mixinStandardHelpOptions = true,
description = "Create, update, delete, recreate, show or list projects",
description = "Create, update, delete, recreate, rename, show or list projects",
subcommands = {
ProjectCommand.CreateCommand.class,
ProjectCommand.UpdateCommand.class,
ProjectCommand.DeleteCommand.class,
ProjectCommand.RecreateCommand.class,
ProjectCommand.RenameCommand.class,
ProjectCommand.ShowCommand.class,
ProjectCommand.ListCommand.class
}
@@ -59,13 +60,17 @@ final class ProjectCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Option(names = {"-l", "--language"}, required = true,
description = "Project source language (natural/java/natjav)")
description = "Project source language (natural/java/natjav/typescript)")
String language;
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
String userExitDir = "";
@@ -75,12 +80,13 @@ final class ProjectCommand implements Callable<Integer> {
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
language, generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir)));
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@Command(name = "update", mixinStandardHelpOptions = true,
description = "Update a project's description, root, exclude-dirs, language and/or generated/user-exit dirs (unset fields are left unchanged)")
description = "Update a project's description, root, exclude-dirs, language, counterparts and/or generated/user-exit dirs (unset fields are left unchanged)")
static final class UpdateCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@@ -97,13 +103,17 @@ final class ProjectCommand implements Callable<Integer> {
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
List<String> excludeDirs = List.of();
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/natjav)")
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/natjav/typescript)")
String language = "";
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
String userExitDir = "";
@@ -116,7 +126,8 @@ final class ProjectCommand implements Callable<Integer> {
excludeDirs.isEmpty() ? null : excludeDirs,
language.isBlank() ? null : language,
generatedDir.isBlank() ? null : generatedDir,
userExitDir.isBlank() ? null : userExitDir)));
userExitDir.isBlank() ? null : userExitDir,
counterparts.isEmpty() ? null : counterparts)));
}
}
@@ -170,6 +181,28 @@ final class ProjectCommand implements Callable<Integer> {
}
}
/**
* Item 202: rename a project in place (nodes, overrides and other projects' counterparts follow).
*/
@Command(name = "rename", mixinStandardHelpOptions = true,
description = "Rename a project; every node, override and counterpart reference follows")
static final class RenameCommand extends AbstractApiCommand {
@SuppressWarnings("NullAway.Init")
@Parameters(index = "0", description = "Current project name")
String name;
@SuppressWarnings("NullAway.Init")
@Parameters(index = "1", description = "New project name")
String newName;
@Override
public Integer call() throws Exception {
return printResponse(apiClient().postJson("/api/projects/" + encode(name) + "/rename",
java.util.Map.of("newName", newName)));
}
}
/**
* Item 78: re-initialise a project from its own stored config — no need to look up and re-type
* {@code root}/{@code language}/{@code generatedDir}/{@code userExitDir}, and no window in which they

View File

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

View File

@@ -0,0 +1,53 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/**
* Item 194: who reads and writes one store slice — reducers, selectors, wrapper hooks, getState() chains.
*/
@Command(name = "store-accesses", mixinStandardHelpOptions = true,
description = "List the access sites of a store slice: reducers writing it, components/hooks/thunks reading it")
final class StoreAccessesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "The slice (reducer key or RTK slice name)")
String slice = "";
@Option(names = "--field", description = "Only this top-level state key")
@Nullable String field;
@Option(names = "--mode", description = "reads | writes (default: both)")
@Nullable String mode;
@Option(names = "--module", description = "Only accesses from this module (identity or short name)")
@Nullable String module;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/store/" + encode(slice) + "/accesses";
path = appendQuery(path, "field", field);
path = appendQuery(path, "mode", mode);
path = appendQuery(path, "module", module);
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,26 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 194: the frontend Redux store — its slices, their state keys and access counts.
*/
@Command(name = "store", mixinStandardHelpOptions = true,
description = "List the project's Redux store slices (reducer key, state keys with types and read/write counts, reducers)")
final class StoreCommand extends AbstractProjectCommand {
@Option(names = "--slice", description = "Only this slice (reducer key or RTK slice name)")
@Nullable String slice;
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(appendQuery(projectPath() + "/store", "slice", slice)));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,50 @@
package com.agenticcode.cli;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 196: the style inventory — sx/style/styled blocks and CSS rules with keys, literals and theme tokens.
*/
@Command(name = "styles", mixinStandardHelpOptions = true,
description = "List style blocks (sx, style, styled, css rules) with their CSS keys, hard-coded literals and theme tokens")
final class StylesCommand extends AbstractProjectCommand {
@Option(names = "--module", description = "Only blocks in this module (identity or short name)")
@Nullable String module;
@Option(names = "--kind", description = "sx | style | styled | css (default: all)")
@Nullable String kind;
@Option(names = "--with-literals", description = "Only blocks with hard-coded colour/length literals (what bypasses the theme)")
boolean withLiterals;
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
boolean countOnly;
@Option(names = "--limit", description = "Max items to return (default: all)")
int limit = -1;
@Option(names = "--offset", description = "Items to skip")
int offset = -1;
@Override
public Integer call() throws Exception {
try {
String path = appendQuery(projectPath() + "/styles", "module", module);
path = appendQuery(path, "kind", kind);
if (withLiterals) {
path = appendQuery(path, "withLiterals", "true");
}
if (countOnly) {
path = appendQuery(path, "countOnly", "true");
}
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,29 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
/**
* Item 196: the MUI theme's tokens with their project-side use counts.
*/
@Command(name = "theme", mixinStandardHelpOptions = true,
description = "List the theme tokens (createTheme leaves + theme constants) with values and project use counts; undeclared tokens the code reads are listed with declared=false")
final class ThemeCommand extends AbstractProjectCommand {
@Option(names = "--unused", description = "Only tokens with no project reference (MUI's own use of a token is not visible)")
boolean unused;
@Override
public Integer call() throws Exception {
try {
String path = projectPath() + "/theme";
if (unused) {
path = appendQuery(path, "unused", "true");
}
return printResponse(apiClient().get(path));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,25 @@
package com.agenticcode.cli;
import picocli.CommandLine.Command;
import picocli.CommandLine.Parameters;
/**
* Item 196: where one theme token is read — style blocks (with the CSS key it feeds) and plain code/prop reads.
*/
@Command(name = "theme-usages", mixinStandardHelpOptions = true,
description = "List where a theme token (palette.primary.dark or a constant name) is read")
final class ThemeUsagesCommand extends AbstractProjectCommand {
@Parameters(index = "0", description = "The token: a dotted createTheme path (palette.primary.dark) or a theme constant (PRIMARY)")
String token = "";
@Override
public Integer call() throws Exception {
try {
return printResponse(apiClient().get(projectPath() + "/theme/" + encode(token) + "/usages"));
} catch (IllegalStateException e) {
System.err.println(e.getMessage());
return 1;
}
}
}

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=346
version=350

View File

@@ -33,6 +33,10 @@
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-natjav</artifactId>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-typescript</artifactId>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-neo4j-store</artifactId>

View File

@@ -1,14 +1,30 @@
# Quarkus JVM (fast-jar) runtime image.
# Expects `mvn package` to have already produced target/quarkus-app/ on the host
# (build context is the ac-code-server module directory).
# Expects `mvn package` to have already produced ac-code-server/target/quarkus-app/ on the host.
# Build context is the REPOSITORY ROOT (see docker-compose.yml), because the image also carries the
# TypeScript sidecar from ac-parser-typescript/sidecar (item 192); the root .dockerignore keeps the
# context down to exactly the paths COPYed below.
# --- stage 1: the TypeScript sidecar with its pinned `typescript` dependency -------------------
FROM node:24-slim AS sidecar
WORKDIR /sidecar
COPY ac-parser-typescript/sidecar/package.json ac-parser-typescript/sidecar/package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY ac-parser-typescript/sidecar/extract.mjs ./
# --- stage 2: the server ------------------------------------------------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /work/
COPY target/quarkus-app/lib/ /work/lib/
COPY target/quarkus-app/*.jar /work/
COPY target/quarkus-app/app/ /work/app/
COPY target/quarkus-app/quarkus/ /work/quarkus/
# Item 192: node + the sidecar. The JVM starts `node /work/sidecar/extract.mjs` per npm workspace of
# a `typescript` project during a deep pass (application.properties, %prod.agenticcode.typescript.*).
COPY --from=sidecar /usr/local/bin/node /usr/local/bin/node
COPY --from=sidecar /sidecar /work/sidecar
COPY ac-code-server/target/quarkus-app/lib/ /work/lib/
COPY ac-code-server/target/quarkus-app/*.jar /work/
COPY ac-code-server/target/quarkus-app/app/ /work/app/
COPY ac-code-server/target/quarkus-app/quarkus/ /work/quarkus/
EXPOSE 8787

View File

@@ -732,7 +732,7 @@ public class AnalysisResource {
@PathParam("function") String function,
@QueryParam("sourceFile") @Nullable String sourceFile) {
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.functionCallers(project, resolvedName, function, anySource(sourceFile))
.map(resp -> Response.ok(resp).build()));
.map(resp -> callRefResponse(resp, null)));
}
@GET
@@ -759,9 +759,18 @@ public class AnalysisResource {
@QueryParam("sourceFile") @Nullable String sourceFile) {
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.callees(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset),
resolveInterfaces != null && resolveInterfaces)
.map(resp -> namesOnly(fields)
? ok(callRefNames(resp))
: Response.ok(resp).build()));
.map(resp -> callRefResponse(resp, fields)));
}
/**
* Item 181: callers/callees page at {@code limit=50} by default; the response says so in the
* item-131 headers (and the body's {@code total}/{@code truncated}), for the names-only shape too.
*/
private Response callRefResponse(CallRefResponse resp, @Nullable String fields) {
Response.ResponseBuilder builder = namesOnly(fields)
? Response.fromResponse(ok(callRefNames(resp)))
: Response.ok(resp);
return builder.header(TOTAL_COUNT, resp.total()).header(TRUNCATED, resp.truncated()).build();
}
/**
@@ -827,6 +836,191 @@ public class AnalysisResource {
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
}
private static @Nullable String blankToNull(@Nullable String s) {
return s == null || s.isBlank() ? null : s.strip();
}
@GET
@Path("/counterparts")
@Operation(summary = "Counterparts in another project (item 193)",
description = "This project's outbound web-service calls, generated data structures and their fields, each "
+ "with its COUNTERPART_OF twin in a counterpart project (project setting `counterparts`); "
+ "`unmatched=true` lists only those without a twin - what is not served or mirrored yet.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Counterpart.class)))
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> counterparts(@PathParam("project") String project,
@Parameter(description = "Narrow to one declaring module (identity or short name).")
@QueryParam("module") @Nullable String module,
@Parameter(description = "rest | dto | field; all when absent.")
@QueryParam("kind") @Nullable String kind,
@Parameter(description = "Only rows without a counterpart.")
@QueryParam("unmatched") @Nullable Boolean unmatched,
@QueryParam("countOnly") @Nullable Boolean countOnly,
@QueryParam("limit") @Nullable Integer limit,
@QueryParam("offset") @Nullable Integer offset) {
String effKind = kind == null || kind.isBlank() ? null : kind.strip().toLowerCase(java.util.Locale.ROOT);
if (effKind != null && !List.of("rest", "dto", "field").contains(effKind)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
"Unknown kind '" + kind + "'; expected rest, dto or field"));
}
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
return withProject(project, () -> graphRepository.counterpartsPage(project, module, effKind,
Boolean.TRUE.equals(unmatched), effLimit, effectiveOffset(offset))
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
}
@GET
@Path("/theme")
@Operation(summary = "The MUI theme's tokens with their project-side use counts (item 196)",
description = "Every leaf of createTheme({..}) (`palette.primary.dark`, value folded through constants) and every "
+ "exported string constant of the theme file, plus the tokens the code reads that no theme declares "
+ "(`declared=false`: MUI defaults such as palette.grey.200, or typos). `uses` counts project references only; "
+ "MUI's own use of a token is invisible, so `unused=true` means 'not referenced by project code', not 'dead'.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeToken.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> theme(@PathParam("project") String project,
@Parameter(description = "Only tokens with no project reference.")
@QueryParam("unused") @Nullable Boolean unused) {
return withProject(project, () -> graphRepository.themeTokens(project, Boolean.TRUE.equals(unused)).map(this::ok));
}
@GET
@Path("/theme/{token}/usages")
@Operation(summary = "Where one theme token is read (item 196)",
description = "Style blocks reading the token (with `styleKind`, `element` and the CSS `property` it feeds) and plain "
+ "reads (`context` = the JSX attribute or `code`). `token` is the dotted path (`palette.primary.dark`) or a constant name (`PRIMARY`).")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeUsage.class)))
@APIResponse(responseCode = "404", description = "Project or token not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> themeUsages(@PathParam("project") String project, @PathParam("token") String token) {
return withProject(project, () -> graphRepository.themeUsages(project, token).chain(rows -> {
if (rows.isEmpty()) {
return graphRepository.themeTokens(project, false).map(all -> all.stream().anyMatch(t -> t.token().equals(token))
? ok(rows)
: ProjectResource.error(Response.Status.NOT_FOUND, "TOKEN_NOT_FOUND",
"No theme token '" + token + "' in project '" + project + "'"));
}
return Uni.createFrom().item(ok(rows));
}));
}
@GET
@Path("/styles")
@Operation(summary = "The style inventory: sx/style/styled blocks and CSS rules (item 196)",
description = "One row per style block with its CSS keys (`properties`, nested selectors flattened), hard-coded "
+ "colours/lengths (`literals`) and the theme tokens it reads. `withLiterals=true` = only blocks that bypass "
+ "the theme; `kind` = sx | style | styled | css; `module` = one component module.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StyleBlock.class)))
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> styles(@PathParam("project") String project,
@QueryParam("module") @Nullable String module,
@Parameter(description = "sx | style | styled | css; all when absent.")
@QueryParam("kind") @Nullable String kind,
@Parameter(description = "Only blocks with hard-coded colour/length literals.")
@QueryParam("withLiterals") @Nullable Boolean withLiterals,
@QueryParam("countOnly") @Nullable Boolean countOnly,
@QueryParam("limit") @Nullable Integer limit,
@QueryParam("offset") @Nullable Integer offset) {
String trimmedKind = blankToNull(kind);
String effKind = trimmedKind == null ? null : trimmedKind.toLowerCase(java.util.Locale.ROOT);
if (effKind != null && !List.of("sx", "style", "styled", "css").contains(effKind)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
"Unknown kind '" + kind + "'; expected sx, style, styled or css"));
}
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
return withProject(project, () -> graphRepository.stylesPage(project, blankToNull(module), effKind,
Boolean.TRUE.equals(withLiterals), effLimit, effectiveOffset(offset))
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
}
@GET
@Path("/bindings")
@Operation(summary = "DTO field bindings: which component reads/writes which backend field (item 195)",
description = "Every use of a generated `Fields` path object (`<SmartInput field={AgstammUseCaseField.broker.ebene}>`) "
+ "as one row: the binding function, the DTO field it names (`dto.field`, full `path` from `rootDto`), "
+ "READS or WRITES (input components write), and the field's COUNTERPART_OF twin in the backend project. "
+ "`dto`/`field`/`module`/`mode`/`partial` narrow the list.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Binding.class)))
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> bindings(@PathParam("project") String project,
@Parameter(description = "Only fields of this DTO (the declaring interface, e.g. Broker).")
@QueryParam("dto") @Nullable String dto,
@Parameter(description = "Only this field name.")
@QueryParam("field") @Nullable String field,
@Parameter(description = "reads | writes; both when absent.")
@QueryParam("mode") @Nullable String mode,
@Parameter(description = "Only bindings in this module (identity or short name).")
@QueryParam("module") @Nullable String module,
@Parameter(description = "true: only partial paths (rooted at a prop/local); false: only full paths.")
@QueryParam("partial") @Nullable Boolean partial,
@QueryParam("countOnly") @Nullable Boolean countOnly,
@QueryParam("limit") @Nullable Integer limit,
@QueryParam("offset") @Nullable Integer offset) {
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
"Unknown mode '" + mode + "'; expected reads or writes"));
}
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
return withProject(project, () -> graphRepository.bindingsPage(project, blankToNull(dto), blankToNull(field), effMode,
blankToNull(module), partial, effLimit, effectiveOffset(offset))
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
}
@GET
@Path("/store")
@Operation(summary = "The frontend Redux store: its slices, state keys and access counts (item 194)",
description = "One row per createSlice mounted in the project's store, named by its reducer key (`state.<slice>`), "
+ "with the top-level state keys (type, optional, read/write site counts), the number of reducer functions "
+ "and the total access sites. `slice` narrows to one slice by reducer key or RTK slice name.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreSlice.class)))
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> store(@PathParam("project") String project,
@Parameter(description = "Reducer key or slice name; all slices when absent.")
@QueryParam("slice") @Nullable String slice) {
String effSlice = slice == null || slice.isBlank() ? null : slice.strip();
return withProject(project, () -> graphRepository.storeSlices(project, effSlice).map(this::ok));
}
@GET
@Path("/store/{slice}/accesses")
@Operation(summary = "Who reads and writes a store slice (item 194)",
description = "Every access site of the slice's state: reducers (`functionKind=reducer`, the writers, `via=reducer`) "
+ "and the components/hooks/thunks reading it through useAppSelector, a wrapper hook or getState() "
+ "(`via` names the hook). `field` narrows to one state key, `mode` to READS or WRITES, `module` to one "
+ "accessing module. `path` is the full sub-path as written; `field` is null for a whole-slice access.")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreAccess.class)))
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project or slice not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> storeAccesses(@PathParam("project") String project, @PathParam("slice") String slice,
@Parameter(description = "One top-level state key; all when absent.")
@QueryParam("field") @Nullable String field,
@Parameter(description = "reads | writes; both when absent.")
@QueryParam("mode") @Nullable String mode,
@Parameter(description = "Only accesses from this module (identity or short name).")
@QueryParam("module") @Nullable String module,
@QueryParam("countOnly") @Nullable Boolean countOnly,
@QueryParam("limit") @Nullable Integer limit,
@QueryParam("offset") @Nullable Integer offset) {
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
"Unknown mode '" + mode + "'; expected reads or writes"));
}
String effField = field == null || field.isBlank() ? null : field.strip();
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
return withProject(project, () -> graphRepository.storeSlices(project, slice).chain(slices -> {
if (slices.isEmpty()) {
return Uni.createFrom().item(ProjectResource.error(Response.Status.NOT_FOUND, "SLICE_NOT_FOUND",
"No store slice '" + slice + "' in project '" + project + "'"));
}
return graphRepository.storeAccessesPage(project, slice, effField, effMode, module, effLimit, effectiveOffset(offset))
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page));
}));
}
@GET
@Path("/search/references")
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ReferenceSite.class)))
@@ -1152,7 +1346,7 @@ public class AnalysisResource {
return withModule(project, name, sourceFile, resolvedName -> fanoutWarm(project,
() -> graphRepository.callers(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset)),
CallRefResponse::sourceFiles,
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build()));
resp -> callRefResponse(resp, fields)));
}
@GET

View File

@@ -1,6 +1,7 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.codeserver.service.ProjectIngestService;
import com.agenticcode.codeserver.service.ProjectMetadataCache;
import com.agenticcode.codeserver.service.ProjectRootResolver;
import com.agenticcode.neo4jstore.graph.GraphRepository;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
@@ -38,14 +39,16 @@ public class ProjectResource {
private final GraphRepository graphRepository;
private final ProjectIngestService ingestService;
private final ProjectRootResolver rootResolver;
private final ProjectMetadataCache projectMetadataCache;
private final boolean scanOnCreate;
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
ProjectRootResolver rootResolver,
ProjectRootResolver rootResolver, ProjectMetadataCache projectMetadataCache,
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
this.graphRepository = graphRepository;
this.ingestService = ingestService;
this.rootResolver = rootResolver;
this.projectMetadataCache = projectMetadataCache;
this.scanOnCreate = scanOnCreate;
}
@@ -61,7 +64,7 @@ public class ProjectResource {
.build();
}
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "natjav");
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "natjav", "typescript");
private static @Nullable String normalize(@Nullable String value) {
return value == null || value.isBlank() ? null : value.strip();
@@ -136,8 +139,14 @@ public class ProjectResource {
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts == null ? List.of() : counterparts)
.map(result -> switch (result) {
case SUCCESS -> {
scanTier1(project);
@@ -185,8 +194,14 @@ public class ProjectResource {
if (invalid != null) {
return Uni.createFrom().item(invalid);
}
@Nullable List<String> counterparts = request.normalizedCounterparts();
if (counterparts != null && counterparts.contains(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
"A project cannot be its own counterpart"));
}
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
counterparts)
.map(result -> switch (result) {
case SUCCESS -> Response.ok().build();
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
@@ -202,6 +217,48 @@ public class ProjectResource {
return graphRepository.clearAll().replaceWith(Response.noContent().build());
}
/**
* Item 202: rename a project in place — every node, override and counterpart reference follows.
*/
@POST
@Path("/{project}/rename")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Rename a project (item 202)",
description = "Rewrites the project key on every node and override and in other projects' counterparts lists. "
+ "Batched; an interrupted rename is finished by re-running it.")
@APIResponse(responseCode = "200", description = "The renamed project.")
@APIResponse(responseCode = "400", description = "newName missing, blank or equal to the current name.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "404", description = "Project not found.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@APIResponse(responseCode = "409", description = "A project with the new name already exists.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public Uni<Response> rename(@PathParam("project") String project, RenameRequest request) {
String newName = request == null || request.newName() == null ? "" : request.newName().trim();
if (newName.isEmpty() || newName.equals(project)) {
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "INVALID_REQUEST",
"newName is required and must differ from the current name"));
}
return graphRepository.renameProject(project, newName)
.flatMap(result -> switch (result) {
case SUCCESS -> {
projectMetadataCache.invalidate(project);
projectMetadataCache.invalidate(newName);
yield graphRepository.getProject(newName).map(info -> Response.ok(info).build());
}
case NOT_FOUND -> Uni.createFrom().item(error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
"Project '" + project + "' does not exist"));
case CONFLICT -> Uni.createFrom().item(error(Response.Status.CONFLICT, "PROJECT_EXISTS",
"Project '" + newName + "' already exists"));
});
}
/**
* Body of {@code POST /api/projects/{project}/rename}.
*/
public record RenameRequest(@Nullable String newName) {
}
@DELETE
@Path("/{project}")
@Operation(summary = "Delete a project")
@@ -229,14 +286,33 @@ public class ProjectResource {
*/
public record ProjectRequest(@Nullable String description, @Nullable String root,
@Nullable List<String> excludeDirs, @Nullable String language,
@Nullable String generatedDir, @Nullable String userExitDir) {
@Nullable String generatedDir, @Nullable String userExitDir,
@Nullable List<String> counterparts) {
/**
* Pre-item-193 shape (no counterparts).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
}
/**
* Backward-compatible constructor for callers that predate the item-47 fields (language /
* generated / user-exit dir all absent).
*/
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
this(description, root, excludeDirs, null, null, null);
this(description, root, excludeDirs, null, null, null, null);
}
/**
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
*/
@Nullable List<String> normalizedCounterparts() {
if (counterparts == null) {
return null;
}
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
}
List<String> excludeDirsOrEmpty() {

View File

@@ -19,6 +19,11 @@ import com.agenticcode.parsernatural.CopycodeResolver;
import com.agenticcode.parsernatural.NaturalCoarseScanner;
import com.agenticcode.parsernatural.NaturalLineCounter;
import com.agenticcode.parsernatural.NaturalParser;
import com.agenticcode.parsertypescript.CssLineCounter;
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner;
import com.agenticcode.parsertypescript.TypeScriptLineCounter;
import com.agenticcode.parsertypescript.TypeScriptParser;
import com.agenticcode.parsertypescript.TypeScriptProject;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@@ -46,6 +51,12 @@ public class AstIngestService {
private final NatJavParser natJavParser = new NatJavParser();
private final NatJavCoarseScanner natJavScanner = new NatJavCoarseScanner();
private final LineCounter natJavLineCounter = new NatJavLineCounter();
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
private final LineCounter cssLineCounter = new CssLineCounter();
/**
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
@@ -117,23 +128,24 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
return parse(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE);
return parse(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE, TypeScriptProject.NONE);
}
/**
* As {@link #parse(SourceFiles.Language, String, String, CopycodeResolver)}, with the project's
* NatJav DDM index (item D) so a view resolves to its table and columns. Ignored by the other
* languages, which have no DDM concept.
* Parses {@code content} with the parser for {@code language}. Each language-specific context is
* ignored by the others: {@code copycodes} serves Natural (item 46a), {@code ddms} the NatJav DDM
* index (item D, so a view resolves to its table and columns), {@code typescript} TypeScript/CSS
* (item 192). The switch is exhaustive on purpose: a language added without a parser must fail to
* compile, not silently hand its files to another parser and store a plausible-looking but wrong
* graph.
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, NatJavDdmResolver ddms) {
// Exhaustive switch, not an if/else with a Natural fallthrough: a language added without a
// parser must fail to compile, not silently hand its files to the Natural parser and store
// a plausible-looking but wrong graph.
CopycodeResolver copycodes, NatJavDdmResolver ddms, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaParser.parse(sourceFile, content);
case NATJAV -> natJavParser.parse(sourceFile, content, ddms);
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
};
}
@@ -152,19 +164,20 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
return coarseScan(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE);
return coarseScan(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE, TypeScriptProject.NONE);
}
/**
* As {@link #coarseScan(SourceFiles.Language, String, String, CopycodeResolver)}, with the
* project's NatJav DDM index.
* As {@link #parse(SourceFiles.Language, String, String, CopycodeResolver, NatJavDdmResolver, TypeScriptProject)},
* for the Tier-1 outline scan.
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, NatJavDdmResolver ddms) {
CopycodeResolver copycodes, NatJavDdmResolver ddms, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaScanner.scan(sourceFile, content);
case NATJAV -> natJavScanner.scan(sourceFile, content, ddms);
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
};
}
@@ -177,6 +190,8 @@ public class AstIngestService {
case JAVA -> javaLineCounter;
case NATJAV -> natJavLineCounter;
case NATURAL -> naturalLineCounter;
case TYPESCRIPT -> typeScriptLineCounter;
case CSS -> cssLineCounter;
};
return counter.count(content);
}

View File

@@ -7,6 +7,7 @@ import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import com.agenticcode.parsernatjav.NatJavDdmResolver;
import com.agenticcode.parsertypescript.TypeScriptProject;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
@@ -53,10 +54,13 @@ public class ProjectIngestService {
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
// report "clean" about a graph whose refresh has just started.
private final ProjectMetadataCache projectMetadata;
// Item 192: builds the per-ingest TypeScript context (workspaces, packages, sidecar facts).
private final TypeScriptSidecarService typescript;
public ProjectIngestService(AstIngestService astIngestService,
VersionInfo versionInfo,
ProjectMetadataCache projectMetadata,
TypeScriptSidecarService typescript,
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
@@ -65,6 +69,7 @@ public class ProjectIngestService {
this.astIngestService = astIngestService;
this.versionInfo = versionInfo;
this.projectMetadata = projectMetadata;
this.typescript = typescript;
this.batchSize = Math.max(1, batchSize);
this.maxDeepDepth = Math.max(1, maxDeepDepth);
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
@@ -237,16 +242,16 @@ public class ProjectIngestService {
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
*/
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
CopycodeLibrary copycodes, Map<String, LocMetrics> userExit,
CopycodeLibrary copycodes, TypeScriptProject ts, Map<String, LocMetrics> userExit,
ProjectInfo project,
Map<String, NatJavIdentities.Rename> natJavRenames,
NatJavDdmResolver ddms) throws IOException {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
ParseResult result = coarse
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ddms)
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ddms, ts)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content,
copycodes, ddms), content, candidate.kind().language());
copycodes, ddms, ts), content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
result = NatJavIdentities.apply(result, sourceFile, natJavRenames);
return new Parsed(candidate, result);
@@ -272,10 +277,12 @@ public class ProjectIngestService {
* Build-output directories always skipped when scanning a root (J6: their generated sources
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
*/
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
private static List<Candidate> walk(Path root, List<String> excludeDirs,
@Nullable String projectLanguage) throws IOException {
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
throws IOException {
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
for (String dir : DEFAULT_EXCLUDE_DIRS) {
if (!effectiveExcludes.contains(dir)) {
@@ -291,8 +298,10 @@ public class ProjectIngestService {
if (SourceFiles.isExcluded(root, file, effectiveExcludes)) {
continue;
}
// NatJav: the project's declared language decides what a .java file is (item A);
// TypeScript/CSS are picked up only by a typescript project (item 192).
SourceFiles.Kind kind = SourceFiles.classify(file, projectLanguage);
if (kind != null) {
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
candidates.add(new Candidate(file, kind));
}
}
@@ -573,6 +582,11 @@ public class ProjectIngestService {
: allCandidates;
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), project.language(),
astIngestService);
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
// Tier-1, so the refresh completes and the response says what is missing.
List<IngestSummary.Failure> failed = new ArrayList<>();
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
List<String> examinedFiles = candidates.stream()
.map(c -> relativeSourceFile(root, c.file()))
@@ -583,11 +597,10 @@ public class ProjectIngestService {
// Results are collected in candidate order (futures list is parallel to candidates), so the
// downstream duplicate detection and persist order stay deterministic.
List<Parsed> parsed = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Parsed>> futures = candidates.stream()
.map(candidate -> parseExecutor.submit(
() -> parseCandidate(candidate, root, coarse, copycodes, userExit, project, natJavRenames, ddms)))
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project, natJavRenames, ddms)))
.toList();
for (int i = 0; i < futures.size(); i++) {
try {
@@ -704,10 +717,9 @@ public class ProjectIngestService {
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
// (a legacy project) keeps the conservative behaviour.
if (!SourceFiles.Language.JAVA.name().equalsIgnoreCase(project.language() == null ? "" : project.language())
&& !SourceFiles.NATJAV_PROJECT_LANGUAGE.equalsIgnoreCase(
project.language() == null ? "" : project.language())
&& copycodeChanged(root, excludeDirs, stored)) {
String declared = project.language() == null ? "" : project.language();
boolean inlinesCopycodes = declared.isEmpty() || "natural".equalsIgnoreCase(declared);
if (inlinesCopycodes && copycodeChanged(root, excludeDirs, stored)) {
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
project.name());
@@ -952,6 +964,10 @@ public class ProjectIngestService {
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), project.language(),
astIngestService);
List<IngestSummary.Failure> failed = new ArrayList<>();
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
// workspace, run once for this call).
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
int ingested = 0;
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
@@ -959,7 +975,6 @@ public class ProjectIngestService {
Set<String> inferredCallTargets = new HashSet<>();
Set<String> staticCallTargets = new HashSet<>();
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
List<String> examinedFiles = new ArrayList<>();
Set<String> ingestedModuleNames = new HashSet<>();
@@ -1008,7 +1023,7 @@ public class ProjectIngestService {
String sourceFile = relativeSourceFile(root, candidate.file());
examinedFiles.add(sourceFile);
ParseResult result = NatJavIdentities.apply(withUserExitMetrics(withShellMetrics(
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ddms),
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ddms, ts),
content, candidate.kind().language()), project.generatedDir(), userExit),
sourceFile, natJavRenames);
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);

View File

@@ -23,6 +23,15 @@ import java.util.Locale;
*
* <p>NatJav (Natural transpiled to Java) breaks the extension-only rule: its files are {@code .java},
* so the <em>project's</em> declared language decides — see {@link #classify(Path, String)}.
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
* ingestible and classify to {@code null}.
*
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
* project of a given declared language picks a file up. Java and Natural files are ingested by every
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
*/
final class SourceFiles {
@@ -45,8 +54,8 @@ final class SourceFiles {
private static final String NATJAV_DATA_AREA_SUFFIX = "_da.java";
/**
* As {@link #classify(Path, String)} for a caller with no project in scope (so {@code .java} is
* hand-written Java).
* @return {@link #classify(Path, String)} with no project in scope — the extension alone, so a
* {@code .java} file is {@code JAVA}.
*/
static @Nullable Kind classify(Path file) {
return classify(file, null);
@@ -76,9 +85,27 @@ final class SourceFiles {
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
}
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
}
if (name.endsWith(".css")) {
return new Kind(Language.CSS, NodeType.MODULE);
}
return null;
}
/**
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
* — see the class comment.
*/
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
return switch (language) {
case JAVA, NATURAL, NATJAV -> true;
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
};
}
/**
* @return the uppercased filename stem (name without extension), used as the case-insensitive
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
@@ -122,9 +149,10 @@ final class SourceFiles {
/**
* The parser a source file is read by. {@code NATJAV} is Natural transpiled to Java: its files
* are syntactically Java but map onto the Natural half of the unified AST, which is why it is a
* language of its own rather than a flavour of {@code JAVA}.
* language of its own rather than a flavour of {@code JAVA}. {@code CSS} shares the TypeScript
* parser but has its own line counter.
*/
enum Language {JAVA, NATURAL, NATJAV}
enum Language {JAVA, NATURAL, NATJAV, TYPESCRIPT, CSS}
/**
* Classification of an ingestible source file.

View File

@@ -0,0 +1,124 @@
package com.agenticcode.codeserver.service;
import com.agenticcode.neo4jstore.graph.ProjectInfo;
import com.agenticcode.parsertypescript.TypeScriptFacts;
import com.agenticcode.parsertypescript.TypeScriptProject;
import com.agenticcode.parsertypescript.TypeScriptSidecar;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Item 192: builds the per-ingest {@link TypeScriptProject} for a {@code typescript} project. Tier-1
* (coarse) reads only the {@code package.json} files; a deep pass also runs the Node sidecar once per
* npm workspace, one after another (measured ~0.5 GB heap and 3–5 s each on the pur frontend), and
* merges the facts. A workspace whose sidecar run fails is reported as a
* {@link IngestSummary.Failure} with the pseudo-path {@code sidecar:<workspace>}; its files are then
* parsed at Tier-1, so the ingest completes and the response says what is missing.
*
* <p>Configuration ({@code application.properties}): {@code agenticcode.typescript.node} (binary, a
* bare name is looked up on {@code PATH}), {@code agenticcode.typescript.sidecar-script}
* ({@code extract.mjs}; {@code %prod} points into the image), {@code agenticcode.typescript.max-heap-mb}
* and {@code agenticcode.typescript.timeout-seconds}.
*/
@ApplicationScoped
public class TypeScriptSidecarService {
private static final Logger LOG = Logger.getLogger(TypeScriptSidecarService.class);
private final TypeScriptSidecar sidecar;
public TypeScriptSidecarService(
@ConfigProperty(name = "agenticcode.typescript.node", defaultValue = "node") String node,
@ConfigProperty(name = "agenticcode.typescript.sidecar-script",
defaultValue = "../ac-parser-typescript/sidecar/extract.mjs") String script,
@ConfigProperty(name = "agenticcode.typescript.max-heap-mb", defaultValue = "1024") int maxHeapMb,
@ConfigProperty(name = "agenticcode.typescript.timeout-seconds", defaultValue = "600") int timeoutSeconds) {
this.sidecar = new TypeScriptSidecar(resolveOnPath(node), Path.of(script).toAbsolutePath().normalize(),
Math.max(128, maxHeapMb), Duration.ofSeconds(Math.max(10, timeoutSeconds)));
}
/**
* A bare program name is searched on {@code PATH}; anything with a separator is taken as given.
*/
static Path resolveOnPath(String program) {
Path given = Path.of(program);
if (given.getNameCount() > 1 || given.isAbsolute()) {
return given;
}
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
Path candidate = Path.of(dir, program);
if (Files.isExecutable(candidate)) {
return candidate;
}
}
}
return given;
}
/**
* @param project the project being ingested; anything but {@code typescript} yields {@link TypeScriptProject#NONE}
* @param root the project root
* @param coarse Tier-1: no sidecar
* @param failures receives one entry per workspace whose sidecar run failed
*/
public TypeScriptProject prepare(ProjectInfo project, Path root, boolean coarse, List<IngestSummary.Failure> failures) {
return prepare(project, root, project.excludeDirs(), coarse, failures);
}
/**
* @param excludeDirs directory names the walk skips; a workspace whose directory is excluded is
* not loaded by the sidecar either (the pur frontend registers only
* {@code pur-ui} + {@code pur-ui-common} and excludes the other two workspaces)
*/
public TypeScriptProject prepare(ProjectInfo project, Path root, List<String> excludeDirs, boolean coarse,
List<IngestSummary.Failure> failures) {
if (!"typescript".equalsIgnoreCase(project.language() == null ? "" : project.language())) {
return TypeScriptProject.NONE;
}
TypeScriptProject context;
try {
context = TypeScriptProject.scan(root);
} catch (IOException e) {
LOG.warnf("Could not read package.json under '%s' (%s); using built-in package list", root, e.toString());
context = TypeScriptProject.NONE;
}
if (coarse) {
return context;
}
if (!sidecar.available()) {
String why = "TypeScript sidecar unavailable (node binary, extract.mjs or its node_modules/typescript missing); "
+ "TypeScript files ingested at Tier-1 only";
LOG.warn(why);
failures.add(new IngestSummary.Failure("sidecar", why));
return context;
}
List<String> workspaces = context.workspaces().isEmpty() ? List.of(".") : context.workspaces().stream()
.filter(ws -> excludeDirs.stream().noneMatch(x -> x.equalsIgnoreCase(ws) || x.equalsIgnoreCase(Path.of(ws).getFileName().toString())))
.sorted()
.toList();
List<TypeScriptFacts> parts = new ArrayList<>();
for (String workspace : workspaces) {
long started = System.nanoTime();
try {
TypeScriptFacts facts = sidecar.extract(root, workspace, null);
parts.add(facts);
LOG.infof("TypeScript sidecar: workspace '%s' of '%s' -> %d files in %d ms", workspace, project.name(),
facts.byFile().size(), (System.nanoTime() - started) / 1_000_000);
} catch (TypeScriptSidecar.SidecarException e) {
LOG.warnf("TypeScript sidecar failed for workspace '%s' of '%s': %s", workspace, project.name(), e.getMessage());
failures.add(new IngestSummary.Failure("sidecar:" + workspace, String.valueOf(e.getMessage())));
}
}
return context.withFacts(TypeScriptFacts.merge(parts));
}
}

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=346
agenticcode.version=350
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API
@@ -44,3 +44,14 @@ agenticcode.ingest.batch-size=200
# persist. Comments are 45.8 % of the upms node population; this exists to measure what they cost.
# Not a feature — no REST parameter and no CLI flag. Leave true for normal operation.
agenticcode.ingest.comments.enabled=true
# Item 192: the TypeScript Tier-2 sidecar (ac-parser-typescript/sidecar/extract.mjs). A `typescript`
# project's deep pass runs `node --max-old-space-size=<max-heap-mb> extract.mjs` once per npm
# workspace, sequentially (measured ~0.5 GB heap, 3-5 s per workspace on the pur frontend). A bare
# `node` is looked up on PATH; dev mode runs from ac-code-server/, hence the relative script path.
# The image copies node and the sidecar to /usr/local/bin/node and /work/sidecar (Dockerfile.jvm).
agenticcode.typescript.node=node
agenticcode.typescript.sidecar-script=../ac-parser-typescript/sidecar/extract.mjs
agenticcode.typescript.max-heap-mb=1024
agenticcode.typescript.timeout-seconds=600
%prod.agenticcode.typescript.node=/usr/local/bin/node
%prod.agenticcode.typescript.sidecar-script=/work/sidecar/extract.mjs

View File

@@ -0,0 +1,215 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 195: a frontend binding DTO fields through generated {@code Fields} path objects
* ({@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}, a prop-rooted prefix, a table's
* list prefix) and the Java backend owning the DTOs. After a deep refresh {@code bindings} lists every
* site with the field's counterpart in the backend, the reads/writes resolve across files onto the
* generated interface's fields, and {@code data-structures/{dto}/fields} carries the bound counts.
* Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
*/
@QuarkusTest
class BindingsIT {
private static final String BACKEND = "bindings-backend";
private static final String FRONTEND = "bindings-frontend";
@TempDir
static Path backendRoot;
@TempDir
static Path frontendRoot;
@BeforeAll
static void ingestFixtures() {
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = backendRoot.resolve("src/main/java/com/example");
write(pkg, "AgstammUseCase.java", """
package com.example;
public class AgstammUseCase {
private String brokerName;
private Broker broker;
private java.util.List<Broker> brokerList;
}
""");
write(pkg, "Broker.java", """
package com.example;
public class Broker {
private Long vermnr;
private String ebene;
}
""");
write(frontendRoot, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = frontendRoot.resolve("app/src");
write(src.resolve("generated"), "api-interfaces.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
export interface Broker {
vermnr: number;
ebene: string;
}
export interface AgstammUseCase {
brokerName: string;
broker: Broker;
brokerList: Broker[];
}
export class Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) {}
get(): string { return '' }
}
export class BrokerFields<TRoot, TSelf extends Broker> extends Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
vermnr = new Fields<TRoot, never>(this, "vermnr");
ebene = new Fields<TRoot, never>(this, "ebene");
}
export class AgstammUseCaseFields<TRoot, TSelf extends AgstammUseCase> extends Fields<TRoot, TSelf> {
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
brokerName = new Fields<TRoot, never>(this, "brokerName");
broker = new BrokerFields<TRoot, Broker>(this, "broker");
brokerList = (index?: number) => new BrokerFields<TRoot, Broker>(this, "brokerList", index);
}
export const AgstammUseCaseField: AgstammUseCaseFields<AgstammUseCase, never> = new AgstammUseCaseFields<AgstammUseCase, never>();
""");
write(src.resolve("components"), "Smart.tsx", """
import { Fields } from 'generated/api-interfaces'
export function SmartInput<T>(props: { field: Fields<T, unknown> }) { return <input id={props.field.get()} /> }
export function SmartOutput<T>(props: { field: Fields<T, unknown> }) { return <span id={props.field.get()} /> }
export function HealthTable<T>(props: { fieldTermForRowData: Fields<T, unknown>; columns: { field: Fields<T, unknown> }[] }) { return <table /> }
""");
write(src.resolve("components"), "BrokerDialog.tsx", """
import { BrokerFields, AgstammUseCase, Broker } from 'generated/api-interfaces'
import { SmartInput, SmartOutput } from './Smart'
export function BrokerDialog(props: { prefix: BrokerFields<AgstammUseCase, Broker> }) {
return (
<div>
<SmartInput field={props.prefix.ebene} />
<SmartOutput field={props.prefix.vermnr} />
</div>
)
}
""");
write(src.resolve("components"), "AgstammPage.tsx", """
import { AgstammUseCaseField } from 'generated/api-interfaces'
import { SmartInput, SmartOutput, HealthTable } from './Smart'
import { BrokerDialog } from './BrokerDialog'
export function AgstammPage() {
return (
<div>
<SmartInput field={AgstammUseCaseField.broker.ebene} />
<SmartOutput field={AgstammUseCaseField.brokerName} />
<HealthTable fieldTermForRowData={AgstammUseCaseField.brokerList()} columns={[{ field: AgstammUseCaseField.brokerList().vermnr }]} />
<BrokerDialog prefix={AgstammUseCaseField.broker} />
</div>
)
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
List.of(BACKEND)))
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response bindings(String query) {
return given().when().get("/api/projects/" + FRONTEND + "/bindings" + query);
}
@Test
void everyBindingSiteIsListedWithItsBackendCounterpart() {
bindings("").then()
.statusCode(200)
// AgstammPage: ebene (R+W), brokerName (R), brokerList prefix (R), brokerList[].vermnr (R), broker prefix (R)
// BrokerDialog: ebene (R+W, partial), vermnr (R, partial)
.body("size()", equalTo(9))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.dto", equalTo("Broker"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("broker.ebene"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.rootDto", equalTo("AgstammUseCase"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.component", equalTo("SmartInput"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.kind", equalTo("field"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(false))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.lineNo", equalTo(8))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartProject", equalTo(BACKEND))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartField", equalTo("ebene"))
.body("find { it.field == 'brokerName' }.mode", equalTo("READS"))
.body("find { it.field == 'brokerName' }.component", equalTo("SmartOutput"))
.body("find { it.field == 'brokerList' }.kind", equalTo("prefix"))
.body("find { it.field == 'brokerList' }.path", equalTo("brokerList[]"))
.body("find { it.field == 'brokerList' }.attribute", equalTo("fieldTermForRowData"))
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.path", equalTo("brokerList[].vermnr"))
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.component", equalTo("HealthTable"))
.body("find { it.field == 'broker' }.kind", equalTo("prefix"))
.body("find { it.field == 'broker' }.dto", equalTo("AgstammUseCase"))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(true))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("ebene"))
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' }.rootDto", equalTo("AgstammUseCase"));
}
@Test
void filtersAnswerTheBoundaryQuestion() {
// which page edits Java Broker.ebene?
bindings("?dto=Broker&field=ebene&mode=writes").then().statusCode(200)
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"))
.body("counterpartModule", everyItem(equalTo("com.example.Broker")));
bindings("?dto=Broker&field=ebene&mode=writes&partial=false").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].function", equalTo("AgstammPage"));
bindings("?module=BrokerDialog&countOnly=true").then().statusCode(200).body("count", equalTo(3));
bindings("?mode=bogus").then().statusCode(400).body("code", equalTo("MODE_UNSUPPORTED"));
bindings("?dto=Nope").then().statusCode(200).body("size()", equalTo(0));
}
@Test
void dtoFieldsCarryBoundCountsAndNoPlaceholderSurvives() {
given().when().get("/api/projects/" + FRONTEND + "/data-structures/Broker/fields").then()
.statusCode(200)
.body("find { it.name == 'ebene' }.type", equalTo("FIELD"))
.body("find { it.name == 'ebene' }.boundReads", equalTo(2))
.body("find { it.name == 'ebene' }.boundWrites", equalTo(2))
.body("find { it.name == 'vermnr' }.boundReads", equalTo(2))
.body("find { it.name == 'vermnr' }.boundWrites", equalTo(0));
given().when().get("/api/projects/" + FRONTEND + "/search/identifier?name=Broker.ebene").then().statusCode(200)
.body("findAll { it.sourceFile == '' }.size()", equalTo(0));
// the generic variable endpoint sees the binding writes too
given().when().get("/api/projects/" + FRONTEND + "/variables/Broker.ebene/writes").then().statusCode(200)
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"));
}
}

View File

@@ -0,0 +1,83 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
/**
* Item 181: {@code callees}/{@code callers} page at 50 by default. A module calling 60 others used to
* get 50 rows and nothing saying so; now the headers and the body's {@code total}/{@code truncated}
* tell a cut fan-out from a complete one.
*/
@QuarkusTest
class CallRefTruncationIT {
private static final String PROJECT = "item181-truncation";
private static final int FANOUT = 60;
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
StringBuilder hub = new StringBuilder("DEFINE DATA\n LOCAL\n 01 #A (A8)\nEND-DEFINE\n");
for (int i = 0; i < FANOUT; i++) {
String callee = "CALLEE%02d".formatted(i);
hub.append("CALLNAT '").append(callee).append("' #A\n");
write(callee + ".nat", "DEFINE DATA\n PARAMETER\n 01 #P (A8)\nEND-DEFINE\nEND\n");
}
hub.append("END\n");
write("HUB.nat", hub.toString());
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static void write(String fileName, String content) {
try {
Files.writeString(root.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void theDefaultPageSaysItIsCut() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("true"))
.body("items.size()", equalTo(50))
.body("total", equalTo(FANOUT))
.body("truncated", equalTo(true));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?fields=name").then().statusCode(200)
.header("X-AC-Truncated", equalTo("true"));
}
@Test
void aCompletePageSaysItIsComplete() {
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?limit=1000").then().statusCode(200)
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(FANOUT))
.body("truncated", equalTo(false));
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?offset=50").then().statusCode(200)
.header("X-AC-Truncated", equalTo("false"))
.body("items.size()", equalTo(10));
given().when().get("/api/projects/" + PROJECT + "/modules/CALLEE07/callers").then().statusCode(200)
.header("X-AC-Total-Count", equalTo("1"))
.header("X-AC-Truncated", equalTo("false"));
}
}

View File

@@ -0,0 +1,265 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 193: a TypeScript frontend (generated web-service client + generated DTO interfaces) and the
* Java backend it calls, as two projects. The frontend's calls appear in {@code rest-endpoints} as
* outbound rows, and {@code COUNTERPART_OF} links call → handler, DTO → class, field → field;
* {@code counterparts?unmatched=true} names the calls nothing serves. Needs the Node sidecar
* ({@code node} + {@code ../ac-parser-typescript/sidecar/node_modules/typescript}); skipped otherwise.
*/
@QuarkusTest
class CounterpartsIT {
private static final String BACKEND = "counterparts-backend";
private static final String FRONTEND = "counterparts-frontend";
@TempDir
static Path backendRoot;
@TempDir
static Path frontendRoot;
static boolean sidecarAvailable() {
boolean node = false;
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
if (Files.isExecutable(Path.of(dir, "node"))) {
node = true;
}
}
}
return node && Files.isDirectory(Path.of("../ac-parser-typescript/sidecar/node_modules/typescript"));
}
@BeforeAll
static void ingestFixtures() {
assumeTrue(sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = backendRoot.resolve("src/main/java/com/example");
write(pkg, "AgstammController.java", """
package com.example;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@Path("/agstamm/ui")
public class AgstammController {
@GET
@Path("/{vermnr}")
public AgstammUseCase getBroker(@PathParam("vermnr") Long vermnr) {
return new AgstammUseCase();
}
@GET
@Path("/search")
public AgstammUseCase searchBroker(Long vermnr) {
return new AgstammUseCase();
}
@POST
public AgstammUseCase saveBroker(AgstammUseCase useCase) {
return useCase;
}
}
""");
write(pkg, "AgstammUseCase.java", """
package com.example;
public class AgstammUseCase {
private String brokerName;
private Broker broker;
}
""");
write(pkg, "Broker.java", """
package com.example;
public class Broker {
private Long vermnr;
private String ebene;
}
""");
write(frontendRoot, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = frontendRoot.resolve("app/src");
write(src.resolve("util"), "requestUtil.ts", """
export function buildPurURL(path: string): string { return 'http://backend' + path }
export function executeGetRequest<R>(url: string): Promise<R> { return Promise.reject(url) }
export function executePostRequest<R, B>(url: string, body: B): Promise<R> { return Promise.reject(url) }
export function encodePathParams(v: unknown): string { return String(v) }
""");
write(src.resolve("generated"), "api-interfaces.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
export interface Broker {
vermnr: number;
ebene: string;
}
export interface AgstammUseCase {
brokerName: string;
broker: Broker;
onlyInFrontend?: string;
}
export interface SvcResult<T> {
result?: T;
}
""");
write(src.resolve("generated"), "endpoints.ts", """
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
// @ts-nocheck
import * as COMMON from 'util/requestUtil';
import * as API from './api-interfaces';
interface Endpoint { baseUrl: string; }
interface GetMethod<R> { get: () => Promise<R>; }
interface GetMethodWithParameters<R, P> { get: (params: P) => Promise<R>; }
interface PostMethod<R, B> { post: (body: B) => Promise<R>; }
export class AgstammControllerEndpoint implements Endpoint {
baseUrl: string = '/agstamm/ui/';
public getBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}${COMMON.encodePathParams(params.vermnr)}`)),
};
public searchBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${COMMON.encodePathParams(params.vermnr)}`)),
};
public saveBroker: PostMethod<API.SvcResult<API.AgstammUseCase>, API.AgstammUseCase> = {
post: (body) => COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body),
};
public ping: GetMethod<string> = {
get: () => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}nothing-serves-this`)),
};
}
""");
write(src.resolve("store"), "agstammSlice.ts", """
import { AgstammControllerEndpoint } from 'generated/endpoints'
import { AgstammUseCase } from 'generated/api-interfaces'
const api = new AgstammControllerEndpoint()
export const saveBrokerToServer = (useCase: AgstammUseCase) => api.saveBroker.post(useCase)
export const loadBroker = (vermnr: number) => api.getBroker.get({ vermnr })
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
List.of(BACKEND)))
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response counterparts(String query) {
return given().when().get("/api/projects/" + FRONTEND + "/counterparts" + query);
}
@Test
void theFrontendsCallsAreOutboundRestEndpoints() {
given().when().get("/api/projects/" + FRONTEND + "/rest-endpoints").then()
.statusCode(200)
.body("size()", equalTo(4))
.body("outbound", everyItem(equalTo(true)))
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.httpMethod", equalTo("POST"))
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.path", equalTo("/agstamm/ui"))
.body("find { it.handler == 'AgstammControllerEndpoint.getBroker' }.path", equalTo("/agstamm/ui/{vermnr}"))
.body("find { it.handler == 'AgstammControllerEndpoint.searchBroker' }.path", equalTo("/agstamm/ui/search"));
}
@Test
void callsAreLinkedToTheHandlersServingThem() {
counterparts("?kind=rest").then()
.statusCode(200)
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartName", equalTo("saveBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartModule", equalTo("com.example.AgstammController"))
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartProject", equalTo(BACKEND))
.body("find { it.name == 'AgstammControllerEndpoint.getBroker' }.counterpartName", equalTo("getBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.searchBroker' }.counterpartName", equalTo("searchBroker"))
.body("find { it.name == 'AgstammControllerEndpoint.ping' }.counterpartName", nullValue());
}
@Test
void unmatchedNamesExactlyWhatNothingServes() {
counterparts("?kind=rest&unmatched=true").then()
.statusCode(200)
.body("name", contains("AgstammControllerEndpoint.ping"))
.body("[0].httpMethod", equalTo("GET"))
.body("[0].path", equalTo("/agstamm/ui/nothing-serves-this"));
}
@Test
void generatedDtosAndTheirFieldsAreLinkedByName() {
counterparts("?kind=dto").then()
.statusCode(200)
.body("find { it.name == 'AgstammUseCase' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
.body("find { it.name == 'Broker' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.name == 'SvcResult' }.counterpartName", nullValue());
counterparts("?kind=field&module=api-interfaces").then()
.statusCode(200)
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartName", equalTo("brokerName"))
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
.body("find { it.name == 'Broker.ebene' }.counterpartModule", equalTo("com.example.Broker"))
.body("find { it.name == 'AgstammUseCase.onlyInFrontend' }.counterpartName", nullValue());
}
@Test
void thePairSurvivesABackendRefresh() {
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
counterparts("?kind=rest&unmatched=true").then()
.statusCode(200)
.body("name", contains("AgstammControllerEndpoint.ping"));
counterparts("?kind=rest&countOnly=true").then()
.statusCode(200)
.header("X-AC-Total-Count", equalTo("4"));
}
@Test
void theSettingIsVisibleAndSelfIsRejected() {
given().when().get("/api/projects/" + FRONTEND).then()
.statusCode(200)
.body("counterparts", contains(BACKEND));
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, null, null, null, null, null, List.of(FRONTEND)))
.when().put("/api/projects/" + FRONTEND).then()
.statusCode(400)
.body("code", equalTo("COUNTERPART_SELF"));
counterparts("?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
}
}

View File

@@ -127,6 +127,30 @@ class DynamicCallOverrideIT {
.body("items.name", hasItem("#TGT"));
}
@Test
void overrideRebuildsTheDerivedModuleEdgesWithoutARefresh() {
// Item 200: CALLS_MODULE (item 68) drives `reaches`; it was only rebuilt by the finalize, so a
// freshly pinned target was unreachable until the next refresh.
resetAll();
Object[] site = unresolvedSite();
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(false));
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD", "TARGET2"));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(true));
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGET2&depth=3")
.then().statusCode(200).body("reachable", equalTo(true));
// a reset of the one site drops the derived edges again, inline
given().when().delete("/api/projects/" + PROJECT + "/dynamic-calls/overrides?originFile=" + site[0] + "&lineNo=" + site[1])
.then().statusCode(200);
given().pathParam("name", "CALLERDYN")
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
.then().statusCode(200).body("reachable", equalTo(false));
}
@Test
void setOverrideResolvesCalleeAndHidesPlaceholder() {
resetAll();

View File

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

View File

@@ -0,0 +1,86 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 203: a caller that invokes an interface at two lines gets one synthetic (CHA) call edge to the
* implementation per line, each carrying the called method — so {@code callees} lists both real lines
* (not one picked at random) and {@code functions/{impl-method}/callers} sees the call made through the
* interface.
*/
@QuarkusTest
class InheritanceCallSitesIT {
private static final String PROJECT = "item203-inheritance-sites";
@TempDir
static Path root;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
Path pkg = root.resolve("src/main/java/com/example");
write(pkg, "Repo.java", "package com.example;\npublic interface Repo { void save(); void load(); }\n");
write(pkg, "RepoImpl.java", "package com.example;\npublic class RepoImpl implements Repo {\n public void save() {}\n public void load() {}\n}\n");
write(pkg, "Service.java", """
package com.example;
public class Service {
private Repo repo;
public void store() {
repo.save();
}
public void read() {
repo.load();
}
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void everyInterfaceCallSiteReachesTheImplementationWithItsLine() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.lineNo", containsInAnyOrder(5, 8))
.body("items.find { it.name == 'com.example.Repo' }.sites.lineNo", containsInAnyOrder(5, 8));
}
@Test
void implementationMethodCallersIncludeCallsThroughTheInterface() {
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/save/callers").then().statusCode(200)
.body("items.name", contains("store"))
.body("items[0].sites.lineNo", contains(5));
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/load/callers").then().statusCode(200)
.body("items.name", contains("read"));
}
@Test
void aSecondRefreshDoesNotMultiplyTheSyntheticEdges() {
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.size()", equalTo(2));
}
}

View File

@@ -0,0 +1,116 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 202: renaming a project moves every node, its dynamic-call overrides and other projects'
* {@code counterparts} references; the old name answers 404 afterwards, the new one answers everything
* the old one did. Refuses a taken name and a blank or unchanged one.
*/
@QuarkusTest
class ProjectRenameIT {
private static final String OLD = "rename-old";
private static final String NEW = "rename-new";
private static final String PEER = "rename-peer";
@TempDir
static Path root;
@TempDir
static Path peerRoot;
@jakarta.inject.Inject
org.neo4j.driver.Driver driver;
@BeforeAll
static void setUp() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write(root, "CALLER.nat", """
DEFINE DATA
LOCAL
01 #TGT (A8)
END-DEFINE
CALLNAT 'CALLEE' #TGT
CALLNAT #TGT
END
""");
write(root, "CALLEE.nat", """
DEFINE DATA
PARAMETER
01 #P (A8)
END-DEFINE
END
""");
write(peerRoot, "Peer.java", "package p; public class Peer {}\n");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
.when().post("/api/projects/" + OLD).then().statusCode(201);
given().when().post("/api/projects/" + OLD + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, peerRoot.toString(), null, "java", null, null, List.of(OLD)))
.when().post("/api/projects/" + PEER).then().statusCode(201);
// a manual override that must follow the rename
String file = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
.extract().path("[0].originFile");
Integer line = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
.extract().path("[0].lineNo");
given().contentType("application/json")
.body(Map.of("originFile", file, "lineNo", line, "targets", List.of("CALLEE"), "variable", "#TGT"))
.when().post("/api/projects/" + OLD + "/dynamic-calls/overrides").then().statusCode(200);
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void renameMovesNodesOverridesAndCounterpartReferences() {
given().contentType("application/json").body(Map.of("newName", " "))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400).body("code", equalTo("INVALID_REQUEST"));
given().contentType("application/json").body(Map.of("newName", OLD))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400);
given().contentType("application/json").body(Map.of("newName", PEER))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(409).body("code", equalTo("PROJECT_EXISTS"));
given().contentType("application/json").body(Map.of("newName", NEW))
.when().post("/api/projects/nope/rename").then().statusCode(404);
given().contentType("application/json").body(Map.of("newName", NEW))
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(200)
.body("name", equalTo(NEW)).body("language", equalTo("natural")).body("ingest.filesPersisted", equalTo(2));
given().when().get("/api/projects/" + OLD).then().statusCode(404);
given().when().get("/api/projects/" + NEW + "/modules").then().statusCode(200).body("name", containsInAnyOrder("CALLER", "CALLEE"));
given().when().get("/api/projects/" + NEW + "/modules/CALLER/callees").then().statusCode(200)
.body("items.name", hasItem("CALLEE"));
given().when().get("/api/projects/" + NEW + "/dynamic-calls/overrides").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].targets", contains("CALLEE"));
given().when().get("/api/projects/" + PEER).then().statusCode(200).body("counterparts", contains(NEW));
given().when().get("/api/projects").then().statusCode(200)
.body("name", hasItem(NEW)).body("name", not(hasItem(OLD)));
// a full delete takes the project's overrides with it (found on upms2: 10 orphaned override nodes)
given().when().delete("/api/projects/" + NEW).then().statusCode(204);
try (var session = driver.session()) {
long orphans = session.run("MATCH (o:DynamicCallOverride {project: $p}) RETURN count(o) AS c", Map.of("p", NEW))
.single().get("c").asLong();
org.junit.jupiter.api.Assertions.assertEquals(0, orphans, "override nodes of a deleted project");
}
}
}

View File

@@ -0,0 +1,113 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 198: an edge a re-parse no longer emits is reaped — for TypeScript and Java, not only Natural.
* A component that imported and called {@code B} is edited to call {@code C}: after the deep refresh
* {@code callees} lists only {@code C}, the old placeholder is gone, and an untouched file keeps every
* edge. The TypeScript half needs the Node sidecar (skipped otherwise); the Java half always runs.
*/
@QuarkusTest
class StaleParsedEdgeReapIT {
private static final String TS = "reap-ts";
private static final String JAVA = "reap-java";
@TempDir
static Path tsRoot;
@TempDir
static Path javaRoot;
@BeforeAll
static void setUp() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static void create(String project, Path root, String language) {
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, language, null, null))
.when().post("/api/projects/" + project).then().statusCode(201);
}
private static void refresh(String project) {
given().when().post("/api/projects/" + project + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
}
private static io.restassured.response.Response callees(String project, String module) {
return given().when().get("/api/projects/" + project + "/modules/" + module + "/callees");
}
@Test
void aRetargetedTypeScriptCallLosesItsOldTargetAndPlaceholder() {
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
write(tsRoot, "package.json", "{\"name\": \"fe\", \"workspaces\": [\"app\"]}");
Path src = tsRoot.resolve("app/src");
write(src, "b.ts", "export function b() { return 1 }\n");
write(src, "c.ts", "export function c() { return 2 }\n");
write(src, "other.ts", "import { b } from 'b'\nexport function other() { return b() }\n");
write(src, "page.ts", "import { b } from 'b'\nexport function page() { return b() }\n");
create(TS, tsRoot, "typescript");
refresh(TS);
callees(TS, "page").then().statusCode(200).body("items.name", hasItem("app/src/b"));
// the second deep refresh is the first one that can reap: the first one stamped the edges
write(src, "page.ts", "import { c } from 'c'\nexport function page() { return c() }\n");
refresh(TS);
callees(TS, "page").then().statusCode(200)
.body("items.name", hasItem("app/src/c"))
.body("items.name", not(hasItem("app/src/b")));
callees(TS, "other").then().statusCode(200).body("items.name", hasItem("app/src/b"));
// a fresh, never-referenced module name becomes a placeholder; once retargeted away it must not survive
write(src, "page.ts", "import { d } from './missing/d'\nexport function page() { return d() }\n");
refresh(TS);
given().when().get("/api/projects/" + TS + "/search/identifier?name=app/src/missing/d&type=MODULE").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].sourceFile", equalTo(""));
write(src, "page.ts", "import { c } from 'c'\nexport function page() { return c() }\n");
refresh(TS);
given().when().get("/api/projects/" + TS + "/search/identifier?name=app/src/missing/d&type=MODULE").then().statusCode(200)
.body("size()", equalTo(0));
callees(TS, "page").then().statusCode(200).body("items.name", hasItem("app/src/c"));
}
@Test
void aRetargetedJavaCallLosesItsOldTarget() {
Path pkg = javaRoot.resolve("src/main/java/com/example");
write(pkg, "B.java", "package com.example;\npublic class B { public void m() {} }\n");
write(pkg, "C.java", "package com.example;\npublic class C { public void m() {} }\n");
write(pkg, "Other.java", "package com.example;\npublic class Other { void run(B b) { b.m(); } }\n");
write(pkg, "Caller.java", "package com.example;\npublic class Caller { void run(B b) { b.m(); } }\n");
create(JAVA, javaRoot, "java");
refresh(JAVA);
callees(JAVA, "com.example.Caller").then().statusCode(200).body("items.name", hasItem("com.example.B"));
write(pkg, "Caller.java", "package com.example;\npublic class Caller { void run(C c) { c.m(); } }\n");
refresh(JAVA);
callees(JAVA, "com.example.Caller").then().statusCode(200)
.body("items.name", hasItem("com.example.C"))
.body("items.name", not(hasItem("com.example.B")));
callees(JAVA, "com.example.Other").then().statusCode(200).body("items.name", hasItem("com.example.B"));
}
}

View File

@@ -0,0 +1,243 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
/**
* Item 194: a React/Redux-Toolkit frontend with one store, two slices (one mounted under a key that
* differs from its slice name) and consumers reading through {@code useAppSelector}, a wrapper hook
* and {@code getState()}. After a deep refresh the store endpoints answer, the consumer reads are
* resolved onto the slice's own field nodes across files, and a dispatched action creator shows up
* as a call to the reducer. Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
*/
@QuarkusTest
class StoreIT {
private static final String PROJECT = "store-frontend";
@TempDir
static Path root;
@BeforeAll
static void ingestFixture() {
org.junit.jupiter.api.Assumptions.assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write(root, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = root.resolve("app/src");
write(src.resolve("store"), "rtk.ts", """
// stand-in for @reduxjs/toolkit + react-redux (no node_modules in the fixture)
export function createSlice(options: any): any { return options }
export function configureStore(options: any): any { return options }
export function createAsyncThunk(prefix: string, fn: any): any { return fn }
export function useSelector<R>(selector: (state: any) => R): R { return selector({}) }
""");
write(src.resolve("store/slices"), "keytableSlice.ts", """
import { createSlice, createAsyncThunk } from 'store/rtk'
export interface KeyTableState {
requestStatus: string
keyTableUseCaseSvcResult?: { result?: { tableId: string; purMode?: string } }
sucheStatus: string
}
const initialState: KeyTableState = { requestStatus: 'idle', sucheStatus: 'NONE' }
const sliceName = 'schluesseltabelle'
export const sucheByServer = createAsyncThunk(`${sliceName}/suche`, async (tableId: string) => ({ result: { tableId } }))
const keytableSlice = createSlice({
name: sliceName,
initialState,
reducers: {
updateKeyTableUseCaseSvcResult(state, action) {
if (state.keyTableUseCaseSvcResult?.result?.tableId !== action.payload?.result?.tableId) {
state.sucheStatus = 'NONE'
}
state.keyTableUseCaseSvcResult = action.payload
},
},
extraReducers: (builder) => {
builder.addCase(sucheByServer.fulfilled, (state) => {
state.sucheStatus = 'SUCCESS'
})
},
})
export const { updateKeyTableUseCaseSvcResult } = keytableSlice.actions
export const schluesseltabelleReducer = keytableSlice.reducer
""");
write(src.resolve("store/slices"), "generalAgreementSlice.ts", """
import { createSlice } from 'store/rtk'
interface GeneralAgreementState { loading: boolean }
const generalAgreementSlice = createSlice({
name: 'generalAgreement',
initialState: { loading: false } as GeneralAgreementState,
reducers: {
setLoading(state, action) {
state.loading = action.payload
},
},
})
export const { setLoading } = generalAgreementSlice.actions
export const gruppenprovisionReducer = generalAgreementSlice.reducer
""");
write(src.resolve("store"), "store.ts", """
import { configureStore } from 'store/rtk'
import { schluesseltabelleReducer } from 'store/slices/keytableSlice'
import { gruppenprovisionReducer } from 'store/slices/generalAgreementSlice'
export const store = configureStore({
reducer: {
schluesseltabelle: schluesseltabelleReducer,
gruppenprovision: gruppenprovisionReducer,
},
})
""");
write(src.resolve("store"), "redux-types.ts", """
import { useSelector } from 'store/rtk'
export const useAppSelector = useSelector
""");
write(src.resolve("store/hooks"), "useKeyTable.ts", """
import { useAppSelector } from 'store/redux-types'
export const useSchluesseltabelleSelector = <R>(selector: (useCase: any) => R): R => {
return useAppSelector((state) => selector(state.schluesseltabelle.keyTableUseCaseSvcResult))
}
""");
write(src.resolve("components"), "KeyTablePage.tsx", """
import { useAppSelector } from 'store/redux-types'
import { useSchluesseltabelleSelector } from 'store/hooks/useKeyTable'
import { updateKeyTableUseCaseSvcResult } from 'store/slices/keytableSlice'
import { setLoading } from 'store/slices/generalAgreementSlice'
import { store } from 'store/store'
export function KeyTablePage() {
const requestStatus = useAppSelector((state) => state.schluesseltabelle.requestStatus)
const purMode = useSchluesseltabelleSelector((useCase) => useCase?.result?.purMode)
const { loading } = useAppSelector((state) => state.gruppenprovision)
const reset = () => {
if (store.getState().schluesseltabelle.sucheStatus === 'NONE') {
store.dispatch(updateKeyTableUseCaseSvcResult(undefined))
store.dispatch(setLoading(false))
}
}
return <div onClick={reset}>{requestStatus} {purMode} {String(loading)}</div>
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "typescript", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response get(String path) {
return given().when().get("/api/projects/" + PROJECT + path);
}
@Test
void storeListsTheSlicesUnderTheirReducerKeys() {
get("/store").then()
.statusCode(200)
.body("slice", contains("gruppenprovision", "schluesseltabelle"))
.body("find { it.slice == 'gruppenprovision' }.sliceName", equalTo("generalAgreement"))
.body("find { it.slice == 'gruppenprovision' }.module", equalTo("app/src/store/slices/generalAgreementSlice"))
.body("find { it.slice == 'schluesseltabelle' }.stateType", equalTo("KeyTableState"))
.body("find { it.slice == 'schluesseltabelle' }.fields.name", contains("requestStatus", "keyTableUseCaseSvcResult", "sucheStatus"))
.body("find { it.slice == 'schluesseltabelle' }.fields.find { it.name == 'keyTableUseCaseSvcResult' }.optional", equalTo(true))
.body("find { it.slice == 'schluesseltabelle' }.reducers", equalTo(2))
// reducer read: tableId; consumer reads: requestStatus, purMode (via the wrapper), sucheStatus
// (getState), plus the wrapper hook's own inner selector
.body("find { it.slice == 'schluesseltabelle' }.reads", equalTo(5))
// reducer writes: sucheStatus x2, keyTableUseCaseSvcResult
.body("find { it.slice == 'schluesseltabelle' }.writes", equalTo(3));
get("/store?slice=generalAgreement").then().statusCode(200).body("size()", equalTo(1)).body("[0].slice", equalTo("gruppenprovision"));
get("/store?slice=nope").then().statusCode(200).body("size()", equalTo(0));
}
@Test
void consumerReadsResolveOntoTheSliceFieldsAcrossFiles() {
get("/store/schluesseltabelle/accesses?mode=reads").then()
.statusCode(200)
.body("size()", equalTo(5))
.body("mode", everyItem(equalTo("READS")))
.body("findAll { it.via == 'useAppSelector' }.function", containsInAnyOrder("KeyTablePage", "useSchluesseltabelleSelector"))
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.field", equalTo("requestStatus"))
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.module", equalTo("app/src/components/KeyTablePage"))
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.functionKind", equalTo("component"))
.body("find { it.function == 'useSchluesseltabelleSelector' }.functionKind", equalTo("hook"))
.body("find { it.function == 'useSchluesseltabelleSelector' }.path", equalTo("keyTableUseCaseSvcResult"))
.body("find { it.via == 'useSchluesseltabelleSelector' }.path", equalTo("keyTableUseCaseSvcResult.result.purMode"))
.body("find { it.via == 'getState' }.field", equalTo("sucheStatus"))
.body("find { it.via == 'getState' }.lineNo", equalTo(12))
.body("find { it.via == 'reducer' }.function", equalTo("schluesseltabelle/updateKeyTableUseCaseSvcResult"))
.body("find { it.via == 'reducer' }.path", equalTo("keyTableUseCaseSvcResult.result.tableId"));
get("/store/schluesseltabelle/accesses?mode=writes").then()
.statusCode(200)
.body("size()", equalTo(3))
.body("functionKind", everyItem(equalTo("reducer")))
.body("find { it.function == 'schluesseltabelle/suche/fulfilled' }.field", equalTo("sucheStatus"));
get("/store/schluesseltabelle/accesses?field=sucheStatus&countOnly=true").then().statusCode(200).body("count", equalTo(3));
get("/store/schluesseltabelle/accesses?module=KeyTablePage").then().statusCode(200).body("size()", equalTo(3));
get("/store/schluesseltabelle/accesses?mode=bogus").then().statusCode(400).body("code", equalTo("MODE_UNSUPPORTED"));
get("/store/nope/accesses").then().statusCode(404).body("code", equalTo("SLICE_NOT_FOUND"));
// the destructured whole-slice selector reads gruppenprovision.loading
get("/store/generalAgreement/accesses?mode=reads").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].field", equalTo("loading")).body("[0].function", equalTo("KeyTablePage"));
}
@Test
void storeFieldsAnswerTheGenericVariableEndpointsToo() {
get("/variables/schluesseltabelle.sucheStatus/writes").then()
.statusCode(200)
.body("function", containsInAnyOrder("schluesseltabelle/updateKeyTableUseCaseSvcResult", "schluesseltabelle/suche/fulfilled"));
get("/variables/schluesseltabelle.requestStatus/reads").then()
.statusCode(200)
.body("size()", equalTo(1))
.body("[0].function", equalTo("KeyTablePage"));
}
@Test
void dispatchedActionsAreCallsToTheSliceModulesAndReducersAreFunctions() {
// the dispatched action creators resolve to the slice modules (the CALLS edge carries
// calleeMethod = the reducer's action type; item 197 tracks exposing that at function level)
get("/modules/KeyTablePage/callees").then()
.statusCode(200)
.body("items.name", hasItems("app/src/store/slices/keytableSlice", "app/src/store/slices/generalAgreementSlice"))
.body("items.find { it.name == 'app/src/store/slices/keytableSlice' }.unresolved", equalTo(false));
get("/modules/keytableSlice/functions").then()
.statusCode(200)
.body("find { it.name == 'schluesseltabelle/suche/fulfilled' }.kind", equalTo("reducer"))
.body("find { it.name == 'sucheByServer' }.kind", equalTo("thunk"));
// no placeholder survives: every consumer read was redirected onto the slice's own nodes
get("/search/identifier?name=schluesseltabelle.requestStatus").then().statusCode(200)
.body("findAll { it.sourceFile == '' }.size()", equalTo(0));
}
}

View File

@@ -0,0 +1,196 @@
package com.agenticcode.codeserver.api;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Item 196: a frontend with an MUI theme, components styling through sx/style/styled with theme
* tokens, constants and hard-coded literals, and a CSS file. After a deep refresh the theme tokens
* carry use counts, token reads resolve across files, undeclared tokens are listed as such, and the
* style inventory answers. Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
*/
@QuarkusTest
class StylesIT {
private static final String PROJECT = "styles-frontend";
@TempDir
static Path root;
@BeforeAll
static void ingestFixture() {
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
write(root, "package.json", """
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
""");
Path src = root.resolve("app/src");
write(src, "mui.ts", """
// stand-in for @mui/material (no node_modules in the fixture)
export function createTheme(options: any): any { return options }
export function styled(base: any, options?: any): any { return (fn: any) => base }
export function useTheme(): any { return {} }
export const Box: any = 'Box'
""");
write(src, "theme.ts", """
import { createTheme } from 'mui'
export const PRIMARY = '#005CA9'
const PRIMARY_DARK = '#0054A2'
export const UNUSED_GRAY = '#808080'
export const theme = createTheme({
palette: {
primary: { main: PRIMARY, dark: PRIMARY_DARK },
background: { paper: '#FCFCFD' },
},
shape: { borderRadius: 4 },
})
""");
// item 199: a second theme file declares the same primary.main (and only that) -> a read of it
// resolves onto both declarations, and neither is reported unused
write(src, "darkTheme.ts", """
import { createTheme } from 'mui'
import { PRIMARY } from './theme'
export const darkTheme = createTheme({
palette: { mode: 'dark', primary: { main: PRIMARY } },
})
""");
write(src.resolve("components"), "Panel.tsx", """
import { styled, Box } from 'mui'
export const Panel = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
color: theme.palette.primary.dark,
border: '1px solid #D2D2D2',
}))
""");
write(src.resolve("components"), "Page.tsx", """
import { Box, useTheme } from 'mui'
import { PRIMARY, theme as appTheme } from 'theme'
import { Panel } from './Panel'
export function Page() {
const theme = useTheme()
return (
<Panel sx={{ mt: 2, color: PRIMARY, borderColor: PRIMARY, '&:hover': { background: theme.palette.background.paper } }}>
<Box style={{ color: appTheme.palette.primary.main, height: '17px' }} borderColor={theme.palette.grey['200']}>x</Box>
<Box sx={{ width: '100%' }} />
</Panel>
)
}
""");
write(src, "index.css", """
body {
margin: 0;
color: #333333;
}
@font-face {
font-family: OpenSans;
src: url("/fonts/OpenSans.ttf");
}
""");
given().contentType("application/json")
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "typescript", null, null))
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200)
.body("failed", empty());
}
private static void write(Path dir, String fileName, String content) {
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private static io.restassured.response.Response get(String path) {
return given().when().get("/api/projects/" + PROJECT + path);
}
@Test
void themeTokensCarryValuesAndUseCounts() {
get("/theme").then()
.statusCode(200)
.body("find { it.token == 'palette.primary.dark' }.value", equalTo("#0054A2"))
.body("find { it.token == 'palette.primary.dark' }.constant", equalTo("PRIMARY_DARK"))
.body("find { it.token == 'palette.primary.dark' }.kind", equalTo("path"))
.body("find { it.token == 'palette.primary.dark' }.declared", equalTo(true))
.body("find { it.token == 'palette.primary.dark' }.module", equalTo("app/src/theme"))
.body("find { it.token == 'palette.primary.dark' }.uses", equalTo(1))
.body("find { it.token == 'palette.primary.main' }.uses", equalTo(1))
.body("find { it.token == 'palette.background.paper' }.uses", equalTo(1))
.body("find { it.token == 'PRIMARY' }.kind", equalTo("constant"))
.body("find { it.token == 'PRIMARY' }.uses", equalTo(1))
.body("find { it.token == 'shape.borderRadius' }.value", equalTo("4"))
.body("find { it.token == 'shape.borderRadius' }.uses", equalTo(0))
// read by the code, declared by no theme: MUI defaults / the spacing function
.body("find { it.token == 'palette.grey.200' }.declared", equalTo(false))
.body("find { it.token == 'palette.grey.200' }.uses", equalTo(1))
.body("find { it.token == 'spacing' }.declared", equalTo(false));
get("/theme?unused=true").then().statusCode(200)
.body("token", containsInAnyOrder("shape.borderRadius", "UNUSED_GRAY", "palette.mode"));
// item 199: two declaring files, one row each, both counting the single read; no placeholder left
get("/theme").then().statusCode(200)
.body("findAll { it.token == 'palette.primary.main' }.module", containsInAnyOrder("app/src/theme", "app/src/darkTheme"))
.body("findAll { it.token == 'palette.primary.main' }.uses", everyItem(equalTo(1)))
.body("findAll { it.token == 'palette.primary.main' }.declared", everyItem(equalTo(true)));
get("/theme/palette.primary.main/usages").then().statusCode(200)
.body("size()", equalTo(1)).body("[0].function", equalTo("Page"));
}
@Test
void tokenUsagesNameTheBlockAndTheProperty() {
get("/theme/palette.primary.dark/usages").then()
.statusCode(200)
.body("size()", equalTo(1))
.body("[0].function", equalTo("Panel"))
.body("[0].styleKind", equalTo("styled"))
.body("[0].element", equalTo("Box"))
.body("[0].property", equalTo("color"))
.body("[0].module", equalTo("app/src/components/Panel"))
.body("[0].lineNo", equalTo(4));
get("/theme/palette.grey.200/usages").then().statusCode(200)
.body("[0].function", equalTo("Page"))
.body("[0].context", equalTo("borderColor"))
.body("[0].styleKind", nullValue());
// item 199: two reads of one token on one line are one usage listing both keys
get("/theme/PRIMARY/usages").then().statusCode(200)
.body("size()", equalTo(1))
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color,borderColor")).body("[0].element", equalTo("Panel"));
get("/theme/nope/usages").then().statusCode(404).body("code", equalTo("TOKEN_NOT_FOUND"));
}
@Test
void styleInventoryListsBlocksLiteralsAndCssRules() {
get("/styles").then()
.statusCode(200)
.body("size()", equalTo(6))
.body("findAll { it.styleKind == 'sx' }.size()", equalTo(2))
.body("find { it.styleKind == 'styled' }.function", equalTo("Panel"))
.body("find { it.styleKind == 'styled' }.properties", equalTo("padding,color,border"))
.body("find { it.styleKind == 'styled' }.literals", equalTo("1px,#D2D2D2"))
.body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark"))
.body("find { it.styleKind == 'style' }.literals", equalTo("17px"))
.body("find { it.styleKind == 'style' }.tokens", contains("palette.primary.main"))
.body("find { it.name == 'Page.sx@8:12' }.properties", equalTo("mt,color,borderColor,&:hover.background"))
.body("find { it.name == 'body@1' }.styleKind", equalTo("css"))
.body("find { it.name == 'body@1' }.literals", equalTo("#333333"))
.body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src"));
get("/styles?withLiterals=true").then().statusCode(200).body("size()", equalTo(4));
get("/styles?kind=css&countOnly=true").then().statusCode(200).body("count", equalTo(2));
get("/styles?module=Page").then().statusCode(200).body("size()", equalTo(3));
get("/styles?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
}
}

View File

@@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for {@link SourceFiles#classify} — the extension → (language, node kind) mapping that
@@ -78,6 +80,32 @@ class SourceFilesTest {
SourceFiles.classify(Path.of("WSUBPX0S.nat"), "natjav"));
}
@Test
void typeScriptAndCssAreModulesButDeclarationFilesAreNot() {
// item 192
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
SourceFiles.classify(Path.of("pur-r-vstamm/src/store/slices/agstammSlice.ts")));
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
SourceFiles.classify(Path.of("pur-ui/src/App.tsx")));
assertEquals(new SourceFiles.Kind(SourceFiles.Language.CSS, NodeType.MODULE),
SourceFiles.classify(Path.of("pur-ui/src/index.css")));
assertNull(SourceFiles.classify(Path.of("pur-ui/src/vite-env.d.ts")), "type declarations carry no code");
assertNull(SourceFiles.classify(Path.of("pur-ui/src/routeTree.gen.js")));
}
@Test
void onlyATypeScriptProjectIngestsTypeScript() {
// item 192: a Java project with a bundled web UI (ac-ui) must not start parsing it.
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "typescript"));
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.CSS, "TypeScript"));
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "java"));
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.CSS, null));
// Java and Natural stay language-agnostic: `ac` carries .cpy Natural fixtures next to its Java.
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.NATURAL, "java"));
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, "typescript"));
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, null));
}
@Test
void unknownExtensionsAreNotIngestible() {
assertNull(SourceFiles.classify(Path.of("WNAUTD0S.meta")));

View File

@@ -0,0 +1,19 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 195: one binding site — a component/hook ({@code function}) reading ({@code mode=READS}) or
* writing ({@code WRITES}) the DTO field {@code dto.field} through a generated {@code Fields} path
* object. {@code path} is the dotted path from {@code rootDto} ({@code broker.ebene}); {@code kind}
* is {@code field} (a scalar leaf) or {@code prefix} (a sub-object handed on); {@code partial} means
* the path starts at a prop or local, so only its tail is known; {@code component}/{@code attribute}
* say where the expression was passed ({@code SmartInput} / {@code field}). The {@code counterpart*}
* columns are the field's {@code COUNTERPART_OF} twin in the backend project (item 193), null when unlinked.
*/
public record Binding(String mode, String dto, String field, @Nullable String path, @Nullable String rootDto,
@Nullable String kind, boolean partial, @Nullable String component, @Nullable String attribute,
String function, String functionType, @Nullable String functionKind, @Nullable String module,
String sourceFile, @Nullable Integer lineNo, @Nullable String counterpartProject,
@Nullable String counterpartModule, @Nullable String counterpartField) {
}

View File

@@ -6,6 +6,17 @@ import java.util.List;
* Wrapper returned by the {@code /callers} and {@code /callees} endpoints: a deduplicated
* {@code sourceFiles} index plus aggregated call references that use integer indices into
* that list instead of repeating the full path on every entry.
*
* @param total item 181: how many references exist before paging
* @param truncated item 181: {@code true} when {@code items} is a page that stops before {@code total}
* — the default {@code limit=50} used to cut a fan-out silently
*/
public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) {
public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items, int total, boolean truncated) {
/**
* An unpaged response: everything is in {@code items}.
*/
public CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) {
this(sourceFiles, items, items.size(), false);
}
}

View File

@@ -0,0 +1,19 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 193: one row of {@code GET /projects/{p}/counterparts} — a node of this project that has (or,
* with {@code unmatched=true}, lacks) a {@code COUNTERPART_OF} twin in a counterpart project.
*
* @param kind {@code rest} (an outbound web-service call), {@code dto} (a generated data
* structure) or {@code field} (a property of one)
* @param httpMethod set for {@code rest} rows, like {@code path}
* @param counterpartProject the twin's project; the {@code counterpart*} fields are all {@code null} when unmatched
*/
public record Counterpart(String kind, String name, String module, String sourceFile, int startLine,
@Nullable String httpMethod, @Nullable String path,
@Nullable String counterpartProject, @Nullable String counterpartName,
@Nullable String counterpartModule, @Nullable String counterpartSourceFile,
@Nullable Integer counterpartStartLine) {
}

View File

@@ -157,6 +157,26 @@ public final class CypherQueries {
* {@code (project, ownerModule)} would go further still, but has to earn its write-side cost
* against <em>this</em> shape — measure before adding it.
*/
/**
* Item 198: after a deep re-parse of a file, deletes every edge from that file's nodes that the
* fresh parse did not re-emit — the edge carries the {@code ingestGen} the merge stamped on it
* ({@link #mergeEdgesBatch}), and an edge the parse re-emitted was re-stamped through the MERGE
* key. Language-agnostic: the three Natural reaps above cover their statement kinds, but a Java
* or TypeScript call/import/type edge whose target the new parse names differently (a renamed
* class, a {@code dist}→{@code src} mapping fix) lingered forever next to the fresh one, keeping
* its placeholder alive. Finalize-built edges never carry a stamp unless a resolver copied it from
* a parser edge, and those are rebuilt by the deep finalize that always follows. Runs after
* {@code merge-edges}; deep only, because a Tier-1 pass emits far fewer edges than a deep one.
* Keyed on the item-160 {@code (sourceFile, ownerModule)} pairs like the reaps above.
*/
public static final String DELETE_STALE_PARSED_EDGES = """
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f})-[r]->()
WHERE src.ownerModule IN p.os
AND r.ingestGen IS NOT NULL AND r.ingestGen <> $ingestGen
DELETE r
""";
public static final String DELETE_STALE_PLACEHOLDER_NODES = """
MATCH (n:AstNode {project: $project, sourceFile: ""})
WHERE n.ownerModule IN $owners
@@ -181,7 +201,7 @@ public final class CypherQueries {
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f})-[r:READS|WRITES]->(fld:AstNode)
WHERE src.ownerModule IN p.os
AND (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
AND (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT' OR fld.store = 'true' OR fld.binding = 'true')
AND fld.sourceFile <> "" AND fld.sourceFile <> p.f
DELETE r
""";
@@ -836,8 +856,9 @@ public final class CypherQueries {
* through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation).
*
* <p>Synthetic edges are tagged {@code resolvedVia: 'INHERITANCE'} so they {@code MERGE}
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; the call-site
* {@code lineNo} is copied from the originating interface/base call. Runs after placeholder
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; since item 203 there is
* one per originating interface/base call site ({@code lineNo}, {@code originFile}), carrying its
* {@code callKind}, {@code calleeMethod} and {@code callerFn}. Runs after placeholder
* resolution and dataflow so both the {@code CALLS} and {@code IMPLEMENTS}/{@code EXTENDS} edges
* already point at real modules, and dataflow is not routed through the synthetic edges.
*
@@ -854,8 +875,14 @@ public final class CypherQueries {
AND ( ()-[:IMPLEMENTS]->(base) OR ()-[:EXTENDS]->(base) )
MATCH (impl:AstNode {type: 'MODULE', project: $project})-[:IMPLEMENTS|EXTENDS*1..]->(base)
WHERE impl <> caller AND impl.sourceFile <> ""
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE'}]->(impl)
ON CREATE SET s.lineNo = r.lineNo, s.callKind = r.callKind
// Item 203: one synthetic edge per originating call site, keyed like the parsed edge it
// derives from. Keyed on the pair alone, the line was whichever base call the MERGE met
// first (95 in one run, 97 in the next for the same pair) and calleeMethod/callerFn were
// lost, so function-level callers could not see a call made through the interface.
// `args` is deliberately not copied: dataflow is not routed through synthetic edges.
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE', lineNo: r.lineNo,
originFile: coalesce(r.originFile, caller.sourceFile)}]->(impl)
SET s.callKind = r.callKind, s.calleeMethod = r.calleeMethod, s.callerFn = r.callerFn
""";
/**
@@ -1462,19 +1489,39 @@ public final class CypherQueries {
""";
/**
* Item 52: function-level callers — the {@code FUNCTION} nodes that {@code CALLS} the subroutine/method
* {@code $function} defined in module {@code $name} (the intra-module {@code PERFORM} sites, and any
* cross-class Java method callers). Complements module-granularity {@link #callers(String)}. Shaped
* like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos) so it reuses the same DTO.
* {@code $function} defined in module {@code $name}. Complements module-granularity
* {@link #callers(String)}. Shaped like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos)
* so it reuses the same DTO. Two branches (item 197): the direct {@code FUNCTION -CALLS-> FUNCTION}
* edges (Natural {@code PERFORM}, same-class Java calls), and the cross-module calls, which the Java
* and TypeScript parsers write as a {@code MODULE -CALLS-> MODULE} edge carrying {@code callerFn} and
* {@code calleeMethod}, joined on those two names back to the calling {@code FUNCTION}. Methods are
* matched by name, so overloads over-approximate (like {@link #LINK_ARGS_TO_PARAMS_JAVA_CROSS}); a
* call whose {@code callerFn} is not a FUNCTION of the calling module (top-level code) has no row here
* and shows only in the module-level {@code callers}.
*/
public static final String FUNCTION_CALLERS = """
MATCH (m:MODULE {name: $name, project: $project})
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
MATCH (m)-[:CONTAINS*0..1]->(callee:FUNCTION {name: $function})
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
coalesce(r.callKind, 'PERFORM') AS edgeKind,
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
CALL {
WITH callee
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
coalesce(r.callKind, 'PERFORM') AS edgeKind,
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
UNION
WITH m, callee
MATCH (callerModule:MODULE {project: $project})-[r:CALLS]->(m)
WHERE r.callerFn IS NOT NULL AND r.calleeMethod = callee.name
AND coalesce(r.manualHidden, false) = false
MATCH (callerModule)-[:CONTAINS]->(caller:FUNCTION {name: r.callerFn})
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
coalesce(r.callKind, 'METHOD_CALL') AS edgeKind,
collect(DISTINCT {lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, callerModule.sourceFile),
viaCopycode: null, includedAt: null, includePath: null}) AS sites
}
RETURN name, type, sourceFile, edgeKind, sites
ORDER BY name
""";
@@ -1529,12 +1576,15 @@ public final class CypherQueries {
] AS memberRoots
UNWIND (CASE WHEN size(memberRoots) > 0 THEN memberRoots ELSE canon END) AS s
MATCH (s)-[:CONTAINS*1..]->(f:AstNode)
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
// FIELD: a TypeScript interface property (item 193); items 195 counts its bindings
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE', 'FIELD']
MATCH (p:AstNode)-[:CONTAINS]->(f)
WHERE p = s OR (s)-[:CONTAINS*1..]->(p)
RETURN DISTINCT f.name AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
RETURN DISTINCT coalesce(f.field, f.name) AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
f.startLine AS startLine, f.endLine AS endLine, f.scope AS scope,
f.sourceFile AS sourceFile
f.sourceFile AS sourceFile,
size([(f)<-[r:READS]-() WHERE r.via = 'binding' | 1]) AS boundReads,
size([(f)<-[r:WRITES]-() WHERE r.via = 'binding' | 1]) AS boundWrites
""";
/**
* Deletes placeholder nodes ({@code sourceFile = ""}, type {@code MODULE}/
@@ -1940,6 +1990,20 @@ public final class CypherQueries {
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args, r2.resolvedBy = 'manual'
SET dyn.manualHidden = true
""";
/**
* Item 200: the modules from whose files a dynamic call at {@code (originFile, lineNo)} is made —
* the same fan-out as {@link #APPLY_MANUAL_DYNAMIC_CALLNAT} (every includer of a copycode site).
* With both null: every module that currently owns a manual edge. The override apply/reset then
* rebuilds exactly these modules' derived {@code CALLS_MODULE} edges so {@code reaches} and
* {@code field-flow} see the pinned target without a refresh.
*/
public static final String CALLER_MODULES_AT_SITE = """
MATCH (m:AstNode {type: 'MODULE', project: $project})-[:CONTAINS*0..1]->(src:AstNode)-[r:CALLS {callKind: 'CALLNAT_DYNAMIC'}]->()
WHERE ($originFile IS NULL AND r.resolvedBy = 'manual')
OR ($originFile IS NOT NULL AND coalesce(r.originFile, src.sourceFile) = $originFile
AND ($lineNo IS NULL OR r.lineNo = $lineNo))
RETURN collect(DISTINCT m.name) AS names
""";
/**
* Upserts one manual override node, keyed by {@code (project, originFile, lineNo)}.
*/
@@ -2734,7 +2798,8 @@ public final class CypherQueries {
""";
public static final String CREATE_PROJECT = """
CREATE (p:Project {name: $name, description: $description, root: $root, excludeDirs: $excludeDirs,
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir})
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir,
counterparts: $counterparts})
""";
/**
* Updates a project. {@code null} parameters leave the corresponding property unchanged
@@ -2748,12 +2813,14 @@ public final class CypherQueries {
p.excludeDirs = COALESCE($excludeDirs, p.excludeDirs),
p.language = COALESCE($language, p.language),
p.generatedDir = COALESCE($generatedDir, p.generatedDir),
p.userExitDir = COALESCE($userExitDir, p.userExitDir)
p.userExitDir = COALESCE($userExitDir, p.userExitDir),
p.counterparts = COALESCE($counterparts, p.counterparts)
""";
public static final String GET_PROJECT = """
MATCH (p:Project {name: $name})
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
p.counterparts AS counterparts,
// Item 126: what the last whole-root ingest did. Null on a project last ingested
// before this was recorded — "never measured", which is not the same as zero.
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
@@ -2770,6 +2837,38 @@ public final class CypherQueries {
* graph. Split from the node delete so {@code recreate} can reuse the latter alone. Not batched: it
* is a single node.
*/
/**
* Item 202: renames a project. The name is the key on every {@code AstNode} ({@code project}), on the
* {@code DynamicCallOverride}s and on the {@code Project} shell, and other shells may list it in
* {@code counterparts}; edges carry no project. The node rewrite is batched like
* {@link #DELETE_PROJECT_NODES} (implicit transaction) and runs first, so an interrupted rename is
* finished by re-running it: nodes already moved answer to the new name, the shell still to the old.
*/
public static final String RENAME_PROJECT_NODES = """
MATCH (n:AstNode {project: $from})
CALL { WITH n SET n.project = $to } IN TRANSACTIONS OF $batchSize ROWS
""";
public static final String RENAME_PROJECT_OVERRIDES = """
MATCH (o:DynamicCallOverride {project: $from})
SET o.project = $to
""";
public static final String RENAME_PROJECT_COUNTERPART_REFS = """
MATCH (p:Project)
WHERE $from IN coalesce(p.counterparts, [])
SET p.counterparts = [c IN p.counterparts | CASE WHEN c = $from THEN $to ELSE c END]
""";
public static final String RENAME_PROJECT_SHELL = """
MATCH (p:Project {name: $from})
SET p.name = $to
""";
/**
* Item 202 (found while deleting {@code upms2}): a project's manual overrides are its own data and
* go with the full delete. The recreate path (item 78) keeps them on purpose — they are config.
*/
public static final String DELETE_PROJECT_OVERRIDES = """
MATCH (o:DynamicCallOverride {project: $name})
DELETE o
""";
public static final String DELETE_PROJECT_SHELL = """
MATCH (p:Project {name: $name})
DELETE p
@@ -2782,6 +2881,7 @@ public final class CypherQueries {
MATCH (p:Project)
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
p.counterparts AS counterparts,
// Item 126: what the last whole-root ingest did. Null on a project last ingested
// before this was recorded — "never measured", which is not the same as zero.
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
@@ -2840,6 +2940,50 @@ public final class CypherQueries {
// transaction for that exceeds the limit whatever the heap is set to.
CALL { WITH c DELETE c } IN TRANSACTIONS OF $batchSize ROWS
""";
// Declared before their first use (REST_ENDPOINTS, BINDINGS): a static final String initialiser
// that references a later field is an illegal forward reference; an IDE member sort had moved them.
/**
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
*/
private static final String REST_ENDPOINTS_ROW = """
DISTINCT f.httpMethod AS httpMethod, path AS path,
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
f.sourceFile AS sourceFile, f.startLine AS startLine,
// A @RegisterRestClient interface declares a call this application *makes*, not one
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
// backwards; they are flagged rather than dropped, because "who calls out to what"
// is a real question too.
// Item 193: a TypeScript endpoint FUNCTION (generated web-service client) stamps
// outbound='true' itself — the frontend's calls, listed by the same query.
(coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' OR f.outbound = 'true') AS outbound
""";
/**
* Item 195: every binding site of the project — a component/hook reading or writing a DTO field
* through a generated {@code Fields} path object — with the field's {@code COUNTERPART_OF} twin in
* the backend project, so "which page edits Java {@code Broker.ebene}" is one query.
*/
private static final String BINDINGS_CORE = """
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
WHERE m.sourceFile <> '' AND ($dto IS NULL OR d.name = $dto) AND ($field IS NULL OR f.field = $field)
MATCH (fn:AstNode)-[r:READS|WRITES]->(f)
WHERE r.via = 'binding' AND ($mode IS NULL OR type(r) = $mode) AND ($partial IS NULL OR r.partial = $partial)
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
WITH d, f, r, fn, head(collect(om)) AS owner
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
OPTIONAL MATCH (f)-[:COUNTERPART_OF]->(c:AstNode)
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
WHERE cm.sourceFile <> ''
WITH d, f, r, fn, owner, c, head(collect(cm)) AS cm
""";
private static final String BINDINGS_ROW = """
type(r) AS mode, d.name AS dto, coalesce(f.field, f.name) AS field, r.path AS path, r.rootDto AS rootDto, r.kind AS kind,
r.partial = 'true' AS partial, r.component AS component, r.attribute AS attribute,
fn.name AS function, fn.type AS functionType, fn.kind AS functionKind, owner.name AS module,
fn.sourceFile AS sourceFile, r.lineNo AS lineNo,
c.project AS counterpartProject, cm.name AS counterpartModule, c.name AS counterpartField
""";
/**
* Item 130: the project's REST surface — one row per handler method, with the endpoint path
* composed from the class-level and method-level {@code @Path} (item 130 persists both as
@@ -2883,17 +3027,26 @@ public final class CypherQueries {
// identical rows — 183 of 436 on `pur`.
""";
/**
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
* The projects whose counterpart edges must be (re)built after {@code $project} was refreshed:
* the project itself, and every project that lists it as a counterpart. The per-file reconcile
* {@code DETACH DELETE}s a refreshed handler together with the edges pointing at it, so a
* backend refresh has to relink from the frontend side too.
*/
private static final String REST_ENDPOINTS_ROW = """
DISTINCT f.httpMethod AS httpMethod, path AS path,
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
f.sourceFile AS sourceFile, f.startLine AS startLine,
// A @RegisterRestClient interface declares a call this application *makes*, not one
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
// backwards; they are flagged rather than dropped, because "who calls out to what"
// is a real question too.
coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' AS outbound
public static final String COUNTERPART_HOLDERS = """
MATCH (p:Project)
WHERE p.name = $project OR $project IN coalesce(p.counterparts, [])
RETURN p.name AS name
""";
/**
* Step 3: the fields of a linked DTO, by name.
*/
public static final String LINK_COUNTERPARTS_FIELD = """
MATCH (d:AstNode {project: $project, type: 'DATA_STRUCTURE'})-[:COUNTERPART_OF {via: 'dto'}]->(j)
MATCH (d)-[:CONTAINS]->(fd:AstNode {type: 'FIELD'})
// a TypeScript field is named <Interface>.<member> (item 195); `field` is the bare member
MATCH (j)-[:CONTAINS]->(jf:AstNode {type: 'FIELD', name: coalesce(fd.field, fd.name)})
MERGE (fd)-[r:COUNTERPART_OF]->(jf)
SET r.via = 'field'
""";
public static final String REST_ENDPOINTS = REST_ENDPOINTS_CORE + "RETURN " + REST_ENDPOINTS_ROW + """
ORDER BY path, httpMethod
@@ -4174,6 +4327,342 @@ public final class CypherQueries {
};
}
// ------------------------------------------------------------------------------------------
// Item 193: COUNTERPART_OF — the same thing in another project.
// ------------------------------------------------------------------------------------------
/**
* Step 0: this project's own counterpart edges are rebuilt from scratch, never accumulated.
*/
public static final String DELETE_COUNTERPART_EDGES = """
MATCH (n:AstNode {project: $project})-[r:COUNTERPART_OF]->()
DELETE r
""";
/**
* Step 1: an outbound web-service call ({@code FUNCTION} with {@code outbound='true'},
* {@code restPath}, {@code httpMethod} — a TypeScript endpoint, item 193) is linked to the handler
* in a counterpart project that serves the same verb and path shape (every {@code {param}}
* segment compares as {@code {}}, empty segments are ignored). The handler's path is composed
* exactly as {@link #REST_ENDPOINTS} composes it (class-level + method-level {@code @Path},
* inherited from the nearest ancestor) — <b>keep the two in step</b>. When several handlers match
* (the backend project holds more than one application), the one whose source lives under the
* frontend's {@code backend} name wins.
*/
public static final String LINK_COUNTERPARTS_REST = """
MATCH (p:Project {name: $project})
UNWIND coalesce(p.counterparts, []) AS cp
MATCH (fm:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
WHERE f.outbound = 'true' AND f.restPath IS NOT NULL AND f.httpMethod IS NOT NULL
MATCH (m:AstNode {project: cp, type: 'MODULE'})-[:CONTAINS]->(h:AstNode {type: 'FUNCTION'})
WHERE h.httpMethod = f.httpMethod AND h.sourceFile <> ''
OPTIONAL MATCH ancestry = (m)-[:EXTENDS|IMPLEMENTS*1..4]->(base:AstNode {type: 'MODULE'})
WHERE base.restPath IS NOT NULL
WITH f, m, h, base, length(ancestry) AS depth
ORDER BY depth
WITH f, m, h, head(collect(base.restPath)) AS inheritedPath
WITH f, m, h, coalesce(m.restPath, inheritedPath, '') AS classPath, coalesce(h.restPath, '') AS methodPath
WITH f, m, h, [x IN [classPath, methodPath] WHERE x <> '' AND x <> '/' |
CASE WHEN left(x, 1) = '/' THEN substring(x, 1) ELSE x END] AS lead
WITH f, m, h, [x IN lead | CASE WHEN size(x) > 0 AND right(x, 1) = '/' THEN left(x, size(x) - 1) ELSE x END] AS parts
WITH f, m, h, '/' + reduce(acc = '', x IN parts | CASE WHEN acc = '' THEN x ELSE acc + '/' + x END) AS hpath
WITH f, m, h, reduce(acc = '', s IN split(hpath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS hkey,
reduce(acc = '', s IN split(f.restPath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS fkey
WHERE hkey = fkey
WITH f, h, CASE WHEN f.backend IS NOT NULL AND m.sourceFile CONTAINS f.backend THEN 0 ELSE 1 END AS pref
ORDER BY pref
WITH f, collect(h)[0] AS h
MERGE (f)-[r:COUNTERPART_OF]->(h)
SET r.via = 'rest'
""";
/**
* Step 2: a data structure declared in a <em>generated</em> module (the frontend's mirror of the
* backend DTOs) is linked to the counterpart project's class of the same simple name — only when
* that name is unique there; an ambiguous name is left unlinked rather than guessed.
*/
public static final String LINK_COUNTERPARTS_DTO = """
MATCH (p:Project {name: $project})
UNWIND coalesce(p.counterparts, []) AS cp
MATCH (gm:AstNode {project: $project, type: 'MODULE', generated: 'true'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
MATCH (j:AstNode {project: cp, type: 'MODULE', simpleName: d.name})
WHERE j.sourceFile <> ''
WITH d, collect(DISTINCT j) AS js
WHERE size(js) = 1
WITH d, js[0] AS j
MERGE (d)-[r:COUNTERPART_OF]->(j)
SET r.via = 'dto'
""";
/**
* Item 196: redirects {@code REFERENCES} from a theme-token placeholder ({@code FIELD},
* {@code sourceFile=""}, {@code theme='true'}) onto every real token of the same name — with a
* light and a dark theme file the read reaches whichever is active, so each declaring file's row
* counts the use (item 199); an undeclared token (an MUI default, a typo) keeps its
* placeholder on purpose — {@code GET /theme} lists it with {@code declared=false}.
*/
public static final String RESOLVE_THEME_PLACEHOLDERS = """
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
WHERE ph.theme = 'true'
MATCH (real:AstNode {project: $project, type: 'FIELD', name: ph.name})
WHERE real.sourceFile <> "" AND real.theme = 'true'
WITH ph, collect(real) AS reals
MATCH (src:AstNode)-[r:REFERENCES]->(ph)
UNWIND reals AS real
MERGE (src)-[r2:REFERENCES {%s}]->(real)
SET r2 += properties(r)
SET r2.lineNo = r.lineNo
WITH DISTINCT r
DELETE r
""".formatted(edgeKey(EdgeType.REFERENCES, "coalesce(r.lineNo, -1)", "r.originFile", "src"));
/**
* Item 194: a store placeholder whose every access was redirected is scaffolding — drop it.
*/
public static final String DELETE_RESOLVED_STORE_PLACEHOLDERS = """
MATCH (ph:AstNode {project: $project, sourceFile: ""})
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD'] AND NOT (ph)--()
DELETE ph
""";
/**
* Item 193: the counterpart listing. Candidates are this project's outbound calls, its generated
* data structures and their fields; each row carries its twin, or nulls when {@code $unmatched}
* selects exactly the ones without one — "what is not served / not mirrored yet".
*/
// ---------------------------------------------------------------------------------------------
// Item 194: the frontend Redux store — slices, fields, reducer/selector accesses
// ---------------------------------------------------------------------------------------------
/**
* Item 194: every slice of the project's store with its state keys and access counts.
* {@code $slice} narrows to one slice by reducer key or by RTK slice name.
*/
public static final String STORE_SLICES = """
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(s:AstNode {project: $project, type: 'STORE_SLICE'})
WHERE s.sourceFile <> '' AND ($slice IS NULL OR s.name = $slice OR s.sliceName = $slice)
OPTIONAL MATCH (s)-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
WITH m, s, f ORDER BY f.startLine, f.name
WITH m, s, collect(CASE WHEN f IS NULL THEN null ELSE {
name: f.field, type: f.dataType, optional: f.optional = 'true',
reads: size([(f)<-[:READS]-() | 1]), writes: size([(f)<-[:WRITES]-() | 1])} END) AS fields
RETURN s.name AS slice, s.sliceName AS sliceName, m.name AS module, s.sourceFile AS sourceFile,
s.startLine AS startLine, s.endLine AS endLine, s.stateType AS stateType, fields,
size([(m)-[:CONTAINS]->(r:AstNode {type: 'FUNCTION', kind: 'reducer', slice: s.name}) | 1]) AS reducers,
size([(s)<-[:READS]-() | 1]) AS sliceReads, size([(s)<-[:WRITES]-() | 1]) AS sliceWrites
ORDER BY slice
""";
/**
* Item 195: a binding placeholder whose every edge was redirected is scaffolding — drop it.
*/
public static final String DELETE_RESOLVED_BINDING_PLACEHOLDERS = """
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
WHERE ph.binding = 'true' AND NOT (ph)--()
DELETE ph
""";
private static final Map<EdgeType, String> RESOLVE_STORE_PLACEHOLDERS = buildResolveStorePlaceholderQueries();
/**
* Item 194: who reads and writes a slice's state — reducers (the writers, {@code functionKind =
* reducer}) and the components/hooks/thunks selecting from it. A row per access site; {@code field}
* is null for an access to the whole slice state, {@code path} is the full sub-path as written
* ({@code agstammUseCaseSvcResult.result.purMode}), {@code via} the hook or {@code reducer}/{@code getState}.
*/
private static final String STORE_ACCESSES_CORE = """
MATCH (s:AstNode {project: $project, type: 'STORE_SLICE'})
WHERE s.sourceFile <> '' AND (s.name = $slice OR s.sliceName = $slice)
MATCH (s)-[:CONTAINS*0..1]->(t:AstNode)
WHERE (t = s OR t.type = 'FIELD') AND ($field IS NULL OR t.field = $field)
MATCH (fn:AstNode)-[r:READS|WRITES]->(t)
WHERE $mode IS NULL OR type(r) = $mode
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
WITH s, t, r, fn, head(collect(om)) AS owner
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
""";
private static final String STORE_ACCESSES_ROW = """
type(r) AS mode, s.name AS slice, t.field AS field, r.path AS path, fn.name AS function, fn.type AS functionType,
fn.kind AS functionKind, owner.name AS module, fn.sourceFile AS sourceFile, r.lineNo AS lineNo, r.via AS via
""";
public static final String STORE_ACCESSES = STORE_ACCESSES_CORE + "RETURN " + STORE_ACCESSES_ROW + """
ORDER BY mode, field, module, lineNo
SKIP $offset LIMIT $limit
""";
public static final String STORE_ACCESSES_COUNT = STORE_ACCESSES_CORE + "WITH " + STORE_ACCESSES_ROW + """
RETURN count(*) AS total
""";
private static final Map<EdgeType, String> RESOLVE_BINDING_PLACEHOLDERS = buildResolveBindingPlaceholderQueries();
// ---------------------------------------------------------------------------------------------
// Item 195: DTO field bindings
// ---------------------------------------------------------------------------------------------
/**
* Item 196: a theme placeholder whose every reference was redirected is scaffolding — drop it.
*/
public static final String DELETE_RESOLVED_THEME_PLACEHOLDERS = """
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
WHERE ph.theme = 'true' AND NOT (ph)--()
DELETE ph
""";
/**
* Item 196: every theme token — declared ones (under the {@code theme} structure) and the
* undeclared ones the code reads (placeholders) — with its project-side use count.
*/
public static final String THEME_TOKENS = """
MATCH (t:AstNode {project: $project, type: 'FIELD'})
WHERE t.theme = 'true'
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS]->(:AstNode {type: 'DATA_STRUCTURE'})-[:CONTAINS]->(t)
WITH t, head(collect(m)) AS m, size([(t)<-[:REFERENCES]-() | 1]) AS uses
WHERE NOT $unused OR uses = 0
RETURN coalesce(t.token, t.name) AS token, t.tokenKind AS kind, t.value AS value, t.constant AS constant,
t.sourceFile <> '' AS declared, m.name AS module, t.startLine AS lineNo, uses
ORDER BY declared DESC, token
""";
public static final String BINDINGS = BINDINGS_CORE + "RETURN " + BINDINGS_ROW + """
ORDER BY dto, field, mode, module, lineNo
SKIP $offset LIMIT $limit
""";
public static final String BINDINGS_COUNT = BINDINGS_CORE + "WITH " + BINDINGS_ROW + """
RETURN count(*) AS total
""";
// ---------------------------------------------------------------------------------------------
// Item 196: styling — theme tokens, style blocks
// ---------------------------------------------------------------------------------------------
/**
* Item 196: where one token is read — style blocks (with the key they feed) and plain code/prop reads.
*/
public static final String THEME_USAGES = """
MATCH (t:AstNode {project: $project, type: 'FIELD'})
WHERE t.theme = 'true' AND (t.token = $token OR t.name = $token)
MATCH (x:AstNode)-[r:REFERENCES]->(t)
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(x)
WITH r, x, CASE WHEN x.type = 'FUNCTION' THEN x ELSE fn END AS f
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS*0..2]->(x)
WITH r, x, f, head(collect(m)) AS m
// DISTINCT: a read resolved onto two theme files (item 199) is one usage, not two
RETURN DISTINCT coalesce(f.name, x.name) AS function, f.kind AS functionKind, m.name AS module, x.sourceFile AS sourceFile,
r.lineNo AS lineNo, x.styleKind AS styleKind, x.element AS element, r.property AS property, r.context AS context
ORDER BY module, lineNo
""";
/**
* Item 196: the style inventory — every sx/style/styled block and CSS rule with keys, literals and tokens.
*/
private static final String STYLES_CORE = """
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(s:AstNode {type: 'STYLE'})
WHERE m.sourceFile <> ''
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
AND ($kind IS NULL OR s.styleKind = $kind)
AND (NOT $withLiterals OR (s.literals IS NOT NULL AND s.literals <> ''))
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(s)
WITH DISTINCT m, s, head(collect(fn)) AS fn
""";
private static final String STYLES_ROW = """
s.name AS name, s.styleKind AS styleKind, s.element AS element, s.selector AS selector, fn.name AS function,
m.name AS module, s.sourceFile AS sourceFile, s.startLine AS lineNo, s.properties AS properties,
s.literals AS literals, s.dynamic = 'true' AS dynamic,
reduce(acc = [], tk IN [(s)-[:REFERENCES]->(t) | coalesce(t.token, t.name)] |
CASE WHEN tk IN acc THEN acc ELSE acc + tk END) AS tokens
""";
public static final String STYLES = STYLES_CORE + "RETURN " + STYLES_ROW + """
ORDER BY module, lineNo, name
SKIP $offset LIMIT $limit
""";
public static final String STYLES_COUNT = STYLES_CORE + "WITH " + STYLES_ROW + """
RETURN count(*) AS total
""";
private static final String COUNTERPARTS_CORE = """
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(n:AstNode)
WHERE m.sourceFile <> ''
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
AND ( (n.type = 'FUNCTION' AND n.outbound = 'true' AND n.restPath IS NOT NULL)
OR (n.type = 'DATA_STRUCTURE' AND m.generated = 'true')
OR (n.type = 'FIELD' AND m.generated = 'true'))
WITH DISTINCT m, n, CASE n.type WHEN 'FUNCTION' THEN 'rest' WHEN 'DATA_STRUCTURE' THEN 'dto' ELSE 'field' END AS kind
WHERE $kind IS NULL OR kind = $kind
OPTIONAL MATCH (n)-[:COUNTERPART_OF]->(c:AstNode)
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
WHERE cm.sourceFile <> ''
WITH m, n, kind, c, head(collect(cm)) AS cm
WHERE NOT $unmatched OR c IS NULL
""";
private static final String COUNTERPARTS_ROW = """
kind AS kind, n.name AS name, m.name AS module, n.sourceFile AS sourceFile, n.startLine AS startLine,
n.httpMethod AS httpMethod, n.restPath AS path,
c.project AS counterpartProject, c.name AS counterpartName,
CASE WHEN c IS NULL THEN null WHEN c.type = 'MODULE' THEN c.name ELSE cm.name END AS counterpartModule,
c.sourceFile AS counterpartSourceFile, c.startLine AS counterpartStartLine
""";
public static final String COUNTERPARTS = COUNTERPARTS_CORE + "RETURN " + COUNTERPARTS_ROW + """
ORDER BY kind, module, name
SKIP $offset LIMIT $limit
""";
public static final String COUNTERPARTS_COUNT = COUNTERPARTS_CORE + "WITH " + COUNTERPARTS_ROW + """
RETURN count(*) AS total
""";
/**
* Item 194: redirects {@code READS}/{@code WRITES} from a store placeholder — a {@code STORE_SLICE}
* or store {@code FIELD} with {@code sourceFile=""} and {@code store='true'}, minted by the file
* that reads {@code state.<key>.<field>} — onto the real node of the same type and name (the slice
* file declares {@code <key>.<field>} with {@code store='true'}). Store names are unique by
* construction (one reducer key per store), so a plain name match is exact; the generic
* placeholder resolver never sees these because it is limited to {@code MODULE}/{@code DATA_STRUCTURE}.
* Cheap (bounded by the project's store placeholders), so it runs in every finalize mode.
*/
public static String resolveStorePlaceholders(EdgeType type) {
String query = RESOLVE_STORE_PLACEHOLDERS.get(type);
if (query == null) {
throw new IllegalArgumentException("Not a store access edge type: " + type);
}
return query;
}
private static Map<EdgeType, String> buildResolveStorePlaceholderQueries() {
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
queries.put(type, """
MATCH (ph:AstNode {project: $project, sourceFile: ""})
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD']
MATCH (real:AstNode {project: $project, type: ph.type, name: ph.name})
WHERE real.sourceFile <> "" AND real.store = 'true'
MATCH (src:AstNode)-[r:%s]->(ph)
MERGE (src)-[r2:%s {%s}]->(real)
SET r2 += properties(r)
SET r2.lineNo = r.lineNo
DELETE r
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
}
return queries;
}
/**
* Item 195: redirects {@code READS}/{@code WRITES} from a binding placeholder — a {@code FIELD}
* with {@code sourceFile=""}, {@code binding='true'}, {@code owner}, {@code field} and
* {@code targetModule} — onto the real {@code FIELD} declared under that {@code DATA_STRUCTURE} in
* that module. Exact by construction (module → structure → field), which is why the generic
* placeholder resolver (name only, and never a module-owned structure — item 74) is not used.
*/
public static String resolveBindingPlaceholders(EdgeType type) {
String query = RESOLVE_BINDING_PLACEHOLDERS.get(type);
if (query == null) {
throw new IllegalArgumentException("Not a binding edge type: " + type);
}
return query;
}
private static Map<EdgeType, String> buildResolveBindingPlaceholderQueries() {
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
queries.put(type, """
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
WHERE ph.binding = 'true'
MATCH (m:AstNode {project: $project, type: 'MODULE', name: ph.targetModule})
-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE', name: ph.owner})
-[:CONTAINS]->(real:AstNode {type: 'FIELD', field: ph.field})
WHERE m.sourceFile <> "" AND real.sourceFile <> ""
MATCH (src:AstNode)-[r:%s]->(ph)
MERGE (src)-[r2:%s {%s}]->(real)
SET r2 += properties(r)
SET r2.lineNo = r.lineNo
DELETE r
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
}
return queries;
}
private static String edgeKey(EdgeType type, String lineNoExpr, String originExpr, String sourceNodeVar) {
if (!needsFileDiscriminator(type)) {
return "lineNo: %s".formatted(lineNoExpr);
@@ -4192,6 +4681,7 @@ public final class CypherQueries {
MERGE (a)-[r:%s {%s}]->(b)
SET r.value = e.value
SET r += e.properties
SET r.ingestGen = $ingestGen
""".formatted(type.name(), edgeKey(type, "e.lineNo", "e.properties.originFile", "a")));
}
return queries;

View File

@@ -17,8 +17,10 @@ import org.jspecify.annotations.Nullable;
* {@code null} when not known (e.g. a DB-table column)
* @param sourceFile the file the field is declared in (item 101) — the discriminator when a structure
* name resolves to more than one definition; {@code null} for an unresolved placeholder
* @param boundReads item 195: binding sites reading this field through a generated {@code Fields} path
* object (TypeScript DTOs only; 0 elsewhere), {@code boundWrites} those writing it
*/
public record DataStructureField(String name, String type, @Nullable String dataType, @Nullable String value,
String parent, int startLine, int endLine, @Nullable String scope,
@Nullable String sourceFile) {
@Nullable String sourceFile, int boundReads, int boundWrites) {
}

View File

@@ -139,6 +139,21 @@ public class GraphRepository {
statements.add(new EnrichmentStep("resolve-placeholder " + type,
CypherQueries.resolvePlaceholderTargets(type), true));
}
// Item 194: store READS/WRITES from selectors and reducers onto the real slice/field nodes.
// Cheap (bounded by the store placeholders of a TypeScript project, none elsewhere) and
// exact by name, so it runs in every mode and its placeholders are dropped right away.
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-store-placeholder " + type, CypherQueries.resolveStorePlaceholders(type)));
}
statements.add(new EnrichmentStep("delete-resolved-store-placeholders", CypherQueries.DELETE_RESOLVED_STORE_PLACEHOLDERS));
// Item 195: DTO field bindings onto the generated interface's FIELD (module -> structure -> field).
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-binding-placeholder " + type, CypherQueries.resolveBindingPlaceholders(type)));
}
statements.add(new EnrichmentStep("delete-resolved-binding-placeholders", CypherQueries.DELETE_RESOLVED_BINDING_PLACEHOLDERS));
// Item 196: theme-token reads onto the declared token (exact name, one theme per project).
statements.add(new EnrichmentStep("resolve-theme-placeholder", CypherQueries.RESOLVE_THEME_PLACEHOLDERS));
statements.add(new EnrichmentStep("delete-resolved-theme-placeholders", CypherQueries.DELETE_RESOLVED_THEME_PLACEHOLDERS));
// J7: resolve Panache-ness inherited through a project base class (needs EXTENDS pointing at
// real nodes, i.e. after the placeholder loop above) by setting repositoryEntity on the
// concrete repository; must run before resolve-java-db-access, which reads it. Project-wide,
@@ -312,6 +327,12 @@ public class GraphRepository {
return statements;
}
private static final List<EnrichmentStep> COUNTERPART_STEPS = List.of(
new EnrichmentStep("delete-counterpart-edges", CypherQueries.DELETE_COUNTERPART_EDGES),
new EnrichmentStep("link-counterparts-rest", CypherQueries.LINK_COUNTERPARTS_REST),
new EnrichmentStep("link-counterparts-dto", CypherQueries.LINK_COUNTERPARTS_DTO),
new EnrichmentStep("link-counterparts-field", CypherQueries.LINK_COUNTERPARTS_FIELD));
/**
* Strips a single leading Natural sigil ({@code #}, {@code &}, {@code +}) so name search is sigil-insensitive.
*/
@@ -923,21 +944,6 @@ public class GraphRepository {
.toList();
}
private static DataStructureField toDataStructureField(Record record) {
return new DataStructureField(
record.get("name").asString(),
record.get("type").asString(),
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
record.get("value").isNull() ? null : record.get("value").asString(),
record.get("parent").asString(),
record.get("startLine").asInt(),
record.get("endLine").asInt(),
record.get("scope").isNull() ? null : record.get("scope").asString(),
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|| record.get("sourceFile").asString().isEmpty()
? null : record.get("sourceFile").asString());
}
public Uni<List<DataStructureField>> dbTableColumns(String project, String name) {
return read(CypherQueries.DB_TABLE_COLUMNS, moduleParams(project, name, ANY_SOURCE_FILE),
GraphRepository::toDataStructureField);
@@ -1393,7 +1399,9 @@ public class GraphRepository {
.toList();
reindexed.add(new AggregatedCallRef(item.name(), item.type(), newIdx, item.edgeKind(), sites, item.unresolved()));
}
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed);
int total = resp.items().size();
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed, total,
Math.max(offset, 0) + reindexed.size() < total);
}
/**
@@ -1426,20 +1434,21 @@ public class GraphRepository {
// Write operations
// -------------------------------------------------------------------------
/**
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
*/
private static ProjectInfo toProjectInfo(Record record) {
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
String root = record.get("root").isNull() ? "" : record.get("root").asString();
List<String> excludeDirs = record.get("excludeDirs").isNull()
? List.of()
: record.get("excludeDirs").asList(value -> value.asString());
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
nullableString(record, "language"), nullableString(record, "generatedDir"),
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
private static DataStructureField toDataStructureField(Record record) {
return new DataStructureField(
record.get("name").asString(),
record.get("type").asString(),
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
record.get("value").isNull() ? null : record.get("value").asString(),
record.get("parent").asString(),
record.get("startLine").asInt(),
record.get("endLine").asInt(),
record.get("scope").isNull() ? null : record.get("scope").asString(),
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|| record.get("sourceFile").asString().isEmpty()
? null : record.get("sourceFile").asString(),
record.containsKey("boundReads") ? record.get("boundReads").asInt(0) : 0,
record.containsKey("boundWrites") ? record.get("boundWrites").asInt(0) : 0);
}
/**
@@ -1872,6 +1881,25 @@ public class GraphRepository {
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep");
}
/**
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
*/
private static ProjectInfo toProjectInfo(Record record) {
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
String root = record.get("root").isNull() ? "" : record.get("root").asString();
List<String> excludeDirs = record.get("excludeDirs").isNull()
? List.of()
: record.get("excludeDirs").asList(value -> value.asString());
List<String> counterparts = !record.containsKey("counterparts") || record.get("counterparts").isNull()
? List.of()
: record.get("counterparts").asList(value -> value.asString());
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
nullableString(record, "language"), nullableString(record, "generatedDir"),
nullableString(record, "userExitDir"), toProjectIngestInfo(record), counterparts);
}
/**
* @param level how far enrichment goes — see {@link EnrichmentLevel}. Field-placeholder
* resolution (the expensive part) runs only for {@link EnrichmentLevel#FULL};
@@ -1888,7 +1916,170 @@ public class GraphRepository {
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
Map.of("project", project, "batchSize", deleteBatchSize),
level.name().toLowerCase(java.util.Locale.ROOT), profile);
level.name().toLowerCase(java.util.Locale.ROOT), profile)
.chain(() -> linkCounterparts(project));
}
/**
* Item 193: (re)builds the {@code COUNTERPART_OF} edges of {@code project} and of every project
* that lists it as a counterpart — the latter because a refresh of the backend
* {@code DETACH DELETE}d the handlers the frontend's edges pointed at.
*/
public Uni<Void> linkCounterparts(String project) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
return read(CypherQueries.COUNTERPART_HOLDERS, params, record -> record.get("name").asString())
.chain(holders -> {
Uni<Void> chain = Uni.createFrom().voidItem();
for (String holder : holders) {
chain = chain.chain(() -> runEnrichment(holder, COUNTERPART_STEPS, Map.of("project", holder), "counterparts"));
}
return chain;
});
}
/**
* Item 193: the counterpart listing as a page (see {@link CypherQueries#COUNTERPARTS}).
*
* @param kind {@code rest}, {@code dto}, {@code field} or {@code null} for all
* @param unmatched only rows without a twin
*/
/**
* Item 194: the slices of the project's Redux store, optionally one by reducer key or slice name.
*/
public Uni<List<StoreSlice>> storeSlices(String project, @Nullable String slice) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("slice", slice);
return read(CypherQueries.STORE_SLICES, params, record -> {
List<StoreSlice.Field> fields = new ArrayList<>();
int reads = record.get("sliceReads").asInt();
int writes = record.get("sliceWrites").asInt();
for (Value f : record.get("fields").asList(v -> v)) {
int fr = f.get("reads").asInt();
int fw = f.get("writes").asInt();
reads += fr;
writes += fw;
fields.add(new StoreSlice.Field(f.get("name").asString(), f.get("type").isNull() ? null : f.get("type").asString(),
f.get("optional").asBoolean(false), fr, fw));
}
return new StoreSlice(record.get("slice").asString(), nullableString(record, "sliceName"), record.get("module").asString(),
record.get("sourceFile").asString(), record.get("startLine").asInt(), record.get("endLine").asInt(),
nullableString(record, "stateType"), fields, record.get("reducers").asInt(), reads, writes);
});
}
/**
* Item 194: the access sites of one slice — {@code mode} = {@code READS}/{@code WRITES}/null (both),
* {@code field} = one state key or null (all, including whole-slice accesses), {@code module} = only
* accesses from that module.
*/
public Uni<Page<StoreAccess>> storeAccessesPage(String project, String slice, @Nullable String field, @Nullable String mode,
@Nullable String module, int limit, int offset) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("slice", slice);
params.put("field", field);
params.put("mode", mode);
params.put("module", module);
params.put("offset", Math.max(offset, 0));
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
return read(CypherQueries.STORE_ACCESSES, params, record -> new StoreAccess(
record.get("mode").asString(), record.get("slice").asString(), nullableString(record, "field"),
nullableString(record, "path"), record.get("function").asString(), record.get("functionType").asString(),
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
intOrNull(record.get("lineNo")), nullableString(record, "via")))
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STORE_ACCESSES_COUNT, params)));
}
/**
* Item 196: every theme token with its use count; {@code unused} = only tokens no project code references.
*/
public Uni<List<ThemeToken>> themeTokens(String project, boolean unused) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("unused", unused);
return read(CypherQueries.THEME_TOKENS, params, record -> new ThemeToken(record.get("token").asString(),
nullableString(record, "kind"), nullableString(record, "value"), nullableString(record, "constant"),
record.get("declared").asBoolean(false), nullableString(record, "module"), intOrNull(record.get("lineNo")),
record.get("uses").asInt(0)));
}
/**
* Item 196: where one theme token (by token path or {@code theme.<token>} name) is read.
*/
public Uni<List<ThemeUsage>> themeUsages(String project, String token) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("token", token);
return read(CypherQueries.THEME_USAGES, params, record -> new ThemeUsage(record.get("function").asString(),
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
intOrNull(record.get("lineNo")), nullableString(record, "styleKind"), nullableString(record, "element"),
nullableString(record, "property"), nullableString(record, "context")));
}
/**
* Item 196: the style inventory, optionally one module, one kind ({@code sx|style|styled|css}) or only blocks with literals.
*/
public Uni<Page<StyleBlock>> stylesPage(String project, @Nullable String module, @Nullable String kind, boolean withLiterals,
int limit, int offset) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("module", module);
params.put("kind", kind);
params.put("withLiterals", withLiterals);
params.put("offset", Math.max(offset, 0));
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
return read(CypherQueries.STYLES, params, record -> new StyleBlock(record.get("name").asString(),
record.get("styleKind").asString(), nullableString(record, "element"), nullableString(record, "selector"),
nullableString(record, "function"), record.get("module").asString(), record.get("sourceFile").asString(),
intOrNull(record.get("lineNo")), nullableString(record, "properties"), nullableString(record, "literals"),
record.get("dynamic").asBoolean(false), record.get("tokens").asList(Value::asString)))
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STYLES_COUNT, params)));
}
/**
* Item 195: the binding sites of DTO fields, each with the field's backend counterpart.
*/
public Uni<Page<Binding>> bindingsPage(String project, @Nullable String dto, @Nullable String field, @Nullable String mode,
@Nullable String module, @Nullable Boolean partial, int limit, int offset) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("dto", dto);
params.put("field", field);
params.put("mode", mode);
params.put("module", module);
params.put("partial", partial == null ? null : partial.toString());
params.put("offset", Math.max(offset, 0));
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
return read(CypherQueries.BINDINGS, params, record -> new Binding(
record.get("mode").asString(), record.get("dto").asString(), record.get("field").asString(),
nullableString(record, "path"), nullableString(record, "rootDto"), nullableString(record, "kind"),
record.get("partial").asBoolean(false), nullableString(record, "component"), nullableString(record, "attribute"),
record.get("function").asString(), record.get("functionType").asString(), nullableString(record, "functionKind"),
nullableString(record, "module"), record.get("sourceFile").asString(), intOrNull(record.get("lineNo")),
nullableString(record, "counterpartProject"), nullableString(record, "counterpartModule"),
nullableString(record, "counterpartField")))
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.BINDINGS_COUNT, params)));
}
public Uni<Page<Counterpart>> counterpartsPage(String project, @Nullable String module, @Nullable String kind,
boolean unmatched, int limit, int offset) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("module", module);
params.put("kind", kind);
params.put("unmatched", unmatched);
params.put("offset", Math.max(offset, 0));
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
return read(CypherQueries.COUNTERPARTS, params, record -> new Counterpart(
record.get("kind").asString(), record.get("name").asString(), record.get("module").asString(),
record.get("sourceFile").asString(), record.get("startLine").asInt(),
nullableString(record, "httpMethod"), nullableString(record, "path"),
nullableString(record, "counterpartProject"), nullableString(record, "counterpartName"),
nullableString(record, "counterpartModule"), nullableString(record, "counterpartSourceFile"),
intOrNull(record.get("counterpartStartLine"))))
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.COUNTERPARTS_COUNT, params)));
}
/**
@@ -2209,6 +2400,16 @@ public class GraphRepository {
String root, List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir,
@Nullable String userExitDir) {
return createProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, List.of());
}
/**
* @param counterparts item 193: projects this one's web-service calls and generated DTOs are linked to
*/
public Uni<ProjectOpResult> createProject(String name, @Nullable String description,
String root, List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir,
@Nullable String userExitDir, List<String> counterparts) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
@@ -2223,6 +2424,7 @@ public class GraphRepository {
params.put("language", language);
params.put("generatedDir", generatedDir);
params.put("userExitDir", userExitDir);
params.put("counterparts", counterparts);
tx.run(CypherQueries.CREATE_PROJECT, params);
return ProjectOpResult.SUCCESS;
});
@@ -2240,6 +2442,16 @@ public class GraphRepository {
@Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir,
@Nullable String userExitDir) {
return updateProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
}
/**
* @param counterparts item 193; {@code null} leaves the list unchanged
*/
public Uni<ProjectOpResult> updateProject(String name, @Nullable String description,
@Nullable String root, @Nullable List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir,
@Nullable String userExitDir, @Nullable List<String> counterparts) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
@@ -2254,6 +2466,7 @@ public class GraphRepository {
params.put("language", language);
params.put("generatedDir", generatedDir);
params.put("userExitDir", userExitDir);
params.put("counterparts", counterparts);
tx.run(CypherQueries.UPDATE_PROJECT, params);
return ProjectOpResult.SUCCESS;
});
@@ -2268,12 +2481,39 @@ public class GraphRepository {
return ProjectOpResult.NOT_FOUND;
}
deleteNodesBatched(session, name);
session.run(CypherQueries.DELETE_PROJECT_OVERRIDES, Map.of("name", name)).consume();
session.run(CypherQueries.DELETE_PROJECT_SHELL, Map.of("name", name)).consume();
return ProjectOpResult.SUCCESS;
}
});
}
/**
* Item 202: renames {@code from} to {@code to} — nodes (batched, implicit transaction like the
* delete), overrides, other projects' {@code counterparts} entries, then the shell.
*
* @return {@code NOT_FOUND} if {@code from} does not exist, {@code CONFLICT} if {@code to} already
* does, else {@code SUCCESS}.
*/
public Uni<ProjectOpResult> renameProject(String from, String to) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
if (!session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", from)).hasNext()) {
return ProjectOpResult.NOT_FOUND;
}
if (session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", to)).hasNext()) {
return ProjectOpResult.CONFLICT;
}
Map<String, Object> params = Map.of("from", from, "to", to, "batchSize", deleteBatchSize);
session.run(CypherQueries.RENAME_PROJECT_NODES, params).consume();
session.run(CypherQueries.RENAME_PROJECT_OVERRIDES, params).consume();
session.run(CypherQueries.RENAME_PROJECT_COUNTERPART_REFS, params).consume();
session.run(CypherQueries.RENAME_PROJECT_SHELL, params).consume();
return ProjectOpResult.SUCCESS;
}
});
}
/**
* Item 78: deletes a project's graph but keeps its {@code (:Project)} shell, so the config survives.
*
@@ -2487,41 +2727,13 @@ public class GraphRepository {
});
}
/**
* Upserts a manual override for the call site {@code (originFile, lineNo)} and applies it to the
* graph immediately (no refresh needed). Rejects the request — returning the offending names in
* {@link OverrideUpsertResult#invalidTargets()} — if any target is not a real {@code MODULE}.
*/
public Uni<OverrideUpsertResult> upsertDynamicCallOverride(String project, String originFile, int lineNo,
List<String> targets, @Nullable String variable,
@Nullable String note, @Nullable String createdBy) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
List<String> existing = tx.run(CypherQueries.EXISTING_MODULE_TARGETS,
Map.of("project", project, "targets", targets))
.single().get("existing").asList(Value::asString);
List<String> invalid = targets.stream().distinct()
.filter(t -> !existing.contains(t)).toList();
if (!invalid.isEmpty()) {
return OverrideUpsertResult.invalid(invalid);
}
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("originFile", originFile);
params.put("lineNo", lineNo);
params.put("targets", targets);
params.put("variable", variable);
params.put("note", note);
params.put("createdBy", createdBy);
params.put("createdAt", java.time.Instant.now().toString());
Record r = tx.run(CypherQueries.UPSERT_DYNAMIC_CALL_OVERRIDE, params).single();
// Apply immediately so the resolved edges show up without a refresh.
tx.run(CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT, Map.of("project", project));
return OverrideUpsertResult.ok(mapOverride(r));
});
}
});
private static List<String> callerModulesAtSite(TransactionContext tx, String project,
@Nullable String originFile, @Nullable Integer lineNo) {
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("originFile", originFile);
params.put("lineNo", lineNo);
return tx.run(CypherQueries.CALLER_MODULES_AT_SITE, params).single().get("names").asList(Value::asString);
}
/**
@@ -2790,6 +3002,15 @@ public class GraphRepository {
stats.runQuery("merge-edges", tx, CypherQueries.mergeEdgesBatch(entry.getKey()),
Map.of("edges", entry.getValue(), "ingestGen", ingestGen));
}
// Item 198: every edge from a re-parsed file's nodes that this parse did not re-emit (its
// stamp is older than this run's) — the Java/TypeScript counterpart of the three Natural
// reaps, for every edge type the parser owns. Before the node sweep on purpose: a resolved
// edge to a real target of an older generation goes too, and the deep finalize rebuilds it
// from the fresh placeholder edge; a placeholder left edgeless falls to the finalize sweeps.
if (reconcile && !freshFiles.isEmpty()) {
stats.runQuery("reap-stale-parsed-edges", tx, CypherQueries.DELETE_STALE_PARSED_EDGES,
Map.of("project", project, "files", freshFiles, "ingestGen", ingestGen));
}
// Item 58: after merging the fresh nodes (whose ids are now written), delete each re-parsed
// file's nodes that the fresh parse no longer produced (renamed/removed fields, moved
// statements) so a refresh purges stale nodes instead of leaving them to shadow the new ones.
@@ -2949,6 +3170,62 @@ public class GraphRepository {
return searchByValue(project, value, substring, includeComments).map(list -> paginate(list, limit, offset));
}
/**
* Item 200: an override changes a module's real callees, and the item-68 {@code CALLS_MODULE}
* projection is only rebuilt by the finalize. Rebuilding it for the calling modules here keeps
* "applied immediately" true for {@code reaches} / {@code field-flow} too.
*/
private static void rebuildCallsModuleAtSite(TransactionContext tx, String project, String originFile, int lineNo) {
rebuildCallsModule(tx, project, callerModulesAtSite(tx, project, originFile, lineNo));
}
private static void rebuildCallsModule(TransactionContext tx, String project, List<String> modules) {
if (modules.isEmpty()) {
return;
}
Map<String, Object> params = Map.of("project", project, "names", modules);
tx.run(CypherQueries.DELETE_CALLS_MODULE_SCOPED, params);
tx.run(CypherQueries.BUILD_CALLS_MODULE_SCOPED, params);
}
/**
* Upserts a manual override for the call site {@code (originFile, lineNo)} and applies it to the
* graph immediately (no refresh needed). Rejects the request — returning the offending names in
* {@link OverrideUpsertResult#invalidTargets()} — if any target is not a real {@code MODULE}.
*/
public Uni<OverrideUpsertResult> upsertDynamicCallOverride(String project, String originFile, int lineNo,
List<String> targets, @Nullable String variable,
@Nullable String note, @Nullable String createdBy) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
return session.executeWrite(tx -> {
List<String> existing = tx.run(CypherQueries.EXISTING_MODULE_TARGETS,
Map.of("project", project, "targets", targets))
.single().get("existing").asList(Value::asString);
List<String> invalid = targets.stream().distinct()
.filter(t -> !existing.contains(t)).toList();
if (!invalid.isEmpty()) {
return OverrideUpsertResult.invalid(invalid);
}
Map<String, @Nullable Object> params = new HashMap<>();
params.put("project", project);
params.put("originFile", originFile);
params.put("lineNo", lineNo);
params.put("targets", targets);
params.put("variable", variable);
params.put("note", note);
params.put("createdBy", createdBy);
params.put("createdAt", java.time.Instant.now().toString());
Record r = tx.run(CypherQueries.UPSERT_DYNAMIC_CALL_OVERRIDE, params).single();
// Apply immediately so the resolved edges show up without a refresh.
tx.run(CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT, Map.of("project", project));
rebuildCallsModuleAtSite(tx, project, originFile, lineNo);
return OverrideUpsertResult.ok(mapOverride(r));
});
}
});
}
/**
* Resets manual overrides — deletes the manual edges, un-hides the placeholder markers (so the
* call sites read as unresolved again, inline, without a refresh) and removes the override nodes.
@@ -2964,10 +3241,14 @@ public class GraphRepository {
params.put("project", project);
params.put("originFile", originFile);
params.put("lineNo", lineNo);
// Item 200: the callers must be collected before their manual edges are gone.
List<String> callers = callerModulesAtSite(tx, project, originFile, lineNo);
tx.run(CypherQueries.RESET_MANUAL_DYNAMIC_EDGES, params);
tx.run(CypherQueries.UNHIDE_MANUAL_DYNAMIC_PLACEHOLDERS, params);
return tx.run(CypherQueries.DELETE_DYNAMIC_CALL_OVERRIDE, params)
int removed = tx.run(CypherQueries.DELETE_DYNAMIC_CALL_OVERRIDE, params)
.consume().counters().nodesDeleted();
rebuildCallsModule(tx, project, callers);
return removed;
});
}
});

View File

@@ -12,6 +12,9 @@ import java.util.List;
* such projects must be updated with a root before they can be (re-)ingested. {@code excludeDirs}
* holds path components skipped when walking {@code root} (e.g. {@code generated_sources}).
*
* <p>Item 193: {@code counterparts} names the projects whose nodes this project's outbound web-service
* calls and generated DTOs are linked to by {@code COUNTERPART_OF} edges (a frontend lists its backend).
*
* <p>{@code language} is the project's declared source language ({@code "natural"}/{@code "java"});
* required on create, {@code null} for legacy projects. It is an attribute only — ingest still
* classifies files by extension. {@code generatedDir}/{@code userExitDir} (item 47) are directory
@@ -21,13 +24,22 @@ import java.util.List;
*/
public record ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
@Nullable ProjectIngestInfo ingest) {
@Nullable ProjectIngestInfo ingest, List<String> counterparts) {
/**
* Pre-item-193 shape: no counterparts.
*/
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
@Nullable ProjectIngestInfo ingest) {
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, ingest, List.of());
}
/**
* Legacy convenience constructor for projects without a language / user-exit split.
*/
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs) {
this(name, description, root, excludeDirs, null, null, null, null);
this(name, description, root, excludeDirs, null, null, null, null, List.of());
}
/**
@@ -37,6 +49,6 @@ public record ProjectInfo(String name, @Nullable String description, String root
*/
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, null, List.of());
}
}

View File

@@ -0,0 +1,17 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 194: one access to a store slice's state. {@code mode} is {@code READS} or {@code WRITES};
* {@code field} is the top-level state key, or {@code null} for an access to the whole slice state;
* {@code path} the full sub-path as written ({@code agstammUseCaseSvcResult.result.purMode});
* {@code functionKind} is {@code reducer} for a reducer, else the reading function's kind
* ({@code component}, {@code hook}, {@code thunk}, ...); {@code via} names the hook the read went
* through ({@code useAppSelector}, a wrapper hook, {@code getState}) or {@code reducer}.
*/
public record StoreAccess(String mode, String slice, @Nullable String field, @Nullable String path, String function,
String functionType, @Nullable String functionKind, @Nullable String module,
String sourceFile,
@Nullable Integer lineNo, @Nullable String via) {
}

View File

@@ -0,0 +1,21 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* Item 194: one slice of a frontend Redux store — {@code slice} is the reducer key it is mounted
* under ({@code state.<slice>}), {@code sliceName} the RTK name (the action-type prefix; usually the
* same). {@code reads}/{@code writes} count access sites over the slice and all its fields.
*/
public record StoreSlice(String slice, @Nullable String sliceName, String module, String sourceFile, int startLine,
int endLine,
@Nullable String stateType, List<Field> fields, int reducers, int reads, int writes) {
/**
* A top-level key of the slice state ({@code state.<slice>.<name>}).
*/
public record Field(String name, @Nullable String type, boolean optional, int reads, int writes) {
}
}

View File

@@ -0,0 +1,17 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
import java.util.List;
/**
* Item 196: one style block — an {@code sx}/{@code style}/{@code styled} literal under a component
* ({@code element} = the JSX tag or styled base) or a CSS rule ({@code styleKind=css}, {@code selector}).
* {@code properties} = its CSS keys (nested selectors flattened), {@code literals} = hard-coded
* colours/lengths, {@code tokens} = the theme tokens it reads, {@code dynamic} = a value the sidecar
* could not classify.
*/
public record StyleBlock(String name, String styleKind, @Nullable String element, @Nullable String selector,
@Nullable String function, String module, String sourceFile, @Nullable Integer lineNo,
@Nullable String properties, @Nullable String literals, boolean dynamic, List<String> tokens) {
}

View File

@@ -0,0 +1,16 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 196: one theme token. {@code declared=true}: a leaf of the project's {@code createTheme}
* ({@code kind=path}, {@code palette.primary.dark}, {@code value} folded through constants,
* {@code constant} the constant it names) or an exported string constant of the theme file
* ({@code kind=constant}); {@code declared=false}: a token the code reads that no theme declares —
* an MUI default such as {@code palette.grey.200}, or a typo. {@code uses} counts project references
* (style blocks and code); MUI's own use of a token is not visible here, so {@code uses=0} means
* "not referenced by project code", never "safe to delete".
*/
public record ThemeToken(String token, @Nullable String kind, @Nullable String value, @Nullable String constant,
boolean declared, @Nullable String module, @Nullable Integer lineNo, int uses) {
}

View File

@@ -0,0 +1,14 @@
package com.agenticcode.neo4jstore.graph;
import org.jspecify.annotations.Nullable;
/**
* Item 196: one read of a theme token. From a style block: {@code styleKind}/{@code element} of the
* block and {@code property} = the CSS key the token feeds; from plain code: {@code context} = the JSX
* attribute ({@code borderColor}) or {@code code}. {@code function} is the component/hook (or the
* module at top level).
*/
public record ThemeUsage(String function, @Nullable String functionKind, @Nullable String module, String sourceFile,
@Nullable Integer lineNo, @Nullable String styleKind, @Nullable String element,
@Nullable String property, @Nullable String context) {
}

View File

@@ -63,5 +63,9 @@ public enum EdgeType {
* the stale sweep; hanging comments off it would leak comment rows into queries that never asked
* for them. Same reasoning as {@link #MENTIONS} vs {@link #REFERENCES} in item 128.
*/
DOCUMENTS
DOCUMENTS,
/**
* Item 193: the same thing in another project — a frontend web-service call and the backend handler serving it, a generated DTO and its Java class, field and field. Built by enrichment, never by a parser.
*/
COUNTERPART_OF
}

View File

@@ -49,5 +49,13 @@ public enum NodeType {
* <p>Attached to the declaration it documents by {@link EdgeType#DOCUMENTS}, never by
* {@code CONTAINS} — see that constant for why. Exposed by {@code /modules/{name}/comments}.
*/
COMMENT
COMMENT,
/**
* Item 194: a Redux Toolkit slice — one mounted piece of the frontend store, named by its reducer key.
*/
STORE_SLICE,
/**
* Item 196: one style block — an {@code sx}/{@code style}/{@code styled()} literal or a CSS rule.
*/
STYLE
}

View File

@@ -264,50 +264,461 @@ public final class JavaParser implements LanguageParser {
* {@code L} suffix); references to another constant in the same class (bare {@code NAME} or
* {@code ThisClass.NAME}) are resolved transitively.
*/
private static Map<String, String> collectConstants(TypeDeclaration<?> type) {
private static Map<String, String> collectConstants(TypeDeclaration<?> type, Set<String> templates) {
String className = type.getNameAsString();
Map<String, Expression> initializers = new LinkedHashMap<>();
Set<String> stringTyped = new HashSet<>();
for (FieldDeclaration field : type.getFields()) {
if (!(field.isStatic() && field.isFinal())) {
continue;
}
for (VariableDeclarator variable : field.getVariables()) {
variable.getInitializer().ifPresent(init -> initializers.put(variable.getNameAsString(), init));
if (variable.getTypeAsString().equals("String")) {
stringTyped.add(variable.getNameAsString());
}
}
}
Map<String, String> resolved = new HashMap<>();
ConstantScope scope = new ConstantScope(className, initializers, stringTyped, new HashMap<>(), templates);
for (String name : initializers.keySet()) {
resolveConstant(name, className, initializers, resolved, new HashSet<>());
resolveConstant(name, scope, new HashSet<>());
}
return resolved;
return scope.resolved();
}
@Nullable
private static String resolveConstant(String name, ConstantScope scope, Set<String> visiting) {
if (scope.resolved().containsKey(name)) {
return scope.resolved().get(name);
}
Expression init = scope.initializers().get(name);
if (init == null || !visiting.add(name)) {
return null; // unknown, or cycle
}
String referenced = referencedConstantName(init, scope.className());
@Nullable String value;
if (referenced != null) {
value = resolveConstant(referenced, scope, visiting);
if (scope.templates().contains(referenced)) {
scope.templates().add(name);
}
} else if (scope.stringTyped().contains(name)) {
value = foldString(name, init, scope, visiting);
} else {
value = literalValue(init);
}
visiting.remove(name);
if (value != null) {
scope.resolved().put(name, value);
}
return value;
}
// -------------------------------------------------------------------------
// Constant value resolution (item 16)
// -------------------------------------------------------------------------
/**
* Item 139: the value of a {@code String} constant built from text blocks, literals, same-class
* constants and {@code +} — {@code CypherQueries}-style query text used to be invisible to
* {@code search/value} because only a single plain literal was resolved. {@code "...".formatted(...)}
* yields its template (with the {@code %s} placeholders) and marks {@code name} a template. Anything
* not resolvable (a method call, another class's constant) makes the whole value {@code null}:
* nothing is guessed.
*/
@Nullable
private static String resolveConstant(String name, String className, Map<String, Expression> initializers,
Map<String, String> resolved, Set<String> visiting) {
if (resolved.containsKey(name)) {
return resolved.get(name);
private static String foldString(String name, Expression expr, ConstantScope scope, Set<String> visiting) {
if (expr instanceof EnclosedExpr enclosed) {
return foldString(name, enclosed.getInner(), scope, visiting);
}
Expression init = initializers.get(name);
if (init == null || !visiting.add(name)) {
return null; // unknown, or cycle
if (expr instanceof TextBlockLiteralExpr text) {
return text.asString();
}
String referenced = referencedConstantName(init, className);
String value;
String referenced = referencedConstantName(expr, scope.className());
if (referenced != null) {
value = resolveConstant(referenced, className, initializers, resolved, visiting);
} else {
value = literalValue(init);
@Nullable String value = resolveConstant(referenced, scope, visiting);
if (value != null && scope.templates().contains(referenced)) {
scope.templates().add(name);
}
return value;
}
visiting.remove(name);
if (value != null) {
resolved.put(name, value);
if (expr instanceof BinaryExpr binary && binary.getOperator() == BinaryExpr.Operator.PLUS) {
@Nullable String left = foldString(name, binary.getLeft(), scope, visiting);
@Nullable String right = left == null ? null : foldString(name, binary.getRight(), scope, visiting);
return left == null || right == null ? null : left + right;
}
return value;
if (expr instanceof MethodCallExpr call && call.getNameAsString().equals("formatted")
&& call.getScope().isPresent()) {
@Nullable String template = foldString(name, call.getScope().get(), scope, visiting);
if (template != null) {
scope.templates().add(name);
}
return template;
}
return literalValue(expr);
}
@Override
public ParseResult parse(String sourceFile, String content) {
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();
com.github.javaparser.ParseResult<CompilationUnit> parsed =
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
CompilationUnit unit = parsed.getResult()
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
TypeResolver types = new TypeResolver(unit);
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
TypeFacts facts = facts(type);
String className = type.getNameAsString();
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
// and the API still accepts it as the convenient short form.
// Local and anonymous classes have no FQN and keep the simple name.
String fqn = type.getFullyQualifiedName().orElse(className);
Map<String, String> moduleProps = new HashMap<>();
moduleProps.put("simpleName", className);
moduleProps.put("fqn", fqn);
// J3: distinguish interfaces from classes (interface -> implementation resolution).
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
moduleProps.put("moduleKind", facts.kind());
// The JPA/Panache heuristics below are class/interface notions — a record is never a
// Panache repository, and generalizing them would apply guesswork to types the assumption
// was never written for.
@Nullable String repositoryEntity = null;
if (type instanceof ClassOrInterfaceDeclaration cls) {
// J1: tag repository classes with their managed entity so the enrichment step can map
// a repository method call to the entity's DB_TABLE.
repositoryEntity = repositoryEntityType(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
// J7: a project base class that passes Panache-ness one level up to its subclasses
// (e.g. AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>).
// Tag it with its entity type parameter + own parameter list so the graph-side
// enrichment step can bind a concrete subclass's EXTENDS type argument to it.
@Nullable String panacheParam = panacheEntityTypeParam(cls);
if (panacheParam != null) {
moduleProps.put("panacheEntityTypeParam", panacheParam);
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
}
// J1b: a repository-named interface with no generic entity argument anywhere in sight
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
if (repositoryEntity == null && panacheParam == null
&& cls.isInterface() && isRepositoryReceiverName(className)) {
repositoryEntity = fallbackRepositoryEntity(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
}
}
type.getJavadoc().ifPresent(jd -> {
String firstLine = jd.getDescription().toText().lines()
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
if (!firstLine.isEmpty()) {
moduleProps.put("description", firstLine);
}
});
// Collected before the module props below, because item 130's class-level @Path may be
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
// a @Column name does.
Set<String> templateConstants = new HashSet<>();
Map<String, String> constants = collectConstants(type, templateConstants);
// Item 29: generic annotation capture for search_annotation, independent of any
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
@Nullable String typeAnnotations = annotationNames(type);
if (typeAnnotations != null) {
moduleProps.put("annotations", typeAnnotations);
}
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
// path lives half on the class and half on the method — composing it needed the source.
@Nullable String typePath = restPath(type, className, constants);
if (typePath != null) {
moduleProps.put("restPath", typePath);
}
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
type.getBegin().map(p -> p.line).orElse(1),
type.getEnd().map(p -> p.line).orElse(1),
null, null, moduleProps);
nodes.add(typeNode);
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
// and cross-class CALLS targets) by name, so repeated references don't create
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
Map<String, AstNode> referencedModules = new HashMap<>();
for (ClassOrInterfaceType extended : facts.extended()) {
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
Map<String, String> extendsProps = extendsTypeArgs(extended);
edges.add(extendsProps.isEmpty()
? edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine())
: edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine(), null, extendsProps));
}
for (ClassOrInterfaceType implemented : facts.implemented()) {
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
}
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
boolean isEntity = annotation(type, "Entity").isPresent()
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
if (isEntity && !isMappedSuperclass) {
String tableName = resolveTableName(type, className, constants);
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
nodes.add(tableNode);
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
}
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
// belongs to the class that declares it.
Map<String, AstNode> fieldsByName = new HashMap<>();
Map<String, String> fieldTypes = enclosingFieldTypes(type);
for (FieldDeclaration field : type.getFields()) {
boolean isConstant = field.isStatic() && field.isFinal();
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
@Nullable String fieldAnnotations = annotationNames(field);
for (VariableDeclarator variable : field.getVariables()) {
String dataType = variable.getTypeAsString();
fieldTypes.put(variable.getNameAsString(), dataType);
int startLine = field.getBegin().map(p -> p.line).orElse(1);
int endLine = field.getEnd().map(p -> p.line).orElse(1);
AstNode fieldNode;
if (column != null) {
Map<String, String> props = columnProperties(field, column, variable, className, constants);
if (fieldAnnotations != null) {
props.put("annotations", fieldAnnotations);
}
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
startLine, endLine, dataType, null, props);
} else {
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
Map<String, String> fieldProps = new LinkedHashMap<>();
if (fieldAnnotations != null) {
fieldProps.put("annotations", fieldAnnotations);
}
if (value != null && templateConstants.contains(variable.getNameAsString())) {
fieldProps.put("valueKind", "template"); // item 139: `"...".formatted(...)`
}
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
fieldProps.isEmpty() ? null : fieldProps);
}
nodes.add(fieldNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
fieldsByName.put(variable.getNameAsString(), fieldNode);
}
}
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
// which is the failure mode item 114 is about.
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
for (Parameter component : record.getParameters()) {
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
line, line, component.getTypeAsString(), null,
Map.of("recordComponent", "true"));
nodes.add(componentNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
fieldsByName.put(component.getNameAsString(), componentNode);
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
}
}
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
// The constant's own class body (a per-constant override) is not modelled as a
// separate module; its methods would need an identity no source-level name gives them.
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
line, line, className, null, Map.of("enumConstant", "true"));
nodes.add(constantNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
fieldsByName.put(constant.getNameAsString(), constantNode);
}
}
for (AnnotationMemberDeclaration member : type.getMembers().stream()
.filter(AnnotationMemberDeclaration.class::isInstance)
.map(AnnotationMemberDeclaration.class::cast).toList()) {
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
Map<String, String> memberProps = new HashMap<>();
memberProps.put("annotationMember", "true");
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
// it carries, not which methods it declares.
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
line, line, member.getType().asString(), null, memberProps);
nodes.add(memberNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
fieldsByName.put(member.getNameAsString(), memberNode);
}
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
Map<String, AstNode> methods = new HashMap<>();
List<CallableDeclaration<?>> callables = new ArrayList<>();
List<AstNode> callableNodes = new ArrayList<>();
for (MethodDeclaration method : type.getMethods()) {
@Nullable String methodAnnotations = annotationNames(method);
Map<String, String> methodProps = new HashMap<>();
if (methodAnnotations != null) {
methodProps.put("annotations", methodAnnotations);
}
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
// means "the class path itself") and the HTTP verb annotation.
@Nullable String methodPath = restPath(method, className, constants);
if (methodPath != null) {
methodProps.put("restPath", methodPath);
}
@Nullable String httpMethod = httpMethod(method);
if (httpMethod != null) {
methodProps.put("httpMethod", httpMethod);
}
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
// implement/not override" without reading the base class source by hand.
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
method.getBegin().map(p -> p.line).orElse(1),
method.getEnd().map(p -> p.line).orElse(1),
method.getTypeAsString(), null, methodProps);
methods.put(method.getNameAsString(), methodNode);
nodes.add(methodNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
callables.add(method);
callableNodes.add(methodNode);
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
// its JPQL/native-SQL DB access is captured here, at the declaration.
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
}
for (ConstructorDeclaration ctor : type.getConstructors()) {
@Nullable String ctorAnnotations = annotationNames(ctor);
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
ctor.getBegin().map(p -> p.line).orElse(1),
ctor.getEnd().map(p -> p.line).orElse(1),
className, null,
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
nodes.add(ctorNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
callables.add(ctor);
callableNodes.add(ctorNode);
}
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
for (int ci = 0; ci < callables.size(); ci++) {
CallableDeclaration<?> callable = callables.get(ci);
AstNode callableNode = callableNodes.get(ci);
Set<String> shadowed = new HashSet<>();
// declaredTypes: field types plus this callable's parameter and local types, for
// resolving a typed receiver (svc.method()) to its class.
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
int paramPosition = 0;
for (Parameter parameter : callable.getParameters()) {
shadowed.add(parameter.getNameAsString());
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
nodes.add(paramNode);
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
}
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
vde.getVariables().forEach(v -> {
shadowed.add(v.getNameAsString());
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
}));
// Every *nested* parameter too — lambda and catch parameters. They are bound names
// like any local, but they are not VariableDeclarationExpr and not the callable's own
// parameter list, so both loops above miss them. Names only: an implicit lambda
// parameter has no usable declared type (see isProbableFieldReceiver).
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
@Nullable Expression scope = call.getScope().orElse(null);
if (scope == null || scope.isThisExpr()) {
// Intra-class call.
AstNode target = methods.get(call.getNameAsString());
if (target != null) {
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
callProps(call, CallKind.METHOD_CALL)));
}
} else {
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
// callees/call-tree (which traverse from the module) span files uniformly
// with Natural's module-level CALLNAT.
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
// Item 116b: the receiver names a field this class does not declare — almost
// always one inherited from a supertype, which lives in another file the
// parser never sees. Record the receiver's *name* against a placeholder so
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
// Dropping it here is what left a service with 185 tests reporting no callers.
AstNode marker = referencedModule(referencedModules, nodes,
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
}
if (targetClass != null) {
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
// J5: carry the invoked method + enclosing function so enrichment can map
// this cross-class call's arguments to the callee method's parameters.
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
}
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
boolean staticReceiver = scope.isNameExpr()
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
callableNode, nodes, edges);
}
}
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
}
}
// J2: CDI injection + class-literal wiring edges.
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
// Item 128: the reference index — imports, declared type positions, annotation usages.
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
}
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
// with nested types would emit every comment once per type.
JavaComments.collect(unit, "java", sourceFile, nodes, edges, UnaryOperator.identity());
return new ParseResult(nodes, edges);
}
/**
@@ -1191,358 +1602,12 @@ public final class JavaParser implements LanguageParser {
// DI + class-literal wiring edges (item J2)
// -------------------------------------------------------------------------
@Override
public ParseResult parse(String sourceFile, String content) {
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();
com.github.javaparser.ParseResult<CompilationUnit> parsed =
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
CompilationUnit unit = parsed.getResult()
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
TypeResolver types = new TypeResolver(unit);
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
TypeFacts facts = facts(type);
String className = type.getNameAsString();
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
// and the API still accepts it as the convenient short form.
// Local and anonymous classes have no FQN and keep the simple name.
String fqn = type.getFullyQualifiedName().orElse(className);
Map<String, String> moduleProps = new HashMap<>();
moduleProps.put("simpleName", className);
moduleProps.put("fqn", fqn);
// J3: distinguish interfaces from classes (interface -> implementation resolution).
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
moduleProps.put("moduleKind", facts.kind());
// The JPA/Panache heuristics below are class/interface notions — a record is never a
// Panache repository, and generalizing them would apply guesswork to types the assumption
// was never written for.
@Nullable String repositoryEntity = null;
if (type instanceof ClassOrInterfaceDeclaration cls) {
// J1: tag repository classes with their managed entity so the enrichment step can map
// a repository method call to the entity's DB_TABLE.
repositoryEntity = repositoryEntityType(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
// J7: a project base class that passes Panache-ness one level up to its subclasses
// (e.g. AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>).
// Tag it with its entity type parameter + own parameter list so the graph-side
// enrichment step can bind a concrete subclass's EXTENDS type argument to it.
@Nullable String panacheParam = panacheEntityTypeParam(cls);
if (panacheParam != null) {
moduleProps.put("panacheEntityTypeParam", panacheParam);
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
}
// J1b: a repository-named interface with no generic entity argument anywhere in sight
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
if (repositoryEntity == null && panacheParam == null
&& cls.isInterface() && isRepositoryReceiverName(className)) {
repositoryEntity = fallbackRepositoryEntity(cls);
if (repositoryEntity != null) {
moduleProps.put("repositoryEntity", repositoryEntity);
}
}
}
type.getJavadoc().ifPresent(jd -> {
String firstLine = jd.getDescription().toText().lines()
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
if (!firstLine.isEmpty()) {
moduleProps.put("description", firstLine);
}
});
// Collected before the module props below, because item 130's class-level @Path may be
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
// a @Column name does.
Map<String, String> constants = collectConstants(type);
// Item 29: generic annotation capture for search_annotation, independent of any
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
@Nullable String typeAnnotations = annotationNames(type);
if (typeAnnotations != null) {
moduleProps.put("annotations", typeAnnotations);
}
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
// path lives half on the class and half on the method — composing it needed the source.
@Nullable String typePath = restPath(type, className, constants);
if (typePath != null) {
moduleProps.put("restPath", typePath);
}
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
type.getBegin().map(p -> p.line).orElse(1),
type.getEnd().map(p -> p.line).orElse(1),
null, null, moduleProps);
nodes.add(typeNode);
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
// and cross-class CALLS targets) by name, so repeated references don't create
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
Map<String, AstNode> referencedModules = new HashMap<>();
for (ClassOrInterfaceType extended : facts.extended()) {
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
Map<String, String> extendsProps = extendsTypeArgs(extended);
edges.add(extendsProps.isEmpty()
? edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine())
: edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine(), null, extendsProps));
}
for (ClassOrInterfaceType implemented : facts.implemented()) {
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
}
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
boolean isEntity = annotation(type, "Entity").isPresent()
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
if (isEntity && !isMappedSuperclass) {
String tableName = resolveTableName(type, className, constants);
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
nodes.add(tableNode);
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
}
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
// belongs to the class that declares it.
Map<String, AstNode> fieldsByName = new HashMap<>();
Map<String, String> fieldTypes = enclosingFieldTypes(type);
for (FieldDeclaration field : type.getFields()) {
boolean isConstant = field.isStatic() && field.isFinal();
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
@Nullable String fieldAnnotations = annotationNames(field);
for (VariableDeclarator variable : field.getVariables()) {
String dataType = variable.getTypeAsString();
fieldTypes.put(variable.getNameAsString(), dataType);
int startLine = field.getBegin().map(p -> p.line).orElse(1);
int endLine = field.getEnd().map(p -> p.line).orElse(1);
AstNode fieldNode;
if (column != null) {
Map<String, String> props = columnProperties(field, column, variable, className, constants);
if (fieldAnnotations != null) {
props.put("annotations", fieldAnnotations);
}
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
startLine, endLine, dataType, null, props);
} else {
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
fieldAnnotations != null ? Map.of("annotations", fieldAnnotations) : null);
}
nodes.add(fieldNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
fieldsByName.put(variable.getNameAsString(), fieldNode);
}
}
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
// which is the failure mode item 114 is about.
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
for (Parameter component : record.getParameters()) {
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
line, line, component.getTypeAsString(), null,
Map.of("recordComponent", "true"));
nodes.add(componentNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
fieldsByName.put(component.getNameAsString(), componentNode);
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
}
}
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
// The constant's own class body (a per-constant override) is not modelled as a
// separate module; its methods would need an identity no source-level name gives them.
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
line, line, className, null, Map.of("enumConstant", "true"));
nodes.add(constantNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
fieldsByName.put(constant.getNameAsString(), constantNode);
}
}
for (AnnotationMemberDeclaration member : type.getMembers().stream()
.filter(AnnotationMemberDeclaration.class::isInstance)
.map(AnnotationMemberDeclaration.class::cast).toList()) {
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
Map<String, String> memberProps = new HashMap<>();
memberProps.put("annotationMember", "true");
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
// it carries, not which methods it declares.
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
line, line, member.getType().asString(), null, memberProps);
nodes.add(memberNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
fieldsByName.put(member.getNameAsString(), memberNode);
}
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
Map<String, AstNode> methods = new HashMap<>();
List<CallableDeclaration<?>> callables = new ArrayList<>();
List<AstNode> callableNodes = new ArrayList<>();
for (MethodDeclaration method : type.getMethods()) {
@Nullable String methodAnnotations = annotationNames(method);
Map<String, String> methodProps = new HashMap<>();
if (methodAnnotations != null) {
methodProps.put("annotations", methodAnnotations);
}
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
// means "the class path itself") and the HTTP verb annotation.
@Nullable String methodPath = restPath(method, className, constants);
if (methodPath != null) {
methodProps.put("restPath", methodPath);
}
@Nullable String httpMethod = httpMethod(method);
if (httpMethod != null) {
methodProps.put("httpMethod", httpMethod);
}
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
// implement/not override" without reading the base class source by hand.
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
method.getBegin().map(p -> p.line).orElse(1),
method.getEnd().map(p -> p.line).orElse(1),
method.getTypeAsString(), null, methodProps);
methods.put(method.getNameAsString(), methodNode);
nodes.add(methodNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
callables.add(method);
callableNodes.add(methodNode);
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
// its JPQL/native-SQL DB access is captured here, at the declaration.
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
}
for (ConstructorDeclaration ctor : type.getConstructors()) {
@Nullable String ctorAnnotations = annotationNames(ctor);
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
ctor.getBegin().map(p -> p.line).orElse(1),
ctor.getEnd().map(p -> p.line).orElse(1),
className, null,
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
nodes.add(ctorNode);
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
callables.add(ctor);
callableNodes.add(ctorNode);
}
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
for (int ci = 0; ci < callables.size(); ci++) {
CallableDeclaration<?> callable = callables.get(ci);
AstNode callableNode = callableNodes.get(ci);
Set<String> shadowed = new HashSet<>();
// declaredTypes: field types plus this callable's parameter and local types, for
// resolving a typed receiver (svc.method()) to its class.
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
int paramPosition = 0;
for (Parameter parameter : callable.getParameters()) {
shadowed.add(parameter.getNameAsString());
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
nodes.add(paramNode);
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
}
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
vde.getVariables().forEach(v -> {
shadowed.add(v.getNameAsString());
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
}));
// Every *nested* parameter too — lambda and catch parameters. They are bound names
// like any local, but they are not VariableDeclarationExpr and not the callable's own
// parameter list, so both loops above miss them. Names only: an implicit lambda
// parameter has no usable declared type (see isProbableFieldReceiver).
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
@Nullable Expression scope = call.getScope().orElse(null);
if (scope == null || scope.isThisExpr()) {
// Intra-class call.
AstNode target = methods.get(call.getNameAsString());
if (target != null) {
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
callProps(call, CallKind.METHOD_CALL)));
}
} else {
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
// callees/call-tree (which traverse from the module) span files uniformly
// with Natural's module-level CALLNAT.
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
// Item 116b: the receiver names a field this class does not declare — almost
// always one inherited from a supertype, which lives in another file the
// parser never sees. Record the receiver's *name* against a placeholder so
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
// Dropping it here is what left a service with 185 tests reporting no callers.
AstNode marker = referencedModule(referencedModules, nodes,
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
}
if (targetClass != null) {
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
// J5: carry the invoked method + enclosing function so enrichment can map
// this cross-class call's arguments to the callee method's parameters.
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
props.put("calleeMethod", call.getNameAsString());
props.put("callerFn", callableNode.name());
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
}
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
boolean staticReceiver = scope.isNameExpr()
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
callableNode, nodes, edges);
}
}
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
}
}
// J2: CDI injection + class-literal wiring edges.
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
// Item 128: the reference index — imports, declared type positions, annotation usages.
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
}
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
// with nested types would emit every comment once per type.
JavaComments.collect(unit, "java", sourceFile, nodes, edges, UnaryOperator.identity());
return new ParseResult(nodes, edges);
/**
* The per-class state of constant resolution. {@code templates} collects the names whose value is
* a {@code .formatted(...)} template rather than the final string (item 139).
*/
private record ConstantScope(String className, Map<String, Expression> initializers, Set<String> stringTyped,
Map<String, String> resolved, Set<String> templates) {
}
/**

View File

@@ -496,4 +496,35 @@ class JavaParserTest {
assertEquals("ServiceEndpoint: partner.update.partnercs.Update\n\nUpmsObject: PartnerCs",
commentAt(result, 2).value());
}
@Test
void stringConstantsFoldTextBlocksConcatenationsAndTemplates() {
// Item 139: CypherQueries-style constants used to have no value, so search/value never found them.
String content = """
package q;
public class Queries {
private static final String ROW = "n.name AS name";
public static final String CORE = \"""
MATCH (n:AstNode {project: $project})
\""";
public static final String LIST = CORE + "RETURN " + ROW;
public static final String KEYED = \"""
MERGE (a)-[r:%s]->(b)
\""".formatted("CALLS");
public static final String ALIAS = KEYED;
public static final String OPAQUE = CORE + helper();
public static final int COUNT = 1 + 2;
static String helper() { return "x"; }
}
""";
LanguageParser.ParseResult result = parser.parse("Queries.java", content);
assertEquals("MATCH (n:AstNode {project: $project})\n", findNode(result, NodeType.CONSTANT, "CORE").value());
assertEquals("MATCH (n:AstNode {project: $project})\nRETURN n.name AS name", findNode(result, NodeType.CONSTANT, "LIST").value());
AstNode keyed = findNode(result, NodeType.CONSTANT, "KEYED");
assertEquals("MERGE (a)-[r:%s]->(b)\n", keyed.value());
assertEquals("template", java.util.Objects.requireNonNull(keyed.properties()).get("valueKind"));
assertEquals("template", java.util.Objects.requireNonNull(findNode(result, NodeType.CONSTANT, "ALIAS").properties()).get("valueKind"));
assertNull(findNode(result, NodeType.CONSTANT, "OPAQUE").value(), "a method call is not folded - nothing is guessed");
assertNull(findNode(result, NodeType.CONSTANT, "COUNT").value(), "only String constants are concatenated");
}
}

View File

@@ -3,6 +3,7 @@ package com.agenticcode.parsernatural;
import com.agenticcode.parsercore.ast.model.AstEdge;
import com.agenticcode.parsercore.ast.model.AstNode;
import com.agenticcode.parsercore.ast.model.CopycodeProperties;
import com.agenticcode.parsercore.ast.model.NodeType;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import org.jspecify.annotations.Nullable;
@@ -144,11 +145,25 @@ public final class CopycodePreprocessor {
*/
public static ParseResult remap(String hostSourceFile, ParseResult raw, List<LineOrigin> origins) {
List<AstNode> nodes = new ArrayList<>(raw.nodes().size());
int hostLines = 0;
for (LineOrigin o : origins) {
if (o.sourceFile().equals(hostSourceFile)) {
hostLines = Math.max(hostLines, o.line());
}
}
for (AstNode n : raw.nodes()) {
if (!n.sourceFile().equals(hostSourceFile)) {
nodes.add(n); // placeholder / already-foreign node
continue;
}
if (n.type() == NodeType.MODULE) {
// Item 201: the module node is the host file itself, whatever its first or last
// expanded line came from. A program that starts with an INCLUDE used to get the
// copycode as sourceFile here and became a second MODULE next to the Tier-1 shell.
nodes.add(new AstNode(n.id(), n.type(), n.name(), hostSourceFile, n.language(),
1, Math.max(hostLines, 1), n.dataType(), n.value(), n.properties()));
continue;
}
LineOrigin start = originAt(origins, n.startLine(), hostSourceFile);
LineOrigin end = originAt(origins, n.endLine(), hostSourceFile);
nodes.add(new AstNode(n.id(), n.type(), n.name(), start.sourceFile(), n.language(),
@@ -271,7 +286,6 @@ public final class CopycodePreprocessor {
}
private static String stripInlineComment(String s) {
int comment = s.indexOf("/*");
return comment >= 0 ? s.substring(0, comment) : s;
return NaturalLines.stripInlineComment(s); // item 152: the shared, quote-aware rule
}
}

View File

@@ -22,7 +22,7 @@ public final class NaturalLineCounter implements LineCounter {
if (trimmed.charAt(0) == '*') {
return false;
}
int inline = line.indexOf("/*");
int inline = NaturalLines.commentStart(line); // item 152: quote-aware
String code = inline >= 0 ? line.substring(0, inline) : line;
return !code.isBlank();
}

View File

@@ -47,14 +47,38 @@ final class NaturalLines {
}
/**
* @return {@code s} truncated at the first {@code /*}, which starts a Natural comment running to
* end of line.
* @return {@code s} truncated at the {@code /*} that starts a Natural comment running to end of
* line, or {@code s} unchanged if it has none (see {@link #commentStart}).
*/
static String stripInlineComment(String s) {
int comment = s.indexOf("/*");
int comment = commentStart(s);
return comment >= 0 ? s.substring(0, comment) : s;
}
/**
* Item 152: the index of the {@code /*} that starts a trailing comment, or -1 — quote-aware, so
* {@code MOVE 'A/*B' TO #X} and {@code MOVE "A/*B" TO #X} hold no comment. Natural delimits a
* literal with {@code '} or {@code "}; the other character is plain text inside it, and a doubled
* delimiter toggles twice, which is the same as not toggling. A literal still open at end of line
* means "no comment" — the conservative reading, which truncates nothing.
*/
static int commentStart(String line) {
char quote = 0;
for (int i = 0; i < line.length() - 1; i++) {
char c = line.charAt(i);
if (quote != 0) {
if (c == quote) {
quote = 0;
}
} else if (c == '\'' || c == '"') {
quote = c;
} else if (c == '/' && line.charAt(i + 1) == '*') {
return i;
}
}
return -1;
}
/**
* Advances {@code matcher} to the first match whose start lies outside a quoted string literal,
* leaving the matcher positioned on it so {@code group()}/{@code end()} apply to that match.

View File

@@ -1121,7 +1121,7 @@ public final class NaturalParser implements LanguageParser {
blockStart = -1;
blockKind = null;
}
int inline = inlineCommentStart(line);
int inline = NaturalLines.commentStart(line);
if (inline >= 0) {
emitComment(sourceFile, i + 1, i + 1, line.substring(inline + 2),
CommentProperties.KIND_NATURAL_INLINE, targets, nodes, edges);
@@ -1177,26 +1177,6 @@ public final class NaturalParser implements LanguageParser {
return stripped.substring(i).stripLeading();
}
/**
* @return the index of the {@code /*} that starts a trailing comment on a code line, or -1.
*
* <p>Quote-aware, unlike {@link NaturalLines#stripInlineComment}: {@code MOVE 'A/*B' TO #X} holds
* no comment. That the strip path is not quote-aware is a separate, pre-existing precision bug —
* there it merely truncates a line, here it would manufacture a comment node out of a literal.
*/
private static int inlineCommentStart(String line) {
boolean inQuote = false;
for (int i = 0; i < line.length() - 1; i++) {
char c = line.charAt(i);
if (c == '\'') {
inQuote = !inQuote; // a doubled '' toggles twice, which is the same as not toggling
} else if (!inQuote && c == '/' && line.charAt(i + 1) == '*') {
return i;
}
}
return -1;
}
private ParseResult parseModule(String sourceFile, String[] lines) {
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();

View File

@@ -0,0 +1,49 @@
package com.agenticcode.parsernatural;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Item 152: the trailing-comment rule every Natural scan path shares is quote-aware.
*/
class NaturalLinesCommentTest {
@Test
void aSlashStarInsideALiteralIsNotAComment() {
assertEquals("MOVE 'A/*B' TO #X", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X"));
assertEquals("MOVE \"A/*B\" TO #X", NaturalLines.stripInlineComment("MOVE \"A/*B\" TO #X"));
assertEquals("MOVE 'A/*B' TO #X ", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X /* real one"));
}
@Test
void theOtherDelimiterIsPlainTextInsideALiteral() {
assertEquals("MOVE 'it\"s /* x' TO #X", NaturalLines.stripInlineComment("MOVE 'it\"s /* x' TO #X"));
assertEquals("MOVE 'it''s' TO #X ", NaturalLines.stripInlineComment("MOVE 'it''s' TO #X /* doubled quote"));
}
@Test
void plainCommentsAndOpenLiteralsBehave() {
assertEquals("CALLNAT 'MOD' ", NaturalLines.stripInlineComment("CALLNAT 'MOD' /* comment"));
assertEquals("", NaturalLines.stripInlineComment("/* whole line"));
assertEquals(-1, NaturalLines.commentStart("MOVE 'unterminated /* TO #X"), "an open literal truncates nothing");
assertEquals(-1, NaturalLines.commentStart("no comment here"));
}
@Test
void theParserKeepsTheStatementBehindAQuotedSlashStar() throws Exception {
String src = """
DEFINE DATA
LOCAL
01 #X (A10)
END-DEFINE
MOVE 'A/*B' TO #X
CALLNAT 'TARGETQ' #X
END
""";
var result = new NaturalParser().parse("QUOTED.nat", src);
var writes = result.edges().stream()
.filter(e -> e.type() == com.agenticcode.parsercore.ast.model.EdgeType.WRITES && e.lineNo() == 5).count();
assertEquals(1, writes, "MOVE 'A/*B' TO #X still writes #X");
}
}

View File

@@ -1,10 +1,6 @@
package com.agenticcode.parsernatural;
import com.agenticcode.parsercore.ast.model.AstEdge;
import com.agenticcode.parsercore.ast.model.AstNode;
import com.agenticcode.parsercore.ast.model.CommentProperties;
import com.agenticcode.parsercore.ast.model.EdgeType;
import com.agenticcode.parsercore.ast.model.NodeType;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser;
import org.junit.jupiter.api.Test;
@@ -2047,4 +2043,32 @@ class NaturalParserTest {
assertEquals(List.of(), comments(parser.parse("WPARTX0S.nat", content)));
}
@Test
void aHostThatStartsWithAnIncludeKeepsItsOwnFileOnTheModuleNode() {
// Item 201: ZDTSTBP6.nat in upms is `INCLUDE ZDTSTBC6` / END. Expanded line 1 originates in the
// copycode, and the module node used to be remapped onto it -> a second MODULE named after the
// program with the .cpy as sourceFile, next to the Tier-1 shell.
String host = """
INCLUDE ONLYCPY
*
END
""";
String copycode = """
* nothing but a comment
""";
CopycodeResolver resolver = name -> "ONLYCPY".equalsIgnoreCase(name)
? new CopycodeResolver.Copycode("copycode/ONLYCPY.cpy", copycode)
: null;
LanguageParser.ParseResult result = parser.parse("program/ZDTSTBP6.nat", host, resolver);
List<AstNode> modules = result.nodes().stream()
.filter(n -> n.type() == NodeType.MODULE && n.name().equals("ZDTSTBP6")).toList();
assertEquals(1, modules.size(), "exactly one MODULE node for the host");
AstNode module = modules.get(0);
assertEquals("program/ZDTSTBP6.nat", module.sourceFile());
assertEquals(1, module.startLine());
// the parser counts the trailing newline as a line, like it does for any host: 4 here
assertEquals(host.split("\n", -1).length, module.endLine(), "the host's own line count, not an expanded one");
assertNull(java.util.Objects.requireNonNull(module.properties()).get("viaCopycode"));
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="CheckStyle-IDEA-Module" serialisationVersion="2">
<option name="activeLocationsIds" />
</component>
</module>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.agenticcode</groupId>
<artifactId>agenticcode</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>ac-parser-typescript</artifactId>
<packaging>jar</packaging>
<name>ac-parser-typescript</name>
<description>TypeScript/React source parser: Tier-1 regex coarse scan in Java, Tier-2 facts from the Node sidecar
(roadmap item 192)
</description>
<dependencies>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-core</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
</dependency>
<!-- the sidecar's JSON facts document; version managed by the Quarkus BOM -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.agenticcode</groupId>
<artifactId>ac-mvn-plugins</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1 @@
node_modules/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
{
"name": "ac-ts-sidecar",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ac-ts-sidecar",
"version": "1.0.0",
"dependencies": {
"typescript": "5.9.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "ac-ts-sidecar",
"version": "1.0.0",
"private": true,
"description": "AgenticCode Tier-2 facts extractor for TypeScript/React on the TypeScript compiler API (roadmap item 192)",
"type": "module",
"main": "extract.mjs",
"engines": {
"node": ">=20"
},
"dependencies": {
"typescript": "5.9.3"
}
}

View File

@@ -0,0 +1,77 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.LocMetrics;
import com.agenticcode.parsercore.ast.spi.LineCounter;
/**
* LoC/SLoC counter for plain CSS (roadmap item 192). CSS has only {@code /* ... *}{@code /} comments
* and {@code "..."}/{@code '...'} strings (a {@code /*} inside {@code url("/*x")} is content, not a
* comment). Deterministic.
*/
public final class CssLineCounter implements LineCounter {
static final String LANGUAGE = "css";
@Override
public String language() {
return LANGUAGE;
}
@Override
public LocMetrics count(String content) {
int loc = LocMetrics.physicalLineCount(content);
int sloc = 0;
boolean lineHasCode = false;
boolean inBlock = false;
char quote = 0;
int n = content.length();
int i = 0;
while (i < n) {
char c = content.charAt(i);
if (c == '\n') {
if (lineHasCode) {
sloc++;
}
lineHasCode = false;
i++;
} else if (c == '\r') {
i++;
} else if (inBlock) {
if (c == '*' && i + 1 < n && content.charAt(i + 1) == '/') {
inBlock = false;
i += 2;
} else {
i++;
}
} else if (quote != 0) {
if (!Character.isWhitespace(c)) {
lineHasCode = true;
}
if (c == '\\' && i + 1 < n) {
i += 2;
} else {
if (c == quote) {
quote = 0;
}
i++;
}
} else if (c == '/' && i + 1 < n && content.charAt(i + 1) == '*') {
inBlock = true;
i += 2;
} else if (c == '"' || c == '\'') {
quote = c;
lineHasCode = true;
i++;
} else {
if (!Character.isWhitespace(c)) {
lineHasCode = true;
}
i++;
}
}
if (lineHasCode) {
sloc++;
}
return new LocMetrics(loc, sloc);
}
}

View File

@@ -0,0 +1,508 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.CoarseScanner;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import org.jspecify.annotations.Nullable;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Tier-1 coarse scanner for TypeScript/TSX and plain CSS (roadmap item 192): a pure-Java, per-file
* regex outline that needs no Node. It yields the {@code MODULE} shell (with {@code sourceHash},
* {@code loc}/{@code sloc}, {@code simpleName}, {@code workspace}, {@code generated}), one
* {@code FUNCTION} per top-level function / arrow function / class (property {@code kind}:
* {@code function}, {@code component}, {@code hook}, {@code thunk}, {@code styled}, {@code class};
* {@code exported}), one {@code DATA_STRUCTURE} per top-level {@code interface}/{@code type}/{@code
* enum}, and a {@code REFERENCES} edge per import to a placeholder {@code MODULE} ({@code sourceFile
* == ""}) named by {@link TypeScriptModuleNames#resolveImport}. npm packages are listed on the module
* as {@code externalImports} and get no placeholder. Calls, store access, DTO bindings and styles are
* Tier-2 and come from the sidecar facts.
*
* <p>Declaration end lines come from brace matching that skips strings, templates and comments —
* good enough for an outline; the sidecar's positions replace them.
*/
public final class TypeScriptCoarseScanner implements CoarseScanner {
/**
* The frontend's runtime dependencies (root package.json, 2026-09-22); the server passes the real list.
*/
public static final Set<String> DEFAULT_EXTERNAL_PACKAGES = Set.of(
"react", "react-dom", "react-redux", "lodash", "moment", "zod", "notistack", "jwt-decode",
"react-oidc-context", "ts-object-path", "react-dropzone", "ag-grid-react", "ag-grid-enterprise",
"ag-grid-community", "vite", "jest", "typescript");
private static final Pattern IMPORT = Pattern.compile(
"^\\s*(?:import|export)\\s+(?:type\\s+)?(?:([^'\"`;]*?)\\s+from\\s+)?['\"]([^'\"]+)['\"]", Pattern.MULTILINE);
private static final Pattern DYNAMIC_IMPORT = Pattern.compile("\\bimport\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)");
private static final Pattern FUNCTION_DECL = Pattern.compile(
"^(export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*([A-Za-z_$][\\w$]*)?", Pattern.MULTILINE);
private static final Pattern CLASS_DECL = Pattern.compile(
"^(export\\s+)?(?:default\\s+)?(?:abstract\\s+)?class\\s+([A-Za-z_$][\\w$]*)", Pattern.MULTILINE);
private static final Pattern CONST_DECL = Pattern.compile(
"^(export\\s+)?(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]*?)?=\\s*([^\\n]*)", Pattern.MULTILINE);
private static final Pattern TYPE_DECL = Pattern.compile(
"^(export\\s+)?(?:declare\\s+)?(interface|type|enum)\\s+([A-Za-z_$][\\w$]*)", Pattern.MULTILINE);
private static final Pattern ARROW_HEAD = Pattern.compile(
"^(?:async\\s+)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[^=]*?)?=>|^(?:async\\s+)?<[^>]*>\\s*\\(");
private static final Pattern THUNK_HEAD = Pattern.compile("^create(?:App)?AsyncThunk\\b");
private static final Pattern STYLED_HEAD = Pattern.compile("^styled\\s*[(<]");
private static final Pattern WRAPPED_HEAD = Pattern.compile(
"^(?:React\\.)?(?:memo|forwardRef|lazy)\\s*[(<]");
private final Set<String> workspaces;
private final Set<String> externalPackages;
private final TypeScriptLineCounter tsCounter = new TypeScriptLineCounter();
private final CssLineCounter cssCounter = new CssLineCounter();
public TypeScriptCoarseScanner() {
this(Set.of(), DEFAULT_EXTERNAL_PACKAGES);
}
/**
* @param workspaces npm workspace names (first path segment of their files), so a bare
* {@code pur-ui-common} import resolves to that workspace's barrel
* @param externalPackages npm dependency names; imports of them are recorded, not placeholdered
*/
public TypeScriptCoarseScanner(Set<String> workspaces, Set<String> externalPackages) {
this.workspaces = Set.copyOf(workspaces);
this.externalPackages = Set.copyOf(externalPackages);
}
/**
* Tier-1 resolution of one import (see {@link TypeScriptModuleNames#resolveImport}).
*/
static @Nullable String resolveImport(TypeScriptProject project, String importer, String specifier) {
return TypeScriptModuleNames.resolveImport(importer, specifier, project.workspaces(), project.externalPackages());
}
private static final Pattern CSS_DECLARATION = Pattern.compile("([a-zA-Z-]+)\\s*:\\s*([^;{}]+)");
private static final Pattern CSS_LITERAL = Pattern.compile("#[0-9a-fA-F]{3,8}|-?\\d+(?:\\.\\d+)?(?:px|rem|em|%|vh|vw|pt)|rgba?\\([^)]*\\)|calc\\([^)]*\\)");
/**
* Item 196: one {@code STYLE} per CSS rule ({@code body@7}, {@code @font-face@2}, a nested
* {@code @media} block is one rule with its inner selectors' declarations) — {@code selector},
* {@code properties} (declaration keys) and {@code literals} (hard-coded colours/lengths).
*/
static void cssRules(AstNode module, String content, List<AstNode> nodes, List<AstEdge> edges) {
StringBuilder sb = new StringBuilder(content.length());
// blank out comments, keeping line structure
Matcher c = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(content);
int last = 0;
while (c.find()) {
sb.append(content, last, c.start()).append(c.group().replaceAll("[^\\n]", " "));
last = c.end();
}
sb.append(content.substring(last));
String text = sb.toString();
// braces inside strings are masked here so the declaration regex sees `content: " "`
StringBuilder masked = new StringBuilder(text);
int depth = 0;
int ruleStart = 0;
int selectorFrom = 0;
char quote = 0;
Set<String> names = new HashSet<>();
for (int i = 0; i < text.length(); i++) {
char ch = text.charAt(i);
// Item 199: braces inside a string (`content: "{"`) do not open or close a block.
if (quote != 0) {
if (ch == '\\') {
i++;
} else if (ch == quote) {
quote = 0;
} else if (ch == '{' || ch == '}') {
masked.setCharAt(i, ' ');
}
continue;
}
if (ch == '"' || ch == '\'') {
quote = ch;
} else if (ch == ';' && depth == 0) {
// a block-less at-statement (`@import url(...);`, `@charset`, `@layer a, b;`) ends here,
// not at the next rule's closing brace
selectorFrom = i + 1;
} else if (ch == '{') {
if (depth == 0) {
ruleStart = i;
}
depth++;
} else if (ch == '}') {
depth--;
if (depth == 0) {
String selector = text.substring(selectorFrom, ruleStart).replaceAll("\\s+", " ").trim();
// a nested rule head inside an at-rule body (`a:hover {`) is a selector, not a declaration
String body = masked.substring(ruleStart + 1, i).replaceAll("[^{};]*\\{", "{");
int line = lineOf(text, ruleStart);
if (!selector.isEmpty()) {
Set<String> props = new java.util.LinkedHashSet<>();
Set<String> literals = new java.util.LinkedHashSet<>();
Matcher d = CSS_DECLARATION.matcher(body);
while (d.find()) {
props.add(d.group(1));
Matcher l = CSS_LITERAL.matcher(d.group(2));
while (l.find()) {
literals.add(l.group());
}
}
Map<String, String> p = new LinkedHashMap<>();
p.put("styleKind", "css");
p.put("selector", selector);
p.put("properties", String.join(",", props));
if (!literals.isEmpty()) {
p.put("literals", String.join(",", literals));
}
String name = selector + "@" + line;
if (!names.add(name)) {
// minified CSS: same selector twice on one line keeps both rules apart by column
name = name + ":" + (ruleStart - text.lastIndexOf('\n', ruleStart));
names.add(name);
}
AstNode rule = new AstNode(UUID.randomUUID(), NodeType.STYLE, name, module.sourceFile(),
CssLineCounter.LANGUAGE, line, lineOf(text, i), selector, null, p);
nodes.add(rule);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), rule.id(), line, null));
}
selectorFrom = i + 1;
}
}
}
}
static AstEdge importEdge(AstNode module, AstNode placeholder, String specifier, String names, int line) {
Map<String, String> edgeProps = new LinkedHashMap<>();
edgeProps.put("specifier", specifier);
return new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, module.id(), placeholder.id(), line,
names.isEmpty() ? null : names, edgeProps);
}
/**
* @return the generator that wrote this file, or {@code null} for hand-written code. The legacy
* Java-side generator marks its output with {@code // @ts-nocheck} under a {@code /generated/}
* directory; hey-api writes an "auto-generated by" banner.
*/
static @Nullable String generatedBy(String sourceFile, String content) {
String head = content.length() > 600 ? content.substring(0, 600) : content;
if (head.contains("@hey-api/openapi-ts")) {
return "hey-api";
}
if (head.contains("EndpointGenerator") || head.contains("typescript-generator")) {
return "typescript-generator";
}
if (sourceFile.contains("/generated/") && (head.contains("@ts-nocheck") || head.contains("auto-generated"))) {
return "unknown";
}
return null;
}
private static void addImport(Shell shell, TypeScriptProject project, String specifier, String names, int line,
Placeholders placeholders, Set<String> external, List<AstEdge> edges) {
String target = resolveImport(project, shell.module().name(), specifier);
if (target == null) {
external.add(specifier);
return;
}
edges.add(importEdge(shell.module(), placeholders.module(target), specifier, names, line));
}
private static void addFunction(AstNode module, String sourceFile, String content, List<AstNode> nodes,
List<AstEdge> edges, String name, int offset, boolean exported, String kind) {
int line = lineOf(content, offset);
Map<String, String> props = new LinkedHashMap<>();
props.put("kind", kind);
props.put("exported", Boolean.toString(exported));
AstNode fn = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, name, sourceFile, TypeScriptLineCounter.LANGUAGE,
line, endLineOf(content, offset, line), null, null, props);
nodes.add(fn);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), fn.id(), line, null));
}
/**
* @return the {@code kind} for a {@code const NAME = <init>} declaration, or {@code null} when the
* initializer is data rather than a callable (a plain object, a literal, a {@code createSlice}).
*/
private static @Nullable String initializerKind(String name, boolean tsx, String init) {
if (THUNK_HEAD.matcher(init).find()) {
return "thunk";
}
if (STYLED_HEAD.matcher(init).find()) {
return "styled";
}
if (WRAPPED_HEAD.matcher(init).find()) {
return kindOf(name, tsx, "component");
}
if (ARROW_HEAD.matcher(init).find()) {
return kindOf(name, tsx, null);
}
return null;
}
private static String kindOf(String name, boolean tsx, @Nullable String fallback) {
if (name.length() > 3 && name.startsWith("use") && Character.isUpperCase(name.charAt(3))) {
return "hook";
}
if (tsx && Character.isUpperCase(name.charAt(0))) {
return "component";
}
return fallback != null ? fallback : "function";
}
private static int lineOf(String content, int offset) {
int line = 1;
for (int i = 0; i < offset && i < content.length(); i++) {
if (content.charAt(i) == '\n') {
line++;
}
}
return line;
}
/**
* The end line of the declaration starting at {@code offset}: the first line end at which every
* bracket ({@code {}}, {@code ()}, {@code []}) opened since {@code offset} is closed again, skipping
* strings, templates and comments. So {@code function f() {...}} ends at its closing brace, a
* {@code createAsyncThunk(...)} at its closing parenthesis, and a one-line arrow or type alias at
* its own line. A multi-line union with no brackets ends at its first line — an outline, not a
* parse; the sidecar's positions replace these.
*/
static int endLineOf(String content, int offset, int startLine) {
int n = content.length();
int depth = 0;
boolean inBlock = false;
char quote = 0;
int line = startLine;
for (int i = offset; i < n; i++) {
char c = content.charAt(i);
if (c == '\n') {
if (depth <= 0) {
return line;
}
line++;
continue;
}
if (inBlock) {
if (c == '*' && i + 1 < n && content.charAt(i + 1) == '/') {
inBlock = false;
i++;
}
continue;
}
if (quote != 0) {
if (c == '\\') {
i++;
} else if (c == quote) {
quote = 0;
}
continue;
}
if (c == '/' && i + 1 < n && content.charAt(i + 1) == '/') {
while (i + 1 < n && content.charAt(i + 1) != '\n') {
i++;
}
continue;
}
if (c == '/' && i + 1 < n && content.charAt(i + 1) == '*') {
inBlock = true;
i++;
continue;
}
if (c == '"' || c == '\'' || c == '`') {
quote = c;
} else if (c == '{' || c == '(' || c == '[') {
depth++;
} else if (c == '}' || c == ')' || c == ']') {
depth--;
}
}
return line;
}
@Override
public String language() {
return TypeScriptLineCounter.LANGUAGE;
}
/**
* The workspace/package sets this scanner was built with, as a facts-less project context.
*/
TypeScriptProject defaults() {
return new TypeScriptProject(workspaces, externalPackages, TypeScriptFacts.NONE);
}
@Override
public ParseResult scan(String sourceFile, String content) {
return scan(sourceFile, content, new TypeScriptProject(workspaces, externalPackages, TypeScriptFacts.NONE));
}
/**
* The outline with the project's own workspace/package knowledge (see {@link TypeScriptProject}).
*/
public ParseResult scan(String sourceFile, String content, TypeScriptProject project) {
Shell shell = shell(sourceFile, content);
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();
nodes.add(shell.module());
if (shell.css()) {
cssRules(shell.module(), content, nodes, edges);
return new ParseResult(nodes, edges);
}
Placeholders placeholders = new Placeholders(nodes);
Set<String> external = new TreeSet<>();
Matcher m = IMPORT.matcher(content);
while (m.find()) {
String names = m.group(1) == null ? "" : m.group(1).replaceAll("\\s+", " ").trim();
addImport(shell, project, m.group(2), names, lineOf(content, m.start()), placeholders, external, edges);
}
Matcher d = DYNAMIC_IMPORT.matcher(content);
while (d.find()) {
addImport(shell, project, d.group(1), "import()", lineOf(content, d.start()), placeholders, external, edges);
}
shell.setExternalImports(external);
scanDeclarations(shell.module(), sourceFile, content, nodes, edges);
return new ParseResult(nodes, edges);
}
/**
* The {@code MODULE} node of {@code sourceFile} with identity, metrics, hash and generator flags —
* shared by the Tier-1 outline and the Tier-2 facts merge in {@link TypeScriptParser}.
*/
Shell shell(String sourceFile, String content) {
boolean css = sourceFile.toLowerCase(java.util.Locale.ROOT).endsWith(".css");
String language = css ? CssLineCounter.LANGUAGE : TypeScriptLineCounter.LANGUAGE;
LocMetrics metrics = css ? cssCounter.count(content) : tsCounter.count(content);
String moduleName = TypeScriptModuleNames.moduleName(sourceFile);
Map<String, String> props = new LinkedHashMap<>();
props.put("simpleName", TypeScriptModuleNames.simpleName(moduleName));
props.put("workspace", TypeScriptModuleNames.workspace(moduleName));
props.put("moduleKind", css ? "css" : sourceFile.endsWith(".tsx") ? "tsx" : "ts");
props.put(SourceHash.PROPERTY, SourceHash.of(content));
props.put(LocMetrics.LOC, Integer.toString(metrics.loc()));
props.put(LocMetrics.SLOC, Integer.toString(metrics.sloc()));
if (!css) {
String generated = generatedBy(sourceFile, content);
props.put("generated", Boolean.toString(generated != null));
if (generated != null) {
props.put("generator", generated);
}
}
AstNode module = new AstNode(UUID.randomUUID(), NodeType.MODULE, moduleName, sourceFile, language,
1, Math.max(1, metrics.loc()), null, null, props);
return new Shell(module, css, props);
}
private void scanDeclarations(AstNode module, String sourceFile, String content, List<AstNode> nodes,
List<AstEdge> edges) {
boolean tsx = sourceFile.endsWith(".tsx");
Matcher f = FUNCTION_DECL.matcher(content);
while (f.find()) {
String name = f.group(2) != null ? f.group(2) : TypeScriptModuleNames.simpleName(module.name());
addFunction(module, sourceFile, content, nodes, edges, name, f.start(), f.group(1) != null,
kindOf(name, tsx, null));
}
Matcher c = CLASS_DECL.matcher(content);
while (c.find()) {
addFunction(module, sourceFile, content, nodes, edges, c.group(2), c.start(), c.group(1) != null, "class");
}
Matcher v = CONST_DECL.matcher(content);
while (v.find()) {
String init = v.group(3).trim();
String kind = initializerKind(v.group(2), tsx, init);
if (kind == null) {
continue;
}
addFunction(module, sourceFile, content, nodes, edges, v.group(2), v.start(), v.group(1) != null, kind);
}
Matcher t = TYPE_DECL.matcher(content);
while (t.find()) {
int line = lineOf(content, t.start());
Map<String, String> props = new LinkedHashMap<>();
props.put("exported", Boolean.toString(t.group(1) != null));
AstNode ds = new AstNode(UUID.randomUUID(), NodeType.DATA_STRUCTURE, t.group(3), sourceFile,
TypeScriptLineCounter.LANGUAGE, line, endLineOf(content, t.end(), line), t.group(2), null, props);
nodes.add(ds);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), ds.id(), line, null));
}
}
/**
* A module shell; {@code props} is the live property map of {@code module} so flags can be added after creation.
*/
record Shell(AstNode module, boolean css, Map<String, String> props) {
void setExternalImports(Set<String> external) {
if (!external.isEmpty()) {
props.put("externalImports", String.join(",", external));
}
}
}
/**
* Placeholder {@code MODULE}s ({@code sourceFile == ""}) deduped by name, appended to {@code nodes} on first use.
*/
static final class Placeholders {
private final Map<String, AstNode> byName = new LinkedHashMap<>();
private final List<AstNode> nodes;
Placeholders(List<AstNode> nodes) {
this.nodes = nodes;
}
AstNode module(String name) {
return byName.computeIfAbsent(name, n -> {
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.MODULE, n, "", TypeScriptLineCounter.LANGUAGE, 1, 1, null, null);
nodes.add(ph);
return ph;
});
}
/**
* Item 194: a store placeholder — a {@code STORE_SLICE} ({@code <key>}) or a store {@code FIELD}
* ({@code <key>.<field>}) another file declares. Carries {@code store=true} so the store
* resolver matches it to the real node of the same type and name.
*/
/**
* Item 195: a DTO-field placeholder — {@code <Dto>.<field>} declared as a {@code FIELD} of the
* {@code DATA_STRUCTURE} {@code owner} in module {@code targetModule}; carries {@code binding=true}
* so the binding resolver matches it exactly (module → structure → field).
*/
AstNode binding(String owner, String field, String targetModule) {
return byName.computeIfAbsent("BINDING|" + owner + "." + field, n -> {
Map<String, String> props = new LinkedHashMap<>();
props.put("binding", "true");
props.put("owner", owner);
props.put("field", field);
props.put("targetModule", targetModule);
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.FIELD, owner + "." + field, "", TypeScriptLineCounter.LANGUAGE,
1, 1, null, null, props);
nodes.add(ph);
return ph;
});
}
/**
* Item 196: a theme-token placeholder {@code theme.<token>} ({@code theme=true}), resolved by exact name.
*/
AstNode theme(String token) {
return byName.computeIfAbsent("THEME|" + token, n -> {
Map<String, String> props = new LinkedHashMap<>();
props.put("theme", "true");
props.put("token", token);
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.FIELD, "theme." + token, "", TypeScriptLineCounter.LANGUAGE,
1, 1, null, null, props);
nodes.add(ph);
return ph;
});
}
AstNode store(NodeType type, String name) {
return byName.computeIfAbsent(type + "|" + name, n -> {
Map<String, String> props = new LinkedHashMap<>();
props.put("store", "true");
AstNode ph = new AstNode(UUID.randomUUID(), type, name, "", TypeScriptLineCounter.LANGUAGE, 1, 1, null, null, props);
nodes.add(ph);
return ph;
});
}
}
}

View File

@@ -0,0 +1,309 @@
package com.agenticcode.parsertypescript;
import org.jspecify.annotations.Nullable;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Tier-2 facts for one ingest, produced by the Node sidecar ({@code sidecar/extract.mjs}) on the
* TypeScript compiler API and keyed by project-root-relative source file (roadmap item 192). Built
* once per ingest like the Natural {@code CopycodeLibrary} and handed into every per-file
* {@link TypeScriptParser#parse(String, String, TypeScriptFacts)} call. {@link #NONE} carries no
* facts, which is what a Tier-1 coarse scan and the outline tests use. The JSON contract is
* documented at the top of {@code extract.mjs} and read by {@link TypeScriptFactsReader}.
*/
public record TypeScriptFacts(Map<String, FileFacts> byFile, Map<String, FileFacts> byModule,
Map<String, String> storeKeys) {
public static final TypeScriptFacts NONE = new TypeScriptFacts(Map.of(), Map.of(), Map.of());
public TypeScriptFacts {
byFile = Map.copyOf(byFile);
byModule = Map.copyOf(byModule);
storeKeys = Map.copyOf(storeKeys);
}
/**
* Builds the facts with the derived module-name index ({@link TypeScriptModuleNames#moduleName})
* and the item-194 store index: every {@code configureStore} of the project contributes
* {@code <slice module>|<slice name> -> reducer key}, so a slice file parsed on its own still
* learns the key it is mounted under ({@code generalAgreementSlice} is {@code state.gruppenprovision}).
*/
public static TypeScriptFacts of(Map<String, FileFacts> byFile) {
Map<String, FileFacts> byModule = new HashMap<>();
Map<String, String> storeKeys = new HashMap<>();
byFile.forEach((file, facts) -> {
byModule.put(TypeScriptModuleNames.moduleName(file), facts);
StoreFact store = facts.store();
if (store != null) {
for (StoreKeyFact k : store.keys()) {
if (k.sliceFile() != null && k.sliceName() != null) {
storeKeys.put(k.sliceFile() + "|" + k.sliceName(), k.key());
}
}
}
});
return new TypeScriptFacts(byFile, byModule, storeKeys);
}
/**
* One facts document per workspace run; a file present in several keeps the last.
*/
public static TypeScriptFacts merge(List<TypeScriptFacts> parts) {
Map<String, FileFacts> all = new HashMap<>();
for (TypeScriptFacts part : parts) {
all.putAll(part.byFile());
}
return of(all);
}
/**
* @return the reducer key a slice is mounted under in a store of the project, or its own name when no store mounts it
*/
public String storeKey(String sliceModule, String sliceName) {
String key = storeKeys.get(sliceModule + "|" + sliceName);
return key != null ? key : sliceName;
}
public boolean has(String sourceFile) {
return byFile.containsKey(sourceFile);
}
public @Nullable FileFacts get(String sourceFile) {
return byFile.get(sourceFile);
}
/**
* @return the facts of the file whose module name (path without extension) is {@code moduleName}, or {@code null}
*/
public @Nullable FileFacts byModuleName(String moduleName) {
return byModule.get(moduleName);
}
/**
* Everything the sidecar knows about one file. {@code endpoints} is item 193; {@code slices},
* {@code store} and {@code stateAccesses} are item 194.
*/
public record FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
List<StateAccessFact> stateAccesses, List<BindingFact> bindings,
List<ThemeTokenFact> themeTokens, List<StyleFact> styles, List<TokenRefFact> tokenRefs) {
/**
* The item-195 shape, without styling facts.
*/
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
List<StateAccessFact> stateAccesses, List<BindingFact> bindings) {
this(imports, declarations, calls, endpoints, slices, store, stateAccesses, bindings, List.of(), List.of(), List.of());
}
/**
* The item-192/193 shape, without store facts and bindings.
*/
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
List<EndpointFact> endpoints) {
this(imports, declarations, calls, endpoints, List.of(), null, List.of(), List.of());
}
/**
* The item-194 shape, without bindings.
*/
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
List<StateAccessFact> stateAccesses) {
this(imports, declarations, calls, endpoints, slices, store, stateAccesses, List.of());
}
}
/**
* A theme token (item 196): a leaf of the {@code createTheme({...})} literal ({@code kind=path},
* {@code palette.primary.dark}) or an exported string constant of the theme file ({@code kind=constant},
* {@code PRIMARY}); {@code value} folded through constants, {@code constant} the constant a path leaf names.
*/
public record ThemeTokenFact(String token, String kind, @Nullable String value, @Nullable String constant, int line,
int variants) {
public ThemeTokenFact(String token, String kind, @Nullable String value, @Nullable String constant, int line) {
this(token, kind, value, constant, line, 1);
}
}
/**
* One {@code sx={}} / {@code style={}} / {@code styled(X)(...)} block (item 196).
*
* @param element the JSX tag or the styled base ({@code Box}, {@code 'div'}), or {@code null}
* @param properties the CSS keys, nested selectors flattened ({@code &:hover.color})
* @param literals hard-coded colours/lengths ({@code #005CA9}, {@code 17px})
* @param dynamic a value is an expression the sidecar could not classify (or the whole block is)
* @param tokens the theme tokens the block reads, each with the key it feeds
*/
public record StyleFact(String fromDecl, String styleKind, @Nullable String element, int line, int col,
List<String> properties,
List<String> literals, boolean dynamic, boolean spread, List<StyleTokenFact> tokens) {
}
public record StyleTokenFact(String token, @Nullable String property, int line) {
}
/**
* A theme token read outside a style block (item 196); {@code context} = the JSX attribute, or {@code code}.
*/
public record TokenRefFact(String fromDecl, String token, String context, int line) {
}
/**
* A use of a generated {@code Fields} path object (item 195): {@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}.
*
* @param kind {@code field} for a scalar leaf, {@code prefix} when a whole sub-object is handed on
* @param rootDto the DTO the path starts from ({@code AgstammUseCase})
* @param ownerDto the DTO declaring the leaf ({@code Broker}); {@code ownerFile} its module
* @param field the leaf ({@code ebene})
* @param path the dotted hops from the root ({@code broker.ebene}, {@code list[]} for an indexed hop)
* @param partial the root is a prop or local, so only the tail of the path is known
* @param component the JSX tag the expression is a prop of, or {@code null}
* @param attribute the prop / object property / callee text the expression is passed as
*/
public record BindingFact(String fromDecl, String kind, String rootDto, String ownerDto, String ownerFile,
String field,
String path, boolean partial, @Nullable String component, @Nullable String attribute,
int line) {
}
/**
* A {@code createSlice} (item 194).
*
* @param name the const holding the slice ({@code keytableSlice})
* @param sliceName the RTK slice name — the action-type prefix ({@code schluesseltabelle})
* @param stateType the checker's name for the state type, or {@code null}
* @param fields the top-level keys of the state
* @param reducers case reducers, extra-reducer cases and matchers
*/
public record SliceFact(String name, String sliceName, @Nullable String stateType, boolean exported, int startLine,
int endLine, List<MemberFact> fields, List<ReducerFact> reducers) {
}
/**
* One reducer function of a slice (item 194).
*
* @param name the action type it handles ({@code schluesseltabelle/updateX}, {@code schluesseltabelle/suche/fulfilled})
* or, for a matcher, {@code <slice>/matcher:<expression>}
* @param kind {@code reducer} (in {@code reducers: {}}), {@code case}, {@code matcher} or {@code default}
* @param trigger for a case/matcher, what fires it; {@code null} for a plain reducer
* @param accesses the state paths it reads and writes, rooted at the slice state
*/
public record ReducerFact(String name, String kind, @Nullable TriggerFact trigger, int startLine, int endLine,
List<AccessFact> accesses) {
}
/**
* @param expression the trigger as written ({@code loadX.fulfilled}, {@code isSlicePending(sliceName)})
* @param actionType the action type when the trigger is a thunk lifecycle action or a slice action
* @param file the module declaring the thunk/slice, {@code null} when unresolved
* @param decl the thunk const or slice const in {@code file}
*/
public record TriggerFact(String expression, @Nullable String actionType, @Nullable String file,
@Nullable String decl) {
}
/**
* @param mode {@code read} or {@code write}; {@code path} is empty for the whole slice state
*/
public record AccessFact(String mode, List<String> path, int line) {
}
/**
* A {@code configureStore} (item 194): which slice each reducer key mounts.
*/
public record StoreFact(int line, List<StoreKeyFact> keys) {
}
/**
* @param sliceFile the module declaring the mounted slice, {@code null} when the reducer could not be traced
*/
public record StoreKeyFact(String key, @Nullable String sliceFile, @Nullable String sliceName) {
}
/**
* A store read outside a reducer (item 194): a selector arrow, a wrapper-hook selector or a
* {@code getState()} chain. {@code path} is rooted at the store, so {@code path[0]} is the reducer key.
*
* @param via {@code useAppSelector}, {@code useSelector}, the wrapper hook's name, or {@code getState}
*/
public record StateAccessFact(String fromDecl, List<String> path, int line, String via) {
}
/**
* @param specifier the string in the {@code import}/{@code export ... from}/{@code import()}
* @param resolved the module name (root-relative path, no extension) the specifier resolved to
* inside the project, or {@code null}
* @param packageName the npm package it resolved to, or {@code null}; both {@code null} = unresolved
* @param names the import clause text ({@code { a, b }}, {@code * as X}, {@code import()})
*/
public record ImportFact(String specifier, @Nullable String resolved, @Nullable String packageName,
int line, String names) {
}
/**
* @param kind {@code function}, {@code component}, {@code hook}, {@code thunk}, {@code styled},
* {@code class}, {@code slice}, {@code const}, {@code interface}, {@code type}, {@code enum}
*/
public record DeclarationFact(String name, String kind, boolean exported, int startLine, int endLine,
List<MemberFact> members) {
}
/**
* A property of an interface or type-literal alias (item 193): the DTO shape the frontend binds to.
*/
public record MemberFact(String name, String type, boolean optional, int line) {
}
/**
* A generated web-service call (item 193). {@code name} is the graph {@code FUNCTION} name:
* {@code <Class>.<member>} for the legacy generator, the exported const for hey-api.
*
* @param owner the legacy Endpoint class, or {@code null} for hey-api
* @param member the legacy member property, or {@code null}
* @param generator {@code typescript-generator} or {@code hey-api}
* @param backend from the URL builder: {@code pur}, {@code pur-r-vstamm}, {@code dynamic}; {@code null} for hey-api
* @param url the composed URL template as written, {@code {param}} for substitutions, may carry a query string
*/
public record EndpointFact(String name, @Nullable String owner, @Nullable String member, String httpMethod,
String generator, @Nullable String backend, String url, @Nullable String requestType,
@Nullable String responseType, @Nullable String paramsType, int line) {
}
/**
* @param fromDecl the top-level declaration containing the call site, {@code ""} at module level
* @param expression the callee text as written ({@code agstammUiApi.saveBroker.post})
* @param symbol the resolved symbol name, or {@code null} when the checker could not resolve it
* @param file the module name whose declaration owns the callee, or {@code null}
* @param decl the top-level declaration in {@code file} owning the callee (a function, or
* the class/interface a called member belongs to), {@code null} for a local
* @param packageName the npm package ({@code lib} for the language library) for external callees
* @param receiver on a member call, the type name of the innermost object ({@code AgstammControllerEndpoint})
* @param member on a member call, the property chain ({@code saveBroker.post})
* @param actionType item 194: {@code <slice>/<reducer>} when the callee is a slice action creator
* (then {@code file}/{@code decl} name the slice const), a thunk's type prefix
* when it is a thunk, else {@code null}
* @param kind {@code call}, {@code new}, {@code tagged} or {@code jsx}
*/
public record CallFact(String fromDecl, String expression, @Nullable String symbol, @Nullable String file,
@Nullable String decl, @Nullable String packageName, @Nullable String receiver,
@Nullable String member, @Nullable String actionType, int line, String kind) {
/**
* The item-192 shape without an action type.
*/
public CallFact(String fromDecl, String expression, @Nullable String symbol, @Nullable String file,
@Nullable String decl, @Nullable String packageName, @Nullable String receiver,
@Nullable String member, int line, String kind) {
this(fromDecl, expression, symbol, file, decl, packageName, receiver, member, null, line, kind);
}
public boolean resolvedInProject() {
return file != null && decl != null;
}
}
}

View File

@@ -0,0 +1,157 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
/**
* Reads the sidecar's JSON facts document (contract version 4, see {@code sidecar/extract.mjs}) into
* {@link TypeScriptFacts}. Tolerant of unknown fields so later phases (store, bindings, styles) can
* extend the document without breaking this reader; strict about the version so a mismatched
* sidecar fails loudly instead of yielding an empty graph.
*/
public final class TypeScriptFactsReader {
public static final int CONTRACT_VERSION = 4;
private static final ObjectMapper MAPPER = new ObjectMapper();
private TypeScriptFactsReader() {
}
public static TypeScriptFacts read(InputStream json) throws IOException {
return read(MAPPER.readTree(json));
}
public static TypeScriptFacts read(String json) throws IOException {
return read(MAPPER.readTree(json));
}
private static TypeScriptFacts read(JsonNode rootNode) throws IOException {
int version = rootNode.path("version").asInt(-1);
if (version != CONTRACT_VERSION) {
throw new IOException("sidecar facts contract version " + version + ", expected " + CONTRACT_VERSION);
}
Map<String, FileFacts> byFile = new HashMap<>();
JsonNode files = rootNode.path("files");
for (Iterator<Map.Entry<String, JsonNode>> it = files.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> e = it.next();
byFile.put(e.getKey(), fileFacts(e.getValue()));
}
return TypeScriptFacts.of(byFile);
}
private static FileFacts fileFacts(JsonNode f) {
List<ImportFact> imports = new ArrayList<>();
for (JsonNode i : f.path("imports")) {
imports.add(new ImportFact(text(i, "specifier"), nullable(i, "resolved"), nullable(i, "package"),
i.path("line").asInt(), text(i, "names")));
}
List<DeclarationFact> declarations = new ArrayList<>();
for (JsonNode d : f.path("declarations")) {
declarations.add(new DeclarationFact(text(d, "name"), text(d, "kind"), d.path("exported").asBoolean(),
d.path("startLine").asInt(), d.path("endLine").asInt(), members(d.path("members"))));
}
List<CallFact> calls = new ArrayList<>();
for (JsonNode c : f.path("calls")) {
calls.add(new CallFact(text(c, "fromDecl"), text(c, "expression"), nullable(c, "symbol"),
nullable(c, "file"), nullable(c, "decl"), nullable(c, "package"), nullable(c, "receiver"),
nullable(c, "member"), nullable(c, "actionType"), c.path("line").asInt(), text(c, "kind")));
}
List<EndpointFact> endpoints = new ArrayList<>();
for (JsonNode e : f.path("endpoints")) {
endpoints.add(new EndpointFact(text(e, "name"), nullable(e, "owner"), nullable(e, "member"), text(e, "httpMethod"),
text(e, "generator"), nullable(e, "backend"), text(e, "url"), nullable(e, "requestType"),
nullable(e, "responseType"), nullable(e, "paramsType"), e.path("line").asInt()));
}
List<SliceFact> slices = new ArrayList<>();
for (JsonNode sl : f.path("slices")) {
List<ReducerFact> reducers = new ArrayList<>();
for (JsonNode r : sl.path("reducers")) {
List<AccessFact> accesses = new ArrayList<>();
for (JsonNode a : r.path("accesses")) {
accesses.add(new AccessFact(text(a, "mode"), strings(a.path("path")), a.path("line").asInt()));
}
JsonNode t = r.get("trigger");
TriggerFact trigger = t == null || t.isNull() ? null
: new TriggerFact(text(t, "expression"), nullable(t, "actionType"), nullable(t, "file"), nullable(t, "decl"));
reducers.add(new ReducerFact(text(r, "name"), text(r, "kind"), trigger, r.path("startLine").asInt(),
r.path("endLine").asInt(), List.copyOf(accesses)));
}
slices.add(new SliceFact(text(sl, "name"), text(sl, "sliceName"), nullable(sl, "stateType"),
sl.path("exported").asBoolean(), sl.path("startLine").asInt(), sl.path("endLine").asInt(),
members(sl.path("fields")), List.copyOf(reducers)));
}
JsonNode st = f.get("store");
StoreFact store = null;
if (st != null && !st.isNull()) {
List<StoreKeyFact> keys = new ArrayList<>();
for (JsonNode k : st.path("keys")) {
keys.add(new StoreKeyFact(text(k, "key"), nullable(k, "sliceFile"), nullable(k, "sliceName")));
}
store = new StoreFact(st.path("line").asInt(), List.copyOf(keys));
}
List<StateAccessFact> stateAccesses = new ArrayList<>();
for (JsonNode a : f.path("stateAccesses")) {
stateAccesses.add(new StateAccessFact(text(a, "fromDecl"), strings(a.path("path")), a.path("line").asInt(), text(a, "via")));
}
List<BindingFact> bindings = new ArrayList<>();
for (JsonNode b : f.path("bindings")) {
bindings.add(new BindingFact(text(b, "fromDecl"), text(b, "kind"), text(b, "rootDto"), text(b, "ownerDto"),
text(b, "ownerFile"), text(b, "field"), text(b, "path"), b.path("partial").asBoolean(),
nullable(b, "component"), nullable(b, "attribute"), b.path("line").asInt()));
}
List<ThemeTokenFact> themeTokens = new ArrayList<>();
for (JsonNode t : f.path("themeTokens")) {
themeTokens.add(new ThemeTokenFact(text(t, "token"), text(t, "kind"), nullable(t, "value"), nullable(t, "constant"), t.path("line").asInt(),
Math.max(1, t.path("variants").asInt(1))));
}
List<StyleFact> styles = new ArrayList<>();
for (JsonNode sb : f.path("styles")) {
List<StyleTokenFact> tokens = new ArrayList<>();
for (JsonNode t : sb.path("tokens")) {
tokens.add(new StyleTokenFact(text(t, "token"), nullable(t, "property"), t.path("line").asInt()));
}
styles.add(new StyleFact(text(sb, "fromDecl"), text(sb, "styleKind"), nullable(sb, "element"), sb.path("line").asInt(),
sb.path("col").asInt(), strings(sb.path("properties")), strings(sb.path("literals")), sb.path("dynamic").asBoolean(),
sb.path("spread").asBoolean(), List.copyOf(tokens)));
}
List<TokenRefFact> tokenRefs = new ArrayList<>();
for (JsonNode r : f.path("tokenRefs")) {
tokenRefs.add(new TokenRefFact(text(r, "fromDecl"), text(r, "token"), text(r, "context"), r.path("line").asInt()));
}
return new FileFacts(List.copyOf(imports), List.copyOf(declarations), List.copyOf(calls), List.copyOf(endpoints),
List.copyOf(slices), store, List.copyOf(stateAccesses), List.copyOf(bindings),
List.copyOf(themeTokens), List.copyOf(styles), List.copyOf(tokenRefs));
}
private static List<MemberFact> members(JsonNode array) {
List<MemberFact> members = new ArrayList<>();
for (JsonNode m : array) {
members.add(new MemberFact(text(m, "name"), text(m, "type"), m.path("optional").asBoolean(), m.path("line").asInt()));
}
return List.copyOf(members);
}
private static List<String> strings(JsonNode array) {
List<String> out = new ArrayList<>();
for (JsonNode n : array) {
out.add(n.asText());
}
return List.copyOf(out);
}
private static String text(JsonNode n, String field) {
return n.path(field).asText("");
}
private static @Nullable String nullable(JsonNode n, String field) {
JsonNode v = n.get(field);
return v == null || v.isNull() ? null : v.asText();
}
}

View File

@@ -0,0 +1,84 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.LocMetrics;
import com.agenticcode.parsercore.ast.spi.LineCounter;
/**
* Per-language LoC/SLoC counter for TypeScript/TSX (roadmap item 192, same contract as item 46).
* SLOC = physical lines carrying code outside comments. One char-state scan tracks {@code //} line
* comments, {@code /* ... *}{@code /} block comments (the JSX form {@code {/* ... *}{@code /}} included), {@code "..."} /
* {@code '...'} strings and multi-line template literals — so a {@code //} inside {@code 'http://x'}
* or a template is code, not a comment. Regex literals are not tracked: a {@code //} inside one is
* counted as a comment start, which is rare and costs at most that one line. Deterministic.
*/
public final class TypeScriptLineCounter implements LineCounter {
static final String LANGUAGE = "typescript";
@Override
public String language() {
return LANGUAGE;
}
@Override
public LocMetrics count(String content) {
int loc = LocMetrics.physicalLineCount(content);
int sloc = 0;
boolean lineHasCode = false;
boolean inBlock = false;
char quote = 0; // '"', '\'' or '`' while inside a string/template, else 0
int n = content.length();
int i = 0;
while (i < n) {
char c = content.charAt(i);
if (c == '\n') {
if (lineHasCode) {
sloc++;
}
lineHasCode = false;
i++;
} else if (c == '\r') {
i++;
} else if (inBlock) {
if (c == '*' && i + 1 < n && content.charAt(i + 1) == '/') {
inBlock = false;
i += 2;
} else {
i++;
}
} else if (quote != 0) {
if (!Character.isWhitespace(c)) {
lineHasCode = true;
}
if (c == '\\' && i + 1 < n) {
i += 2;
} else {
if (c == quote) {
quote = 0;
}
i++;
}
} else if (c == '/' && i + 1 < n && content.charAt(i + 1) == '/') {
while (i < n && content.charAt(i) != '\n') {
i++;
}
} else if (c == '/' && i + 1 < n && content.charAt(i + 1) == '*') {
inBlock = true;
i += 2;
} else if (c == '"' || c == '\'' || c == '`') {
quote = c;
lineHasCode = true;
i++;
} else {
if (!Character.isWhitespace(c)) {
lineHasCode = true;
}
i++;
}
}
if (lineHasCode) {
sloc++;
}
return new LocMetrics(loc, sloc);
}
}

View File

@@ -0,0 +1,131 @@
package com.agenticcode.parsertypescript;
import org.jspecify.annotations.Nullable;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Set;
/**
* Naming rules for TypeScript modules (roadmap item 192). A module's graph identity is its
* project-root-relative path <em>without the script extension</em>, e.g.
* {@code pur-r-vstamm/src/store/slices/agstammSlice}; a CSS module keeps its extension
* ({@code pur-ui/src/index.css}); {@code simpleName} is the file stem ({@code agstammSlice},
* {@code index.css}); the first path segment is the npm {@code workspace}. Identities are
* path-like on purpose: the placeholder rewiring step matches module placeholders by exact
* {@code name}, so an import resolved to the same path joins the real module with no new Cypher.
*
* <p>Import specifiers resolve as the frontend's tsconfig does ({@code baseUrl: "src"} per workspace,
* no {@code paths}, a vite alias {@code pur-ui-common -> ../pur-ui-common/src}):
* <ul>
* <li>{@code ./x}, {@code ../x} — relative to the importing file's directory</li>
* <li>{@code <workspace>} or {@code <workspace>/...} — the workspace barrel ({@code <ws>/src/index})
* or that path</li>
* <li>{@code #/x} — the importing workspace's {@code src/x} (package.json {@code imports})</li>
* <li>{@code @scope/pkg}, a name in {@code externalPackages}, {@code node:*} — an npm package,
* recorded on the module, never a placeholder</li>
* <li>any other bare specifier — {@code baseUrl}-relative: {@code <ws>/src/<specifier>}</li>
* </ul>
* Content-only resolution cannot tell {@code x} from {@code x/index}; the sidecar (Tier-2) resolves
* against the file system and corrects such placeholders.
*/
final class TypeScriptModuleNames {
// .css is deliberately NOT stripped: `index.css` next to `index.ts` must not share the identity
// `…/src/index` (the whole-root duplicate check would skip both, and a placeholder would be ambiguous).
private static final Set<String> SOURCE_EXTENSIONS = Set.of(".tsx", ".ts", ".jsx", ".js", ".mjs");
private TypeScriptModuleNames() {
}
/**
* @return {@code sourceFile} with its source extension removed and separators normalised to {@code /}.
*/
static String moduleName(String sourceFile) {
String s = sourceFile.replace('\\', '/');
for (String ext : SOURCE_EXTENSIONS) {
if (s.endsWith(ext)) {
return s.substring(0, s.length() - ext.length());
}
}
return s;
}
static String simpleName(String moduleName) {
int slash = moduleName.lastIndexOf('/');
return slash >= 0 ? moduleName.substring(slash + 1) : moduleName;
}
/**
* @return the first path segment, or {@code ""} for a file at the project root.
*/
static String workspace(String moduleName) {
int slash = moduleName.indexOf('/');
return slash >= 0 ? moduleName.substring(0, slash) : "";
}
static boolean isExternal(String specifier, Set<String> externalPackages) {
if (specifier.startsWith("@") || specifier.startsWith("node:")) {
return true;
}
int slash = specifier.indexOf('/');
String head = slash >= 0 ? specifier.substring(0, slash) : specifier;
return externalPackages.contains(head) || externalPackages.contains(specifier);
}
/**
* Resolves {@code specifier} imported from {@code importer} (a module name) to the module name it
* denotes, or {@code null} for an npm package (see the class comment for the rules).
*/
static @Nullable String resolveImport(String importer, String specifier, Set<String> workspaces,
Set<String> externalPackages) {
String spec = stripExtension(specifier);
if (spec.startsWith("./") || spec.startsWith("../")) {
return normalise(directoryOf(importer) + "/" + spec);
}
if (spec.startsWith("#/")) {
return workspace(importer) + "/src/" + spec.substring(2);
}
int slash = spec.indexOf('/');
String head = slash >= 0 ? spec.substring(0, slash) : spec;
if (workspaces.contains(head)) {
return slash < 0 ? head + "/src/index" : spec;
}
if (isExternal(spec, externalPackages)) {
return null;
}
return workspace(importer) + "/src/" + spec;
}
private static String stripExtension(String specifier) {
for (String ext : SOURCE_EXTENSIONS) {
if (specifier.endsWith(ext)) {
return specifier.substring(0, specifier.length() - ext.length());
}
}
return specifier;
}
private static String directoryOf(String moduleName) {
int slash = moduleName.lastIndexOf('/');
return slash >= 0 ? moduleName.substring(0, slash) : "";
}
/**
* Collapses {@code .} and {@code ..} segments; a {@code ..} above the root is dropped.
*/
private static String normalise(String path) {
Deque<String> out = new ArrayDeque<>();
for (String seg : path.split("/")) {
if (seg.isEmpty() || seg.equals(".")) {
continue;
}
if (seg.equals("..")) {
out.pollLast();
} else {
out.addLast(seg);
}
}
return String.join("/", out);
}
}

View File

@@ -0,0 +1,543 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser;
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner.Placeholders;
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner.Shell;
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
import org.jspecify.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
/**
* {@link LanguageParser} for TypeScript/React (roadmap item 192). Without facts for a file the
* result is the Tier-1 outline of {@link TypeScriptCoarseScanner}. With {@link TypeScriptFacts} from
* the sidecar, the file's declarations, imports and calls come from the type checker instead:
* <ul>
* <li>declarations: {@code FUNCTION} for {@code function}/{@code component}/{@code hook}/{@code thunk}/
* {@code styled}/{@code class}, {@code DATA_STRUCTURE} for {@code interface}/{@code type}/{@code enum};
* a {@code slice} or {@code const} is data and gets no node here (item 194 models slices)</li>
* <li>imports: resolved against the file system by the sidecar; an npm package lands in
* {@code externalImports}; an unresolved specifier keeps the Tier-1 guess</li>
* <li>item 193: every generated web-service call (legacy {@code XEndpoint.member}, hey-api const) is
* a {@code FUNCTION} of {@code kind=endpoint} carrying {@code restPath}/{@code restBase}/{@code
* httpMethod}/{@code outbound=true} — the properties the Java parser writes for a handler, so
* {@code rest-endpoints} lists them — plus {@code backend}, {@code restUrl}, {@code queryParams},
* {@code requestType}, {@code responseType}, {@code generator}; an interface's properties are
* {@code FIELD}s named {@code <Interface>.<member>} under its {@code DATA_STRUCTURE}; a member call on an Endpoint instance is
* retargeted to the endpoint {@code FUNCTION} ({@code calleeMethod = X.member})</li>
* <li>calls: a callee owned by a top-level declaration of <em>this</em> file is a
* {@code FUNCTION -CALLS-> FUNCTION} edge; one owned by another module is a
* {@code MODULE -CALLS-> MODULE(placeholder)} edge carrying {@code callKind}, {@code callerFn}
* and {@code calleeMethod} exactly as the Java parser writes them, so the function-level
* callers/callees queries (item 52) and the placeholder rewiring apply unchanged. A member call
* also carries {@code receiver} and {@code member} for item 193. Calls into npm packages and
* the language library are not edges.</li>
* <li>item 194 (Redux store): a {@code createSlice} is a {@code STORE_SLICE} named by the reducer key
* it is mounted under ({@code TypeScriptFacts#storeKey}), with one store {@code FIELD} per top-level
* state key ({@code <key>.<field>}, {@code store=true}) under it. Every reducer (case reducers,
* extra-reducer cases, matchers) is a {@code FUNCTION} of {@code kind=reducer} named by the action
* type it handles ({@code schluesseltabelle/updateX}, {@code schluesseltabelle/suche/fulfilled}),
* with {@code READS}/{@code WRITES} edges to the store fields it touches ({@code path} = the full
* sub-path). A thunk whose lifecycle action a case handles {@code CALLS} that case. Selector
* arrows, wrapper-hook selectors and {@code getState()} chains in any file are {@code READS} from
* the reading function to a store placeholder ({@code via} = the hook), resolved by name at
* finalize. A dispatched action creator carries {@code actionType}, and its {@code calleeMethod}
* is the reducer function, so callees of a component list what it dispatches.</li>
* <li>item 195 (DTO field bindings): a use of a generated {@code Fields} path object
* ({@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}) is a {@code READS} (and, on an
* input component, {@code WRITES}) edge from the binding function to the DTO {@code FIELD} the
* leaf names ({@code Broker.ebene}) — same-file directly, otherwise a {@code binding=true}
* placeholder resolved at finalize through {@code targetModule → owner → field}. The edge carries
* {@code path}, {@code rootDto}, {@code kind}, {@code partial}, {@code component}, {@code attribute},
* {@code via=binding}.</li>
* <li>item 196 (styling): the file with {@code createTheme} gets a {@code DATA_STRUCTURE theme} with a
* {@code FIELD theme.<token>} per token ({@code theme=true}, {@code value}); every {@code sx}/{@code style}/
* {@code styled} block is a {@code STYLE} under its component ({@code properties}, {@code literals},
* {@code element}); a theme token read is a {@code REFERENCES} edge ({@code via=theme}, {@code property}
* or {@code context}) to the token, a placeholder when the theme lives in another file. A CSS file
* gets a {@code STYLE} per rule from the Tier-1 scanner.</li>
* </ul>
*/
public final class TypeScriptParser implements LanguageParser {
private static final Set<String> FUNCTION_KINDS = Set.of("function", "component", "hook", "thunk", "styled", "class");
private static final Set<String> DATA_STRUCTURE_KINDS = Set.of("interface", "type", "enum");
/**
* Item 195: components that write the bound field (everything else reads it).
*/
private static final java.util.regex.Pattern WRITING_COMPONENTS = java.util.regex.Pattern.compile(".*(Input|Dropzone|Editor)$");
private final TypeScriptCoarseScanner scanner;
public TypeScriptParser() {
this(new TypeScriptCoarseScanner());
}
public TypeScriptParser(Set<String> workspaces, Set<String> externalPackages) {
this(new TypeScriptCoarseScanner(workspaces, externalPackages));
}
private TypeScriptParser(TypeScriptCoarseScanner scanner) {
this.scanner = scanner;
}
/**
* Item 194: the slice, its store fields, its reducer functions and their field accesses.
*/
private static void sliceNodes(TypeScriptProject project, AstNode module, SliceFact sl, Map<String, AstNode> declared,
List<AstNode> nodes, List<AstEdge> edges) {
String sourceFile = module.sourceFile();
String key = project.facts().storeKey(module.name(), sl.sliceName());
Map<String, String> sliceProps = new LinkedHashMap<>();
sliceProps.put("store", "true");
sliceProps.put("sliceName", sl.sliceName());
sliceProps.put("storeKey", key);
sliceProps.put("exported", Boolean.toString(sl.exported()));
if (sl.stateType() != null) {
sliceProps.put("stateType", sl.stateType());
}
AstNode slice = new AstNode(UUID.randomUUID(), NodeType.STORE_SLICE, key, sourceFile, TypeScriptLineCounter.LANGUAGE,
sl.startLine(), Math.max(sl.startLine(), sl.endLine()), sl.stateType(), null, sliceProps);
nodes.add(slice);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), slice.id(), sl.startLine(), null));
declared.put(sl.name(), slice);
Map<String, AstNode> fields = new HashMap<>();
for (MemberFact m : sl.fields()) {
Map<String, String> props = new LinkedHashMap<>();
props.put("store", "true");
props.put("slice", key);
props.put("field", m.name());
props.put("optional", Boolean.toString(m.optional()));
AstNode field = new AstNode(UUID.randomUUID(), NodeType.FIELD, key + "." + m.name(), sourceFile,
TypeScriptLineCounter.LANGUAGE, m.line(), m.line(), m.type(), null, props);
nodes.add(field);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, slice.id(), field.id(), m.line(), null));
fields.put(m.name(), field);
}
for (ReducerFact r : sl.reducers()) {
if (declared.containsKey(r.name())) {
continue;
}
Map<String, String> props = new LinkedHashMap<>();
props.put("kind", "reducer");
props.put("reducerKind", r.kind());
props.put("slice", key);
props.put("exported", "false");
TriggerFact trigger = r.trigger();
if (trigger != null) {
props.put("trigger", trigger.expression());
if (trigger.actionType() != null) {
props.put("actionType", trigger.actionType());
}
} else {
props.put("actionType", r.name());
}
AstNode fn = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, r.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
r.startLine(), Math.max(r.startLine(), r.endLine()), null, null, props);
nodes.add(fn);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), fn.id(), r.startLine(), null));
declared.put(r.name(), fn);
for (AccessFact a : r.accesses()) {
AstNode target = a.path().isEmpty() ? slice : fields.get(a.path().get(0));
if (target == null) {
continue; // a key the state type does not declare (dynamic state) — nothing to attach to
}
EdgeType type = a.mode().equals("write") ? EdgeType.WRITES : EdgeType.READS;
edges.add(accessEdge(type, fn, target, a.path(), a.line(), "reducer"));
}
// the thunk whose lifecycle action this case handles, when it lives in this file
if (trigger != null && trigger.decl() != null && module.name().equals(trigger.file())) {
AstNode thunk = declared.get(trigger.decl());
if (thunk != null && thunk.type() == NodeType.FUNCTION && thunk != fn) {
Map<String, String> callProps = new LinkedHashMap<>();
callProps.put("callKind", CallKind.METHOD_CALL.name());
callProps.put("callSyntax", "extraReducer");
if (trigger.actionType() != null) {
callProps.put("actionType", trigger.actionType());
}
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, thunk.id(), fn.id(), r.startLine(),
trigger.expression(), callProps));
}
}
}
}
private static AstEdge bindingEdge(EdgeType type, AstNode from, AstNode target, BindingFact b) {
Map<String, String> props = new LinkedHashMap<>();
props.put("path", b.path());
props.put("rootDto", b.rootDto());
props.put("kind", b.kind());
props.put("partial", Boolean.toString(b.partial()));
if (b.component() != null) {
props.put("component", b.component());
}
if (b.attribute() != null) {
props.put("attribute", b.attribute());
}
props.put("via", "binding");
return new AstEdge(UUID.randomUUID(), type, from.id(), target.id(), b.line(), null, props);
}
private static AstEdge accessEdge(EdgeType type, AstNode from, AstNode target, List<String> path, int line, String via) {
Map<String, String> props = new LinkedHashMap<>();
props.put("path", String.join(".", path));
props.put("via", via);
return new AstEdge(UUID.randomUUID(), type, from.id(), target.id(), line, null, props);
}
/**
* @return whether {@code calleeFile} declares a reducer FUNCTION named {@code actionType} (a slice action, not a thunk)
*/
private static boolean isReducerOf(TypeScriptProject project, String calleeFile, String actionType) {
FileFacts target = project.facts().byModuleName(calleeFile);
if (target == null) {
return false;
}
for (SliceFact sl : target.slices()) {
for (ReducerFact r : sl.reducers()) {
if (r.name().equals(actionType)) {
return true;
}
}
}
return false;
}
/**
* Item 193: a member call on a legacy Endpoint instance ({@code agstammUiApi.saveBroker.post})
* resolves, through the checker, to the {@code post} signature of the generic {@code PostMethod}
* interface — true, but useless as a call target. When the receiver type owns an endpoint whose
* member is the head of the property chain, the call is retargeted to that endpoint
* {@code FUNCTION} ({@code AgstammControllerEndpoint.saveBroker}), so function-level callers and
* the counterpart link land on the actual web-service call.
*/
private static String endpointTarget(TypeScriptProject project, String calleeFile, @Nullable String receiver,
@Nullable String member, String fallback) {
if (receiver == null || member == null) {
return fallback;
}
FileFacts target = project.facts().byModuleName(calleeFile);
if (target == null) {
return fallback;
}
int dot = member.indexOf('.');
String head = dot >= 0 ? member.substring(0, dot) : member;
for (EndpointFact e : target.endpoints()) {
if (receiver.equals(e.owner()) && head.equals(e.member())) {
return e.name();
}
}
return fallback;
}
private static Map<String, String> endpointProps(EndpointFact e) {
TypeScriptRestPaths paths = TypeScriptRestPaths.of(e.url());
Map<String, String> props = new LinkedHashMap<>();
props.put("kind", "endpoint");
props.put("exported", "true");
props.put("outbound", "true");
props.put("httpMethod", e.httpMethod());
props.put("restPath", paths.restPath());
props.put("restBase", paths.restBase());
props.put("restUrl", e.url());
if (!paths.queryParams().isEmpty()) {
props.put("queryParams", String.join(",", paths.queryParams()));
}
props.put("generator", e.generator());
if (e.backend() != null) {
props.put("backend", e.backend());
}
if (e.owner() != null) {
props.put("owner", e.owner());
}
if (e.member() != null) {
props.put("member", e.member());
}
if (e.requestType() != null) {
props.put("requestType", TypeScriptRestPaths.unqualified(e.requestType()));
}
if (e.responseType() != null) {
props.put("responseType", TypeScriptRestPaths.unqualified(e.responseType()));
}
if (e.paramsType() != null) {
props.put("paramsType", TypeScriptRestPaths.unqualified(e.paramsType()));
}
return props;
}
@Override
public String language() {
return TypeScriptLineCounter.LANGUAGE;
}
@Override
public ParseResult parse(String sourceFile, String content) {
return parse(sourceFile, content, TypeScriptFacts.NONE);
}
/**
* Facts with the scanner's default workspace/package sets (tests, ad-hoc use).
*/
public ParseResult parse(String sourceFile, String content, TypeScriptFacts facts) {
return parse(sourceFile, content, scanner.defaults().withFacts(facts));
}
/**
* The per-ingest entry point: the project's workspaces, packages and (on Tier-2) sidecar facts.
*/
public ParseResult parse(String sourceFile, String content, TypeScriptProject project) {
FileFacts file = project.facts().get(sourceFile);
if (file == null) {
return scanner.scan(sourceFile, content, project);
}
Shell shell = scanner.shell(sourceFile, content);
AstNode module = shell.module();
List<AstNode> nodes = new ArrayList<>();
List<AstEdge> edges = new ArrayList<>();
nodes.add(module);
if (shell.css()) {
TypeScriptCoarseScanner.cssRules(module, content, nodes, edges);
return new ParseResult(nodes, edges);
}
shell.props().put("ingestTier", "2");
Placeholders placeholders = new Placeholders(nodes);
Map<String, AstNode> declared = new HashMap<>();
Map<String, AstNode> dtoFields = new HashMap<>(); // "<Dto>.<field>" -> FIELD of this file (item 195)
// Item 193: endpoints first — a hey-api const is both a declaration and an endpoint, and the
// endpoint node (kind=endpoint, restPath, ...) is the one that must win.
for (EndpointFact e : file.endpoints()) {
if (declared.containsKey(e.name())) {
continue;
}
AstNode node = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, e.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
e.line(), e.line(), null, null, endpointProps(e));
nodes.add(node);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), node.id(), e.line(), null));
declared.put(e.name(), node);
}
for (DeclarationFact d : file.declarations()) {
NodeType type = FUNCTION_KINDS.contains(d.kind()) ? NodeType.FUNCTION
: DATA_STRUCTURE_KINDS.contains(d.kind()) ? NodeType.DATA_STRUCTURE : null;
if (type == null || declared.containsKey(d.name())) {
continue;
}
Map<String, String> props = new LinkedHashMap<>();
props.put("exported", Boolean.toString(d.exported()));
if (type == NodeType.FUNCTION) {
props.put("kind", d.kind());
}
AstNode node = new AstNode(UUID.randomUUID(), type, d.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
d.startLine(), Math.max(d.startLine(), d.endLine()), type == NodeType.DATA_STRUCTURE ? d.kind() : null,
null, props);
nodes.add(node);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), node.id(), d.startLine(), null));
declared.put(d.name(), node);
if (type == NodeType.DATA_STRUCTURE) {
// Item 193: the DTO shape — one FIELD per property, so data-structures/{name}/fields
// answers for a TypeScript interface and field counterparts can be matched by name.
// Named <Interface>.<member>: the node identity is (type, name, sourceFile), and one generated
// file declares hundreds of interfaces — a bare `vid` would be ONE node under six interfaces
// (found by item 195). `field` keeps the bare member name, `owner` the interface.
for (MemberFact m : d.members()) {
Map<String, String> fieldProps = new LinkedHashMap<>();
fieldProps.put("optional", Boolean.toString(m.optional()));
fieldProps.put("field", m.name());
fieldProps.put("owner", d.name());
AstNode field = new AstNode(UUID.randomUUID(), NodeType.FIELD, d.name() + "." + m.name(), sourceFile,
TypeScriptLineCounter.LANGUAGE, m.line(), m.line(), m.type(), null, fieldProps);
nodes.add(field);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, node.id(), field.id(), m.line(), null));
dtoFields.put(d.name() + "." + m.name(), field);
}
}
}
for (SliceFact sl : file.slices()) {
sliceNodes(project, module, sl, declared, nodes, edges);
}
for (StateAccessFact a : file.stateAccesses()) {
if (a.path().isEmpty()) {
continue;
}
AstNode fromNode = declared.get(a.fromDecl());
AstNode reader = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
AstNode target = a.path().size() == 1
? placeholders.store(NodeType.STORE_SLICE, a.path().get(0))
: placeholders.store(NodeType.FIELD, a.path().get(0) + "." + a.path().get(1));
edges.add(accessEdge(EdgeType.READS, reader, target, a.path().subList(1, a.path().size()), a.line(), a.via()));
}
// Item 196: the theme (one DATA_STRUCTURE with a FIELD per token), the style blocks and the token reads.
Map<String, AstNode> tokenNodes = new HashMap<>();
if (!file.themeTokens().isEmpty()) {
Map<String, String> themeProps = new LinkedHashMap<>();
themeProps.put("kind", "theme");
themeProps.put("exported", "true");
int first = file.themeTokens().get(0).line();
AstNode theme = new AstNode(UUID.randomUUID(), NodeType.DATA_STRUCTURE, "theme", sourceFile, TypeScriptLineCounter.LANGUAGE,
first, first, "theme", null, themeProps);
nodes.add(theme);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), theme.id(), first, null));
for (ThemeTokenFact t : file.themeTokens()) {
if (tokenNodes.containsKey(t.token())) {
continue;
}
Map<String, String> props = new LinkedHashMap<>();
props.put("theme", "true");
props.put("token", t.token());
props.put("tokenKind", t.kind());
if (t.constant() != null) {
props.put("constant", t.constant());
}
if (t.variants() > 1) {
props.put("variants", Integer.toString(t.variants()));
}
AstNode token = new AstNode(UUID.randomUUID(), NodeType.FIELD, "theme." + t.token(), sourceFile,
TypeScriptLineCounter.LANGUAGE, t.line(), t.line(), t.value(), t.value(), props);
nodes.add(token);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, theme.id(), token.id(), t.line(), null));
tokenNodes.put(t.token(), token);
}
}
Set<String> styleNames = new HashSet<>();
for (StyleFact st : file.styles()) {
AstNode owner = declared.get(st.fromDecl());
AstNode parent = owner != null && owner.type() == NodeType.FUNCTION ? owner : module;
String base = owner != null ? st.fromDecl() : TypeScriptModuleNames.simpleName(module.name());
String name = base + "." + st.styleKind() + "@" + st.line() + ":" + st.col();
if (!styleNames.add(name)) {
continue;
}
Map<String, String> props = new LinkedHashMap<>();
props.put("styleKind", st.styleKind());
if (st.element() != null) {
props.put("element", st.element());
}
props.put("properties", String.join(",", st.properties()));
if (!st.literals().isEmpty()) {
props.put("literals", String.join(",", st.literals()));
}
props.put("dynamic", Boolean.toString(st.dynamic()));
props.put("spread", Boolean.toString(st.spread()));
AstNode style = new AstNode(UUID.randomUUID(), NodeType.STYLE, name, sourceFile, TypeScriptLineCounter.LANGUAGE,
st.line(), st.line(), st.element(), null, props);
nodes.add(style);
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, parent.id(), style.id(), st.line(), null));
// Item 199: edges merge on (lineNo), so two reads of one token on one line (`color: PRIMARY,
// borderColor: PRIMARY`) become one edge whose `property` lists every key they feed.
Map<String, List<StyleTokenFact>> perSite = new LinkedHashMap<>();
for (StyleTokenFact t : st.tokens()) {
perSite.computeIfAbsent(t.token() + "@" + t.line(), k -> new ArrayList<>()).add(t);
}
for (List<StyleTokenFact> site : perSite.values()) {
StyleTokenFact t = site.get(0);
AstNode target = tokenNodes.containsKey(t.token()) ? tokenNodes.get(t.token()) : placeholders.theme(t.token());
Map<String, String> eprops = new LinkedHashMap<>();
eprops.put("via", "theme");
String property = site.stream().map(StyleTokenFact::property).filter(Objects::nonNull).distinct()
.collect(Collectors.joining(","));
if (!property.isEmpty()) {
eprops.put("property", property);
}
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, style.id(), target.id(), t.line(), t.token(), eprops));
}
}
for (TokenRefFact r : file.tokenRefs()) {
AstNode fromNode = declared.get(r.fromDecl());
AstNode from = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
AstNode target = tokenNodes.containsKey(r.token()) ? tokenNodes.get(r.token()) : placeholders.theme(r.token());
Map<String, String> eprops = new LinkedHashMap<>();
eprops.put("via", "theme");
eprops.put("context", r.context());
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, from.id(), target.id(), r.line(), r.token(), eprops));
}
for (BindingFact b : file.bindings()) {
AstNode fromNode = declared.get(b.fromDecl());
AstNode binder = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
AstNode target = module.name().equals(b.ownerFile()) ? dtoFields.get(b.ownerDto() + "." + b.field())
: placeholders.binding(b.ownerDto(), b.field(), b.ownerFile());
if (target == null) {
continue; // the leaf is not a declared property of the owner interface
}
edges.add(bindingEdge(EdgeType.READS, binder, target, b));
if (b.component() != null && WRITING_COMPONENTS.matcher(b.component()).matches()) {
edges.add(bindingEdge(EdgeType.WRITES, binder, target, b));
}
}
Set<String> external = new TreeSet<>();
for (ImportFact i : file.imports()) {
String target = i.resolved();
if (target == null && i.packageName() == null) {
// The checker resolved neither a file nor a package. A bare specifier that is not a
// workspace is then a transitive dependency the project does not list (`immer`,
// `redux`), not a baseUrl-relative module — the Tier-1 guess would mint a placeholder
// that never resolves. Relative, `#/` and workspace specifiers keep the Tier-1 rule.
String spec = i.specifier();
int slash = spec.indexOf('/');
String head = slash >= 0 ? spec.substring(0, slash) : spec;
boolean bare = !spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#");
target = bare && !project.workspaces().contains(head) ? null
: TypeScriptCoarseScanner.resolveImport(project, module.name(), spec);
}
if (target == null) {
external.add(i.packageName() != null ? i.packageName() : i.specifier());
continue;
}
edges.add(TypeScriptCoarseScanner.importEdge(module, placeholders.module(target), i.specifier(), i.names(), i.line()));
}
shell.setExternalImports(external);
Set<String> seen = new HashSet<>();
for (CallFact c : file.calls()) {
if (!c.resolvedInProject()) {
continue;
}
String calleeFile = Objects.requireNonNull(c.file());
String calleeDecl = Objects.requireNonNull(c.decl());
AstNode fromNode = declared.get(c.fromDecl());
// the calling FUNCTION, or the module itself for a call at module level / inside a const
AstNode caller = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
boolean callerIsFunction = caller != module;
Map<String, String> props = new LinkedHashMap<>();
props.put("callKind", (c.kind().equals("new") ? CallKind.CONSTRUCTOR : CallKind.METHOD_CALL).name());
props.put("callSyntax", c.kind());
if (c.receiver() != null) {
props.put("receiver", c.receiver());
}
if (c.member() != null) {
props.put("member", c.member());
}
String actionType = c.actionType();
if (actionType != null) {
props.put("actionType", actionType);
}
if (module.name().equals(calleeFile)) {
// a dispatched slice action targets its reducer FUNCTION (named by the action type)
AstNode callee = actionType != null && declared.containsKey(actionType) ? declared.get(actionType) : declared.get(calleeDecl);
if (callee == null || callee.type() != NodeType.FUNCTION) {
continue;
}
if (seen.add("L|" + caller.id() + "|" + callee.id() + "|" + c.line())) {
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, caller.id(), callee.id(), c.line(),
c.expression(), props));
}
continue;
}
if (callerIsFunction) {
props.put("callerFn", caller.name());
}
props.put("calleeMethod", actionType != null && isReducerOf(project, calleeFile, actionType) ? actionType
: endpointTarget(project, calleeFile, c.receiver(), c.member(), calleeDecl));
if (seen.add("X|" + calleeFile + "|" + calleeDecl + "|" + c.fromDecl() + "|" + c.line())) {
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, module.id(), placeholders.module(calleeFile).id(),
c.line(), c.expression(), props));
}
}
return new ParseResult(nodes, edges);
}
}

View File

@@ -0,0 +1,131 @@
package com.agenticcode.parsertypescript;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;
/**
* Per-ingest context for a TypeScript project (roadmap item 192), the TypeScript counterpart of the
* Natural {@code CopycodeLibrary}: built once per ingest from the root {@code package.json} and
* handed into every per-file scan/parse call.
*
* @param workspaces npm workspace directory names under the root ({@code pur-ui-common}, …);
* a bare import of one resolves to that workspace's barrel
* @param externalPackages npm dependency names from every {@code package.json} found; imports of
* them are recorded on the module, never turned into placeholders
* @param facts Tier-2 facts from the sidecar, or {@link TypeScriptFacts#NONE} on a Tier-1 pass
*/
public record TypeScriptProject(Set<String> workspaces, Set<String> externalPackages, TypeScriptFacts facts) {
public static final TypeScriptProject NONE =
new TypeScriptProject(Set.of(), TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES, TypeScriptFacts.NONE);
private static final ObjectMapper MAPPER = new ObjectMapper();
public TypeScriptProject {
workspaces = Set.copyOf(workspaces);
externalPackages = Set.copyOf(externalPackages);
}
/**
* Reads {@code root/package.json}: its {@code workspaces} (plain directory names, or
* {@code dir/*} globs expanded one level) and the union of {@code dependencies} and
* {@code devDependencies} of the root and of every workspace, plus every installed package under
* {@code root/node_modules} (transitive dependencies are imported too). A root without a
* {@code package.json} yields no workspaces and the built-in package list, so a plain tree of
* {@code .ts} files still scans.
*/
public static TypeScriptProject scan(Path root) throws IOException {
Set<String> workspaces = new LinkedHashSet<>();
Set<String> packages = new TreeSet<>(TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
JsonNode rootJson = readPackageJson(root);
if (rootJson != null) {
addDependencies(rootJson, packages);
for (JsonNode ws : rootJson.path("workspaces")) {
String pattern = ws.asText();
if (pattern.endsWith("/*")) {
Path dir = root.resolve(pattern.substring(0, pattern.length() - 2));
if (Files.isDirectory(dir)) {
try (DirectoryStream<Path> children = Files.newDirectoryStream(dir, Files::isDirectory)) {
for (Path child : children) {
workspaces.add(root.relativize(child).toString().replace('\\', '/'));
}
}
}
} else if (!pattern.isBlank()) {
workspaces.add(pattern.replace('\\', '/'));
}
}
}
Set<String> workspacePackageNames = new LinkedHashSet<>();
for (String ws : workspaces) {
JsonNode wsJson = readPackageJson(root.resolve(ws));
if (wsJson != null) {
addDependencies(wsJson, packages);
String name = wsJson.path("name").asText("");
if (!name.isBlank()) {
workspacePackageNames.add(name);
}
}
int slash = ws.lastIndexOf('/');
workspacePackageNames.add(slash >= 0 ? ws.substring(slash + 1) : ws);
}
// Installed packages: package.json lists direct dependencies only, but code imports transitive
// ones too (`immer`, `redux` behind @reduxjs/toolkit). Without this the Tier-1 pass at project
// creation minted a placeholder `<ws>/src/redux` per such import (verified on purfe).
Path nodeModules = root.resolve("node_modules");
if (Files.isDirectory(nodeModules)) {
try (DirectoryStream<Path> entries = Files.newDirectoryStream(nodeModules, Files::isDirectory)) {
for (Path entry : entries) {
String name = entry.getFileName().toString();
if (name.startsWith(".")) {
continue;
}
if (name.startsWith("@")) {
try (DirectoryStream<Path> scoped = Files.newDirectoryStream(entry, Files::isDirectory)) {
for (Path child : scoped) {
packages.add(name + "/" + child.getFileName());
}
}
} else {
packages.add(name);
}
}
}
}
// a workspace (by its package name or its directory name) is never an npm package from the
// resolver's point of view, even when another workspace lists it as a dependency
packages.removeAll(workspaces);
packages.removeAll(workspacePackageNames);
return new TypeScriptProject(workspaces, packages, TypeScriptFacts.NONE);
}
private static @Nullable JsonNode readPackageJson(Path dir) throws IOException {
Path file = dir.resolve("package.json");
if (!Files.isRegularFile(file)) {
return null;
}
return MAPPER.readTree(Files.readString(file));
}
private static void addDependencies(JsonNode packageJson, Set<String> into) {
for (String section : new String[]{"dependencies", "devDependencies", "peerDependencies"}) {
for (Iterator<String> it = packageJson.path(section).fieldNames(); it.hasNext(); ) {
into.add(it.next());
}
}
}
public TypeScriptProject withFacts(TypeScriptFacts newFacts) {
return new TypeScriptProject(workspaces, externalPackages, newFacts);
}
}

View File

@@ -0,0 +1,64 @@
package com.agenticcode.parsertypescript;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Item 193: normalises a frontend URL template into the shape the backend's {@code rest-endpoints}
* rows use, so the two sides can be matched.
*
* <p>The backend persists a handler's path <em>without</em> its application base
* ({@code quarkus.rest.path=/pur-r-vbuch/v1}, {@code @ApplicationPath("pur-r-vstamm/v1")}), while
* the frontend composes that base in: the legacy generator through {@code buildPurRVstammURL},
* hey-api by embedding it in every URL literal. A leading {@code /<name>/v<digits>} segment pair is
* therefore split off as {@code restBase}; the remainder, with the query string removed and no
* trailing slash, is {@code restPath}. {@code {param}} placeholders stay as written — the matcher
* compares them shape-wise.
*
* @param restBase the application base that was split off ({@code /pur-r-vstamm/v1}), or {@code ""}
* @param restPath the path relative to the base, always starting with {@code /}
* @param queryParams the query-string parameter names in order
*/
public record TypeScriptRestPaths(String restBase, String restPath, List<String> queryParams) {
private static final Pattern APP_BASE = Pattern.compile("^/([^/]+)/v\\d+(?=/|$)");
public static TypeScriptRestPaths of(String url) {
String path = url;
List<String> query = new ArrayList<>();
int q = url.indexOf('?');
if (q >= 0) {
path = url.substring(0, q);
for (String pair : url.substring(q + 1).split("&")) {
if (pair.isEmpty()) {
continue;
}
int eq = pair.indexOf('=');
query.add(eq >= 0 ? pair.substring(0, eq) : pair);
}
}
path = ("/" + path).replaceAll("/{2,}", "/");
if (path.length() > 1 && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
String base = "";
Matcher m = APP_BASE.matcher(path);
if (m.find()) {
base = m.group();
path = path.substring(base.length());
if (path.isEmpty()) {
path = "/";
}
}
return new TypeScriptRestPaths(base, path, List.copyOf(query));
}
/**
* Strips the generated namespace qualifiers ({@code COMMON.SvcResult<API.X>} → {@code SvcResult<X>}).
*/
public static String unqualified(String type) {
return type.replaceAll("\\b[A-Z][A-Z0-9_]*\\.(?=[A-Za-z_])", "");
}
}

View File

@@ -0,0 +1,133 @@
package com.agenticcode.parsertypescript;
import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Runs the Node sidecar ({@code sidecar/extract.mjs}) for one workspace and reads its facts
* (roadmap item 192). One short-lived process per call: {@code node --max-old-space-size=<mb>
* extract.mjs --root <root> --workspace <ws> [--files ...]}, stdout is the JSON document, stderr is
* kept for the error message, and the process is killed on timeout. Nothing is written to disk by
* the sidecar (the project root is mounted read-only in the container). Measured on the pur
* frontend: 3–5 s and ~0.5 GB heap per workspace, so workspaces are run one after another.
*/
public final class TypeScriptSidecar {
private final Path node;
private final Path script;
private final int maxHeapMb;
private final Duration timeout;
/**
* @param node the {@code node} binary
* @param script {@code extract.mjs}; its sibling {@code node_modules/typescript} must be installed
* @param maxHeapMb V8 old-space cap (validated: 1024 leaves headroom in the 5 GB container)
* @param timeout per-workspace wall clock before the process is killed
*/
public TypeScriptSidecar(Path node, Path script, int maxHeapMb, Duration timeout) {
this.node = node;
this.script = script;
this.maxHeapMb = maxHeapMb;
this.timeout = timeout;
}
private static void deleteQuietly(Path p) {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
// best effort
}
}
/**
* @return whether the sidecar can run here: node binary, script and its typescript dependency present.
*/
public boolean available() {
return Files.isExecutable(node) && Files.isRegularFile(script)
&& Files.isDirectory(script.resolveSibling("node_modules").resolve("typescript"));
}
/**
* @param root project root (absolute)
* @param workspace directory under {@code root} to load as one program
* @param files root-relative files to emit facts for, or {@code null} for all of the workspace
*/
public TypeScriptFacts extract(Path root, String workspace, @Nullable Collection<String> files) {
List<String> cmd = new ArrayList<>(List.of(node.toString(), "--max-old-space-size=" + maxHeapMb,
script.toString(), "--root", root.toString(), "--workspace", workspace));
if (files != null && !files.isEmpty()) {
cmd.add("--files");
cmd.add(String.join(",", files));
}
Path scriptDir = script.toAbsolutePath().getParent();
ProcessBuilder pb = new ProcessBuilder(cmd);
if (scriptDir != null) {
pb.directory(scriptDir.toFile());
}
Path stderr;
try {
stderr = Files.createTempFile("ac-ts-sidecar", ".err");
} catch (IOException e) {
throw new SidecarException("cannot create stderr capture file", e);
}
pb.redirectError(stderr.toFile());
Process process;
try {
process = pb.start();
} catch (IOException e) {
deleteQuietly(stderr);
throw new SidecarException("cannot start " + String.join(" ", cmd), e);
}
try {
// Read stdout on this thread while the process runs: the document is large and a pipe
// that is not drained would block the sidecar before the exit code is ever seen.
byte[] out;
try (InputStream in = process.getInputStream()) {
out = in.readAllBytes();
}
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
throw new SidecarException("sidecar for workspace '" + workspace + "' exceeded " + timeout);
}
if (process.exitValue() != 0) {
throw new SidecarException("sidecar for workspace '" + workspace + "' exited " + process.exitValue()
+ ": " + Files.readString(stderr, StandardCharsets.UTF_8).strip());
}
return TypeScriptFactsReader.read(new String(out, StandardCharsets.UTF_8));
} catch (IOException e) {
throw new SidecarException("sidecar for workspace '" + workspace + "' produced unreadable facts", e);
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
throw new SidecarException("interrupted while waiting for the sidecar", e);
} finally {
if (process.isAlive()) {
process.destroyForcibly();
}
deleteQuietly(stderr);
}
}
/**
* The sidecar could not run or did not produce facts; the ingest reports it and falls back to Tier-1.
*/
public static final class SidecarException extends RuntimeException {
public SidecarException(String message) {
super(message);
}
public SidecarException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,8 @@
/**
* TypeScript/React source parser (roadmap item 192). Tier-1 is a pure-Java regex coarse scan
* ({@link com.agenticcode.parsertypescript.TypeScriptCoarseScanner}); Tier-2 detail comes from the
* Node sidecar on the TypeScript compiler API and is handed in as
* {@link com.agenticcode.parsertypescript.TypeScriptFacts}.
*/
@org.jspecify.annotations.NullMarked
package com.agenticcode.parsertypescript;

View File

@@ -0,0 +1,31 @@
package com.agenticcode.parsertypescript;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Reads a fixture under {@code src/test/resources/fixtures/typescript/}; the path doubles as the graph {@code sourceFile}.
*/
final class Fixtures {
static final String SLICE = "pur-r-vstamm/src/store/slices/agstammSlice.ts";
static final String PAGE = "pur-r-vstamm/src/components/Agstamm/AgstammPage.tsx";
static final String ENDPOINTS = "pur-r-vstamm/src/generated/agstamm-endpoints.ts";
static final String CSS = "pur-ui/index.css";
static final String FACTS = "facts-pur-r-vstamm.json";
static final Path ROOT = Path.of("src/test/resources/fixtures/typescript").toAbsolutePath();
private Fixtures() {
}
static String read(String sourceFile) {
try {
return Files.readString(Path.of("src/test/resources/fixtures/typescript", sourceFile), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}

View File

@@ -0,0 +1,206 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tier-1 {@link TypeScriptCoarseScanner} (item 192) on the fixture files: module shell with metrics,
* function/component/hook/thunk/class shells with line ranges, interface/type/enum data structures,
* import placeholders named by resolved path, npm packages recorded and not placeholdered, and no
* dangling edges.
*/
class TypeScriptCoarseScannerTest {
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
private final TypeScriptCoarseScanner scanner =
new TypeScriptCoarseScanner(WS, TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
private static AstNode one(ParseResult r, NodeType type, String name) {
return r.nodes().stream().filter(n -> n.type() == type && n.name().equals(name)).findFirst()
.orElseThrow(() -> new AssertionError("missing " + type + " " + name + " in "
+ r.nodes().stream().map(n -> n.type() + ":" + n.name()).toList()));
}
private static String prop(AstNode n, String key) {
return Objects.requireNonNull(Objects.requireNonNull(n.properties()).get(key), key);
}
private static AstNode byId(ParseResult r, UUID id) {
return r.nodes().stream().filter(n -> n.id().equals(id)).findFirst().orElseThrow();
}
private static AstNode module(ParseResult r) {
return r.nodes().stream().filter(n -> n.type() == NodeType.MODULE && !n.sourceFile().isEmpty()).findFirst().orElseThrow();
}
@Test
void language() {
assertEquals("typescript", scanner.language());
assertEquals("typescript", new TypeScriptParser().language());
}
@Test
void moduleShellCarriesIdentityAndMetrics() {
String src = Fixtures.read(Fixtures.SLICE);
AstNode m = module(scanner.scan(Fixtures.SLICE, src));
assertEquals("pur-r-vstamm/src/store/slices/agstammSlice", m.name());
assertEquals("agstammSlice", prop(m, "simpleName"));
assertEquals("pur-r-vstamm", prop(m, "workspace"));
assertEquals("ts", prop(m, "moduleKind"));
assertEquals("false", prop(m, "generated"));
assertEquals(SourceHash.of(src), prop(m, SourceHash.PROPERTY));
assertEquals("63", prop(m, "loc"));
assertEquals("@reduxjs/toolkit", prop(m, "externalImports"));
assertEquals(1, m.startLine());
assertEquals(63, m.endLine());
}
@Test
void sliceDeclarations() {
ParseResult r = scanner.scan(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
AstNode load = one(r, NodeType.FUNCTION, "loadBrokerFromServer");
assertEquals("thunk", prop(load, "kind"));
assertEquals("true", prop(load, "exported"));
assertEquals(41, load.startLine());
assertEquals(44, load.endLine());
assertEquals("thunk", prop(one(r, NodeType.FUNCTION, "saveBrokerToServer"), "kind"));
AstNode sel = one(r, NodeType.FUNCTION, "selectAgstamm");
assertEquals("function", prop(sel, "kind"));
assertEquals(51, sel.endLine(), "an arrow without a block ends at its own line");
AstNode helper = one(r, NodeType.FUNCTION, "statusOf");
assertEquals("false", prop(helper, "exported"));
assertEquals(58, helper.startLine());
assertEquals(60, helper.endLine());
AstNode state = one(r, NodeType.DATA_STRUCTURE, "AgstammState");
assertEquals("interface", state.dataType());
assertEquals(8, state.startLine());
assertEquals(11, state.endLine());
assertTrue(r.nodes().stream().noneMatch(n -> n.name().equals("agstammSlice") && n.type() == NodeType.FUNCTION),
"createSlice(...) is data, not a callable shell (item 194 models it)");
assertTrue(r.nodes().stream().noneMatch(n -> n.name().equals("initialState")));
}
@Test
void importsBecomePlaceholdersNamedByResolvedPath() {
ParseResult r = scanner.scan(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
AstNode m = module(r);
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES).toList();
Set<String> targets = refs.stream().map(e -> byId(r, e.targetId()).name()).collect(Collectors.toSet());
assertEquals(Set.of("pur-r-vstamm/src/generated/agstamm-interfaces",
"pur-r-vstamm/src/generated/agstamm-endpoints",
"pur-r-vstamm/src/generated/client/sdk.gen",
"pur-ui-common/src/index",
"pur-r-vstamm/src/store/rv-redux-types"), targets);
assertTrue(refs.stream().allMatch(e -> e.sourceId().equals(m.id())));
AstEdge common = refs.stream().filter(e -> byId(r, e.targetId()).name().equals("pur-ui-common/src/index")).findFirst().orElseThrow();
assertEquals("{createAppAsyncThunk, RequestStatus, SvcResult}", common.value());
assertEquals("pur-ui-common", Objects.requireNonNull(common.properties()).get("specifier"));
assertEquals(4, common.lineNo());
assertTrue(r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).allMatch(n -> n.type() == NodeType.MODULE));
}
@Test
void pageComponentsHooksAndStyled() {
ParseResult r = scanner.scan(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE));
AstNode m = module(r);
assertEquals("tsx", prop(m, "moduleKind"));
assertEquals("@mui/material,@mui/material/styles,react", prop(m, "externalImports"));
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
assertEquals("component", prop(page, "kind"));
assertEquals(23, page.startLine());
assertEquals(42, page.endLine());
assertEquals("hook", prop(one(r, NodeType.FUNCTION, "useVermnr"), "kind"));
AstNode panel = one(r, NodeType.FUNCTION, "Panel");
assertEquals("styled", prop(panel, "kind"));
assertEquals("false", prop(panel, "exported"));
Set<String> targets = r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).map(AstNode::name).collect(Collectors.toSet());
assertEquals(Set.of("pur-ui-common/src/index", "pur-r-vstamm/src/store/hooks/useAgstamm",
"pur-r-vstamm/src/generated/agstamm-interfaces", "pur-r-vstamm/src/store/rv-redux-types",
"pur-r-vstamm/src/store/store", "pur-r-vstamm/src/store/slices/agstammSlice",
"pur-r-vstamm/src/components/Agstamm/HistorieDrawer"), targets);
}
@Test
void generatedEndpointsFile() {
ParseResult r = scanner.scan(Fixtures.ENDPOINTS, Fixtures.read(Fixtures.ENDPOINTS));
AstNode m = module(r);
assertEquals("true", prop(m, "generated"));
assertEquals("typescript-generator", prop(m, "generator"));
AstNode cls = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint");
assertEquals("class", prop(cls, "kind"));
assertEquals(21, cls.startLine());
assertEquals(33, cls.endLine(), "template literals with braces inside must not break brace matching");
assertEquals("type", one(r, NodeType.DATA_STRUCTURE, "AgstammAction").dataType());
assertEquals("enum", one(r, NodeType.DATA_STRUCTURE, "AgstammKind").dataType());
AstNode endpoint = one(r, NodeType.DATA_STRUCTURE, "Endpoint");
assertEquals("false", prop(endpoint, "exported"));
Set<String> targets = r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).map(AstNode::name).collect(Collectors.toSet());
assertEquals(Set.of("pur-ui-common/src/index", "pur-r-vstamm/src/generated/agstamm-interfaces"), targets);
}
@Test
void cssRulesSurviveAtStatementsStringsNestingAndMinification() {
// item 199 (review of 196): the four ways a real stylesheet broke the rule scanner
String css = """
@import url('https://fonts.googleapis.com/css?family=Open+Sans');
@charset "UTF-8";
a::after { content: "{"; color: #fff }
b::before { content: '}' }
@media (max-width: 600px) { a:hover { color: red; margin: 4px } }
.x{color:#000}.x{color:#111}p{margin:0}
""";
ParseResult r = scanner.scan("pur-ui/app.css", css);
List<String> names = r.nodes().stream().filter(n -> n.type() == NodeType.STYLE).map(AstNode::name).toList();
assertEquals(List.of("a::after@3", "b::before@4", "@media (max-width: 600px)@5", ".x@6", ".x@6:17", "p@6"), names);
assertEquals("content,color", prop(one(r, NodeType.STYLE, "a::after@3"), "properties"));
assertEquals("#fff", prop(one(r, NodeType.STYLE, "a::after@3"), "literals"));
AstNode media = one(r, NodeType.STYLE, "@media (max-width: 600px)@5");
assertEquals("color,margin", prop(media, "properties"), "the nested `a:hover {` head is a selector, not a declaration");
assertEquals("4px", prop(media, "literals"));
assertEquals("#111", prop(one(r, NodeType.STYLE, ".x@6:17"), "literals"));
}
@Test
void cssIsAModuleShellOnly() {
ParseResult r = scanner.scan(Fixtures.CSS, Fixtures.read(Fixtures.CSS));
assertEquals(3, r.nodes().size(), "the module and one STYLE per rule (item 196)");
AstNode m = r.nodes().get(0);
AstNode font = one(r, NodeType.STYLE, "@font-face@2");
assertEquals("css", prop(font, "styleKind"));
assertEquals("font-family,src", prop(font, "properties"));
assertNull(Objects.requireNonNull(font.properties()).get("literals"), "a url() is not a colour/length literal");
assertEquals(2, font.startLine());
assertEquals(5, font.endLine());
AstNode body = one(r, NodeType.STYLE, "body@7");
assertEquals("margin", prop(body, "properties"));
assertEquals(2, r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(m.id())).count());
assertEquals("css", m.language());
assertEquals("pur-ui/index.css", m.name());
assertEquals("index.css", prop(m, "simpleName"));
assertEquals("css", prop(m, "moduleKind"));
assertEquals("7", prop(m, "sloc"));
}
@Test
void everyEdgeConnectsTwoEmittedNodes() {
for (String f : List.of(Fixtures.SLICE, Fixtures.PAGE, Fixtures.ENDPOINTS)) {
ParseResult r = scanner.scan(f, Fixtures.read(f));
Set<UUID> ids = r.nodes().stream().map(AstNode::id).collect(Collectors.toSet());
for (AstEdge e : r.edges()) {
assertTrue(ids.contains(e.sourceId()) && ids.contains(e.targetId()), "dangling edge in " + f);
}
long contains = r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS).count();
long children = r.nodes().stream().filter(n -> n.type() != NodeType.MODULE).count();
assertEquals(children, contains, "one CONTAINS per declaration in " + f);
}
}
}

View File

@@ -0,0 +1,223 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.*;
/**
* The sidecar facts contract (item 192): {@code facts-pur-r-vstamm.json} is what
* {@code extract.mjs} printed for the fixture workspace, checked in so the Java half is tested
* without Node. Regenerate with
* {@code node sidecar/extract.mjs --root src/test/resources/fixtures/typescript --workspace pur-r-vstamm}
* whenever a fixture or the sidecar changes.
*/
class TypeScriptFactsReaderTest {
static TypeScriptFacts facts() throws IOException {
return TypeScriptFactsReader.read(Fixtures.read(Fixtures.FACTS));
}
@Test
void readsEveryFixtureFileOfTheWorkspace() throws IOException {
TypeScriptFacts f = facts();
assertEquals(13, f.byFile().size());
assertSame(f.get(Fixtures.SLICE), f.byModuleName("pur-r-vstamm/src/store/slices/agstammSlice"));
assertTrue(f.has(Fixtures.SLICE));
assertTrue(f.has(Fixtures.PAGE));
assertFalse(f.has(Fixtures.CSS), "css is not part of the TypeScript program");
}
@Test
void declarationsWithCheckerPositions() throws IOException {
FileFacts slice = Objects.requireNonNull(facts().get(Fixtures.SLICE));
List<String> kinds = slice.declarations().stream().map(d -> d.name() + ":" + d.kind()).toList();
assertEquals(List.of("AgstammState:interface", "initialState:const", "agstammSlice:slice", "agstammUiApi:const",
"loadBrokerFromServer:thunk", "saveBrokerToServer:thunk", "selectAgstamm:function", "loadHistorie:thunk",
"statusOf:function"), kinds);
DeclarationFact state = slice.declarations().get(0);
assertEquals(List.of("requestStatus:RequestStatus:false", "agstammUseCaseSvcResult:SvcResult<AgstammUseCase>:true"),
state.members().stream().map(m -> m.name() + ":" + m.type() + ":" + m.optional()).toList());
DeclarationFact load = slice.declarations().get(4);
assertTrue(load.exported());
assertEquals(41, load.startLine());
assertEquals(44, load.endLine());
}
@Test
void endpointsOfBothGenerators() throws IOException {
FileFacts legacy = Objects.requireNonNull(facts().get(Fixtures.ENDPOINTS));
assertEquals(2, legacy.endpoints().size());
EndpointFact save = legacy.endpoints().get(1);
assertEquals("AgstammControllerEndpoint.saveBroker", save.name());
assertEquals("AgstammControllerEndpoint", save.owner());
assertEquals("saveBroker", save.member());
assertEquals("POST", save.httpMethod());
assertEquals("typescript-generator", save.generator());
assertEquals("pur", save.backend());
assertEquals("/agstamm/ui/", save.url());
assertEquals("API.AgstammUseCase", save.requestType());
assertEquals("COMMON.SvcResult<API.AgstammUseCase>", save.responseType());
EndpointFact search = legacy.endpoints().get(0);
assertEquals("GET", search.httpMethod());
assertEquals("/agstamm/ui/search?vermnr={vermnr}", search.url());
assertEquals("{ vermnr: number }", search.paramsType());
assertNull(search.requestType());
FileFacts hey = Objects.requireNonNull(facts().get("pur-r-vstamm/src/generated/client/sdk.gen.ts"));
assertEquals(1, hey.endpoints().size());
EndpointFact h = hey.endpoints().get(0);
assertEquals("getPurRVstammV1AgstammUiHistorieByVermnr", h.name());
assertNull(h.owner());
assertEquals("hey-api", h.generator());
assertEquals("GET", h.httpMethod());
assertEquals("/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}", h.url());
assertEquals("GetPurRVstammV1AgstammUiHistorieByVermnrData", h.requestType());
assertEquals("GetPurRVstammV1AgstammUiHistorieByVermnrResponses", h.responseType());
}
@Test
void importsResolvedAgainstTheFileSystem() throws IOException {
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
ImportFact hook = page.imports().stream().filter(i -> i.specifier().equals("store/hooks/useAgstamm")).findFirst().orElseThrow();
assertEquals("pur-r-vstamm/src/store/hooks/useAgstamm", hook.resolved());
assertNull(hook.packageName());
assertEquals("{useAgstamm}", hook.names());
ImportFact react = page.imports().stream().filter(i -> i.specifier().equals("react")).findFirst().orElseThrow();
assertNull(react.resolved(), "no node_modules in the fixture tree: unresolved, both null");
assertNull(react.packageName());
}
@Test
void callsCarryOwnerReceiverAndMember() throws IOException {
FileFacts slice = Objects.requireNonNull(facts().get(Fixtures.SLICE));
CallFact post = slice.calls().stream().filter(c -> c.expression().equals("agstammUiApi.saveBroker.post")).findFirst().orElseThrow();
assertEquals("saveBrokerToServer", post.fromDecl());
assertEquals("pur-r-vstamm/src/generated/agstamm-endpoints", post.file());
assertEquals("PostMethod", post.decl(), "the interface owning the called member");
assertEquals("AgstammControllerEndpoint", post.receiver());
assertEquals("saveBroker.post", post.member());
assertTrue(post.resolvedInProject());
CallFact ctor = slice.calls().stream().filter(c -> c.kind().equals("new")).findFirst().orElseThrow();
assertEquals("AgstammControllerEndpoint", ctor.decl());
CallFact lib = slice.calls().stream().filter(c -> c.expression().equals("String")).findFirst().orElseThrow();
assertEquals("lib", lib.packageName());
assertFalse(lib.resolvedInProject());
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
CallFact local = page.calls().stream().filter(c -> c.expression().equals("dispatchLoadBrokerFromServer")).findFirst().orElseThrow();
assertNull(local.decl(), "a local binding has no owning top-level declaration");
CallFact unresolved = page.calls().stream().filter(c -> c.expression().equals("useEffect")).findFirst().orElseThrow();
assertNull(unresolved.symbol(), "the checker's unknown symbol is reported as null");
}
@Test
void rejectsAnotherContractVersion() {
IOException e = assertThrows(IOException.class, () -> TypeScriptFactsReader.read("{\"version\": 5, \"files\": {}}"));
assertTrue(String.valueOf(e.getMessage()).contains("version 5"));
}
@Test
void storeFactsOfItem194() throws IOException {
TypeScriptFacts f = facts();
StoreFact store = Objects.requireNonNull(Objects.requireNonNull(f.get("pur-r-vstamm/src/store/store.ts")).store());
assertEquals(1, store.keys().size());
assertEquals("broker", store.keys().get(0).key());
assertEquals("pur-r-vstamm/src/store/slices/agstammSlice", store.keys().get(0).sliceFile(), "traced through the default export");
assertEquals("agstamm", store.keys().get(0).sliceName());
assertEquals("broker", f.storeKey("pur-r-vstamm/src/store/slices/agstammSlice", "agstamm"), "the key differs from the slice name on purpose");
assertEquals("other", f.storeKey("nowhere", "other"), "unmounted: the slice name itself");
FileFacts sliceFile = Objects.requireNonNull(f.get(Fixtures.SLICE));
assertNull(sliceFile.store());
assertEquals(1, sliceFile.slices().size());
SliceFact slice = sliceFile.slices().get(0);
assertEquals("agstammSlice", slice.name());
assertEquals("agstamm", slice.sliceName());
assertEquals("AgstammState", slice.stateType());
assertEquals(List.of("requestStatus:RequestStatus:false", "agstammUseCaseSvcResult:SvcResult<AgstammUseCase>:true"),
slice.fields().stream().map(m -> m.name() + ":" + m.type() + ":" + m.optional()).toList(), "from the checker's type of initialState");
assertEquals(List.of("agstamm/updateAgstammUseCaseSvcResult:reducer", "agstamm/resetIfIdle:reducer",
"agstamm/loadBroker/fulfilled:case", "agstamm/matcher:(action) => action.type.endsWith('/rejected'):matcher"),
slice.reducers().stream().map(r -> r.name() + ":" + r.kind()).toList());
ReducerFact reset = slice.reducers().get(1);
assertEquals(List.of("read:requestStatus:23", "write:agstammUseCaseSvcResult:24"),
reset.accesses().stream().map(a -> a.mode() + ":" + String.join(".", a.path()) + ":" + a.line()).toList());
ReducerFact fulfilled = slice.reducers().get(2);
assertEquals("loadBrokerFromServer.fulfilled", Objects.requireNonNull(fulfilled.trigger()).expression());
assertEquals("agstamm/loadBroker/fulfilled", Objects.requireNonNull(fulfilled.trigger()).actionType());
assertEquals("loadBrokerFromServer", Objects.requireNonNull(fulfilled.trigger()).decl());
assertNull(Objects.requireNonNull(slice.reducers().get(3).trigger()).actionType(), "a predicate matcher has no action type");
FileFacts page = Objects.requireNonNull(f.get(Fixtures.PAGE));
assertEquals(List.of("AgstammPage:broker.requestStatus:useAppSelector", "AgstammPage:broker.agstammUseCaseSvcResult:useAppSelector",
"AgstammPage:broker.requestStatus:getState"),
page.stateAccesses().stream().map(a -> a.fromDecl() + ":" + String.join(".", a.path()) + ":" + a.via()).toList(),
"a selector arrow, a destructured selector result, a getState() chain");
CallFact dispatch = page.calls().stream().filter(c -> c.expression().equals("resetIfIdle")).findFirst().orElseThrow();
assertEquals("agstamm/resetIfIdle", dispatch.actionType());
assertEquals("agstammSlice", dispatch.decl(), "the action creator is owned by the slice const");
assertEquals(Fixtures.SLICE.replace(".ts", ""), dispatch.file());
CallFact thunk = Objects.requireNonNull(f.get("pur-r-vstamm/src/store/hooks/useAgstamm.ts")).calls().stream()
.filter(c -> c.expression().equals("loadBrokerFromServer")).findFirst().orElseThrow();
assertEquals("agstamm/loadBroker", thunk.actionType(), "a thunk carries its type prefix");
StateAccessFact wrapped = Objects.requireNonNull(f.get("pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx")).stateAccesses().get(0);
assertEquals(List.of("broker", "agstammUseCaseSvcResult", "result", "purMode"), wrapped.path(), "wrapper base path + the selector's own path");
assertEquals("useAgstammSelector", wrapped.via());
}
@Test
void bindingFactsOfItem195() throws IOException {
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
assertEquals(1, page.bindings().size());
TypeScriptFacts.BindingFact b = page.bindings().get(0);
assertEquals("AgstammPage", b.fromDecl());
assertEquals("field", b.kind());
assertEquals("AgstammUseCase", b.rootDto());
assertEquals("Broker", b.ownerDto(), "the DTO declaring the leaf, from the previous hop's TSelf");
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces", b.ownerFile());
assertEquals("ebene", b.field());
assertEquals("broker.ebene", b.path());
assertFalse(b.partial());
assertEquals("SmartInput", b.component());
assertEquals("field", b.attribute());
assertEquals(38, b.line());
}
@Test
void stylingFactsOfItem196() throws IOException {
TypeScriptFacts f = facts();
FileFacts theme = Objects.requireNonNull(f.get("pur-r-vstamm/src/rvTheme.ts"));
assertEquals(List.of("palette.primary.main:path:#005CA9:PRIMARY", "palette.primary.dark:path:#0054A2:PRIMARY_DARK",
"palette.primary.contrastText:path:#FFFFFF:null", "palette.background.paper:path:#FFFFFF:WHITE",
"shape.borderRadius:path:4:null", "typography.fontFamily:path:OpenSans:null", "typography.h1.fontWeight:path:300:null",
"palette.mode:path:dark:null", "PRIMARY:constant:#005CA9:null", "WHITE:constant:#FFFFFF:null"),
theme.themeTokens().stream().map(t -> t.token() + ":" + t.kind() + ":" + t.value() + ":" + t.constant()).toList(),
"createTheme leaves with values folded through constants, then the exported constants");
assertEquals(2, theme.themeTokens().stream().filter(t -> t.token().equals("palette.primary.main")).findFirst().orElseThrow().variants(),
"item 199: the dark theme in the same file declares it too");
assertEquals(1, theme.themeTokens().stream().filter(t -> t.token().equals("palette.mode")).findFirst().orElseThrow().variants());
assertTrue(theme.tokenRefs().isEmpty(), "the theme file's own constant uses are not references");
FileFacts drawer = Objects.requireNonNull(f.get("pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx"));
assertEquals(1, drawer.styles().size());
TypeScriptFacts.StyleFact sx = drawer.styles().get(0);
assertEquals("sx", sx.styleKind());
assertEquals("Box", sx.element());
assertEquals("HistorieDrawer", sx.fromDecl());
assertEquals(List.of("color", "borderColor", "height", "&:hover.background", "mt"), sx.properties(), "nested selectors flattened");
assertEquals(List.of("17px"), sx.literals(), "mt: 2 is theme-relative, not a literal");
assertFalse(sx.dynamic());
assertEquals(List.of("PRIMARY:color", "PRIMARY:borderColor", "palette.background.paper:&:hover.background"),
sx.tokens().stream().map(t -> t.token() + ":" + t.property()).toList(), "a theme constant and a theme chain, each with the key it feeds");
assertEquals(1, drawer.tokenRefs().size());
assertEquals("palette.primary.main", drawer.tokenRefs().get(0).token());
assertEquals("borderColor", drawer.tokenRefs().get(0).context(), "a token used as a plain prop, outside any style block");
FileFacts page = Objects.requireNonNull(f.get(Fixtures.PAGE));
assertEquals(List.of("Panel:styled:Box", "AgstammPage:sx:Panel", "AgstammPage:style:Typography"),
page.styles().stream().map(st -> st.fromDecl() + ":" + st.styleKind() + ":" + st.element()).toList());
assertEquals("spacing", page.styles().get(0).tokens().get(0).token(), "theme.spacing(2) ends at the call");
assertEquals("palette.primary.dark", page.styles().get(2).tokens().get(0).token(), "an imported theme object is a theme root too");
}
}

View File

@@ -0,0 +1,65 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.LocMetrics;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link TypeScriptLineCounter} (item 192): comments and blanks are not SLOC; a {@code //} inside a
* string or a template literal is code; a template literal spans lines as code.
*/
class TypeScriptLineCounterTest {
private final TypeScriptLineCounter counter = new TypeScriptLineCounter();
@Test
void language() {
assertEquals("typescript", counter.language());
}
@Test
void excludesCommentsAndBlanksButNotUrlsInStrings() {
String src = """
// header
import x from 'y'
/* block
comment */
const url = 'http://host/path' // trailing
export const t = `line one
// still inside the template
`
""";
LocMetrics m = counter.count(src);
assertEquals(9, m.loc());
// import, const url, export const t, the template's middle line, the closing backtick line = 5
assertEquals(5, m.sloc());
}
@Test
void jsxBlockCommentIsNotCode() {
String src = "return (\n {/* only a comment */}\n)\n";
assertEquals(3, counter.count(src).loc());
// the JSX braces around the comment are non-whitespace outside a comment, so the line is code
assertEquals(3, counter.count(src).sloc());
}
@Test
void fixturePageCounts() {
LocMetrics m = counter.count(Fixtures.read(Fixtures.PAGE));
assertEquals(44, m.loc());
// 4 blank + 2 block-comment lines are excluded; the `{/* JSX comment */}` line counts (braces)
assertEquals(38, m.sloc());
}
@Test
void cssCounter() {
CssLineCounter css = new CssLineCounter();
assertEquals("css", css.language());
LocMetrics m = css.count(Fixtures.read(Fixtures.CSS));
assertEquals(9, m.loc());
// @font-face {, font-family, src (code before the trailing comment), }, body {, margin, } = 7
assertEquals(7, m.sloc());
}
}

View File

@@ -0,0 +1,58 @@
package com.agenticcode.parsertypescript;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Import resolution rules of {@link TypeScriptModuleNames} (item 192) against the frontend's tsconfig conventions.
*/
class TypeScriptModuleNamesTest {
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
private static final Set<String> EXT = TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES;
private static final String IMPORTER = "pur-r-vstamm/src/store/slices/agstammSlice";
@Test
void identityIsPathWithoutExtension() {
assertEquals(IMPORTER, TypeScriptModuleNames.moduleName("pur-r-vstamm/src/store/slices/agstammSlice.ts"));
assertEquals("agstammSlice", TypeScriptModuleNames.simpleName(IMPORTER));
assertEquals("pur-r-vstamm", TypeScriptModuleNames.workspace(IMPORTER));
assertEquals("pur-ui/index.css", TypeScriptModuleNames.moduleName("pur-ui\\index.css"), "css keeps its extension");
assertEquals("pur-ui/src/index.css", TypeScriptModuleNames.resolveImport("pur-ui/src/main", "./index.css", WS, EXT));
}
@Test
void relativeImportsResolveAgainstTheImportersDirectory() {
assertEquals("pur-r-vstamm/src/store/rv-redux-types",
TypeScriptModuleNames.resolveImport(IMPORTER, "../rv-redux-types", WS, EXT));
assertEquals("pur-r-vstamm/src/store/slices/HistorieDrawer",
TypeScriptModuleNames.resolveImport(IMPORTER, "./HistorieDrawer.tsx", WS, EXT));
}
@Test
void baseUrlImportsResolveUnderTheWorkspaceSrc() {
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces",
TypeScriptModuleNames.resolveImport(IMPORTER, "generated/agstamm-interfaces", WS, EXT));
assertEquals("pur-r-vstamm/src/hooks/x",
TypeScriptModuleNames.resolveImport(IMPORTER, "#/hooks/x", WS, EXT));
}
@Test
void workspaceImportsResolveToBarrelOrPath() {
assertEquals("pur-ui-common/src/index", TypeScriptModuleNames.resolveImport(IMPORTER, "pur-ui-common", WS, EXT));
assertEquals("pur-ui-common/src/generated/api-interfaces",
TypeScriptModuleNames.resolveImport(IMPORTER, "pur-ui-common/src/generated/api-interfaces", WS, EXT));
}
@Test
void npmPackagesAreExternal() {
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "@reduxjs/toolkit", WS, EXT));
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "react", WS, EXT));
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "lodash/get", WS, EXT));
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "node:path", WS, EXT));
}
}

View File

@@ -0,0 +1,339 @@
package com.agenticcode.parsertypescript;
import com.agenticcode.parsercore.ast.model.AstEdge;
import com.agenticcode.parsercore.ast.model.AstNode;
import com.agenticcode.parsercore.ast.model.EdgeType;
import com.agenticcode.parsercore.ast.model.NodeType;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tier-2 merge of {@link TypeScriptParser} (item 192): facts override the outline; calls become graph edges shaped like the Java parser's.
*/
class TypeScriptParserTest {
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
private final TypeScriptParser parser = new TypeScriptParser(WS, TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
private static AstNode byId(ParseResult r, UUID id) {
return r.nodes().stream().filter(n -> n.id().equals(id)).findFirst().orElseThrow();
}
private static AstNode one(ParseResult r, NodeType type, String name) {
return r.nodes().stream().filter(n -> n.type() == type && n.name().equals(name) && !n.sourceFile().isEmpty())
.findFirst().orElseThrow(() -> new AssertionError("missing " + type + " " + name));
}
private static String prop(AstEdge e, String key) {
return Objects.requireNonNull(Objects.requireNonNull(e.properties()).get(key), key);
}
private static List<AstEdge> access(ParseResult r, EdgeType type, AstNode from) {
return r.edges().stream().filter(e -> e.type() == type && e.sourceId().equals(from.id())).toList();
}
@Test
void withoutFactsItIsTheOutline() {
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
assertTrue(r.edges().stream().noneMatch(e -> e.type() == EdgeType.CALLS));
assertNull(Objects.requireNonNull(r.nodes().get(0).properties()).get("ingestTier"));
}
@Test
void factsReplaceDeclarationsAndMarkTier2() throws IOException {
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
AstNode module = r.nodes().get(0);
assertEquals("2", Objects.requireNonNull(module.properties()).get("ingestTier"));
assertEquals("63", Objects.requireNonNull(module.properties()).get("loc"), "shell metrics stay");
Set<String> names = r.nodes().stream().filter(n -> n.type() != NodeType.MODULE && n.type() != NodeType.STYLE && !n.sourceFile().isEmpty())
.map(n -> n.type() + ":" + n.name()).collect(Collectors.toSet());
assertEquals(Set.of("DATA_STRUCTURE:AgstammState", "FIELD:AgstammState.requestStatus", "FIELD:AgstammState.agstammUseCaseSvcResult",
"FUNCTION:loadBrokerFromServer", "FUNCTION:saveBrokerToServer", "FUNCTION:loadHistorie",
"FUNCTION:selectAgstamm", "FUNCTION:statusOf",
// item 194: the slice under its store key, its state keys, its reducers by action type
"STORE_SLICE:broker", "FIELD:broker.requestStatus", "FIELD:broker.agstammUseCaseSvcResult",
"FUNCTION:agstamm/updateAgstammUseCaseSvcResult", "FUNCTION:agstamm/resetIfIdle", "FUNCTION:agstamm/loadBroker/fulfilled",
"FUNCTION:agstamm/matcher:(action) => action.type.endsWith('/rejected')"), names, "plain consts are not nodes");
assertEquals("thunk", Objects.requireNonNull(one(r, NodeType.FUNCTION, "loadBrokerFromServer").properties()).get("kind"));
}
@Test
void crossFileCallsAreModuleToPlaceholderEdgesLikeJava() throws IOException {
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
AstNode module = r.nodes().get(0);
List<AstEdge> calls = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id())).toList();
assertEquals(4, calls.size(), "new Endpoint(), searchBroker.get, saveBroker.post, hey-api fn; lib/unresolved calls are no edges");
assertEquals(5, r.edges().stream().filter(e -> e.type() == EdgeType.CALLS).count(), "+ the same-file thunk -> case edge (item 194)");
AstEdge post = calls.stream().filter(e -> "agstammUiApi.saveBroker.post".equals(e.value())).findFirst().orElseThrow();
assertEquals("pur-r-vstamm/src/generated/agstamm-endpoints", byId(r, post.targetId()).name());
assertEquals("METHOD_CALL", prop(post, "callKind"));
assertEquals("saveBrokerToServer", prop(post, "callerFn"));
assertEquals("AgstammControllerEndpoint.saveBroker", prop(post, "calleeMethod"),
"item 193: retargeted from the generic PostMethod.post signature to the endpoint function");
AstEdge hey = calls.stream().filter(e -> "getPurRVstammV1AgstammUiHistorieByVermnr".equals(e.value())).findFirst().orElseThrow();
assertEquals("loadHistorie", prop(hey, "callerFn"));
assertEquals("getPurRVstammV1AgstammUiHistorieByVermnr", prop(hey, "calleeMethod"));
assertEquals("AgstammControllerEndpoint", prop(post, "receiver"));
assertEquals("saveBroker.post", prop(post, "member"));
assertEquals(48, post.lineNo());
AstEdge ctor = calls.stream().filter(e -> "AgstammControllerEndpoint".equals(e.value())).findFirst().orElseThrow();
assertEquals("CONSTRUCTOR", prop(ctor, "callKind"));
assertNull(Objects.requireNonNull(ctor.properties()).get("callerFn"), "module-level const: no calling function");
assertEquals("AgstammControllerEndpoint", prop(ctor, "calleeMethod"));
}
@Test
void sameFileCallsAreFunctionToFunctionAndJsxCountsAsACall() throws IOException {
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
List<AstEdge> fromPage = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(page.id())).toList();
Set<String> targets = fromPage.stream().map(e -> byId(r, e.targetId()).name()).collect(Collectors.toSet());
assertEquals(Set.of("useVermnr", "Panel"), targets, "local top-level callees; the local variable call is no edge");
AstEdge panel = fromPage.stream().filter(e -> byId(r, e.targetId()).name().equals("Panel")).findFirst().orElseThrow();
assertEquals("jsx", prop(panel, "callSyntax"));
AstNode module = r.nodes().get(0);
Set<String> cross = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id()))
.map(e -> byId(r, e.targetId()).name() + "#" + prop(e, "calleeMethod") + "<-" + prop(e, "callerFn")).collect(Collectors.toSet());
assertEquals(Set.of("pur-r-vstamm/src/store/hooks/useAgstamm#useAgstamm<-AgstammPage",
"pur-r-vstamm/src/components/Agstamm/HistorieDrawer#HistorieDrawer<-AgstammPage",
"pur-r-vstamm/src/store/rv-redux-types#useAppSelector<-AgstammPage",
// item 194: a dispatched action creator targets the reducer FUNCTION, not the slice const
"pur-r-vstamm/src/store/slices/agstammSlice#agstamm/resetIfIdle<-AgstammPage"), cross);
AstEdge dispatch = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "agstamm/resetIfIdle".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
assertEquals("agstamm/resetIfIdle", prop(dispatch, "actionType"));
assertEquals("@mui/material,@mui/material/styles,react", Objects.requireNonNull(module.properties()).get("externalImports"),
"no node_modules in the fixture tree: the sidecar resolves none of these, so the Tier-1 rule decides — "
+ "@-scoped and known packages are external, pur-ui-common is a workspace and became a placeholder");
assertTrue(r.nodes().stream().anyMatch(n -> n.sourceFile().isEmpty() && n.name().equals("pur-ui-common/src/index")));
}
@Test
void endpointFunctionsCarryTheJavaHandlerProperties() throws IOException {
ParseResult r = parser.parse(Fixtures.ENDPOINTS, Fixtures.read(Fixtures.ENDPOINTS), TypeScriptFactsReaderTest.facts());
AstNode save = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.saveBroker");
java.util.Map<String, String> p = Objects.requireNonNull(save.properties());
assertEquals("endpoint", p.get("kind"));
assertEquals("true", p.get("outbound"));
assertEquals("POST", p.get("httpMethod"));
assertEquals("/agstamm/ui", p.get("restPath"));
assertEquals("", p.get("restBase"));
assertEquals("/agstamm/ui/", p.get("restUrl"));
assertEquals("pur", p.get("backend"));
assertEquals("AgstammUseCase", p.get("requestType"), "namespace qualifiers stripped");
assertEquals("SvcResult<AgstammUseCase>", p.get("responseType"));
assertEquals("typescript-generator", p.get("generator"));
AstNode search = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.searchBroker");
assertEquals("/agstamm/ui/search", Objects.requireNonNull(search.properties()).get("restPath"));
assertEquals("vermnr", Objects.requireNonNull(search.properties()).get("queryParams"));
assertTrue(r.nodes().stream().anyMatch(n -> n.type() == NodeType.FUNCTION && n.name().equals("AgstammControllerEndpoint")
&& "class".equals(Objects.requireNonNull(n.properties()).get("kind"))), "the class shell stays");
String heyFile = "pur-r-vstamm/src/generated/client/sdk.gen.ts";
ParseResult h = parser.parse(heyFile, Fixtures.read(heyFile), TypeScriptFactsReaderTest.facts());
AstNode fn = one(h, NodeType.FUNCTION, "getPurRVstammV1AgstammUiHistorieByVermnr");
assertEquals(1, h.nodes().stream().filter(n -> n.name().equals(fn.name())).count(), "endpoint node wins over the const declaration");
assertEquals("endpoint", Objects.requireNonNull(fn.properties()).get("kind"));
assertEquals("/pur-r-vstamm/v1", Objects.requireNonNull(fn.properties()).get("restBase"));
assertEquals("/agstamm/ui/historie/{vermnr}", Objects.requireNonNull(fn.properties()).get("restPath"));
assertEquals("hey-api", Objects.requireNonNull(fn.properties()).get("generator"));
}
@Test
void interfaceMembersBecomeFields() throws IOException {
String f = "pur-r-vstamm/src/generated/agstamm-interfaces.ts";
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
AstNode useCase = one(r, NodeType.DATA_STRUCTURE, "AgstammUseCase");
List<AstNode> fields = r.edges().stream()
.filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(useCase.id()))
.map(e -> byId(r, e.targetId())).toList();
assertEquals(List.of("AgstammUseCase.broker:Broker", "AgstammUseCase.brokerName:string"),
fields.stream().map(n -> n.name() + ":" + n.dataType()).toList(), "qualified: one node per interface member, not per name");
assertTrue(fields.stream().allMatch(n -> n.type() == NodeType.FIELD));
assertEquals("broker", Objects.requireNonNull(fields.get(0).properties()).get("field"));
assertEquals("AgstammUseCase", Objects.requireNonNull(fields.get(0).properties()).get("owner"));
}
// ----- item 194: the Redux store -----
@Test
void everyEdgeConnectsTwoEmittedNodes() throws IOException {
TypeScriptFacts facts = TypeScriptFactsReaderTest.facts();
for (String f : facts.byFile().keySet()) {
ParseResult r = parser.parse(f, Fixtures.read(f), facts);
Set<UUID> ids = r.nodes().stream().map(AstNode::id).collect(Collectors.toSet());
for (AstEdge e : r.edges()) {
assertTrue(ids.contains(e.sourceId()) && ids.contains(e.targetId()), "dangling edge in " + f);
}
}
}
@Test
void sliceBecomesStoreSliceWithFieldsAndReducerFunctions() throws IOException {
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
AstNode slice = one(r, NodeType.STORE_SLICE, "broker");
assertEquals("agstamm", Objects.requireNonNull(slice.properties()).get("sliceName"));
assertEquals("broker", Objects.requireNonNull(slice.properties()).get("storeKey"), "named by the key store.ts mounts it under");
assertEquals("true", Objects.requireNonNull(slice.properties()).get("store"));
assertEquals("AgstammState", slice.dataType());
assertEquals(15, slice.startLine());
assertEquals(37, slice.endLine());
AstNode module = r.nodes().get(0);
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(module.id()) && e.targetId().equals(slice.id())));
AstNode status = one(r, NodeType.FIELD, "broker.requestStatus");
AstNode result = one(r, NodeType.FIELD, "broker.agstammUseCaseSvcResult");
assertEquals("RequestStatus", status.dataType());
assertEquals("requestStatus", Objects.requireNonNull(status.properties()).get("field"));
assertEquals("broker", Objects.requireNonNull(status.properties()).get("slice"));
assertEquals("true", Objects.requireNonNull(result.properties()).get("optional"));
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(slice.id()) && e.targetId().equals(status.id())));
AstNode reset = one(r, NodeType.FUNCTION, "agstamm/resetIfIdle");
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("kind"));
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("reducerKind"));
assertEquals("broker", Objects.requireNonNull(reset.properties()).get("slice"));
assertEquals("agstamm/resetIfIdle", Objects.requireNonNull(reset.properties()).get("actionType"));
assertEquals(22, reset.startLine());
assertEquals(26, reset.endLine());
List<AstEdge> reads = access(r, EdgeType.READS, reset);
assertEquals(1, reads.size());
assertEquals(status.id(), reads.get(0).targetId());
assertEquals("requestStatus", prop(reads.get(0), "path"));
assertEquals("reducer", prop(reads.get(0), "via"));
assertEquals(23, reads.get(0).lineNo());
List<AstEdge> writes = access(r, EdgeType.WRITES, reset);
assertEquals(1, writes.size());
assertEquals(result.id(), writes.get(0).targetId());
assertEquals(24, writes.get(0).lineNo());
AstNode fulfilled = one(r, NodeType.FUNCTION, "agstamm/loadBroker/fulfilled");
assertEquals("case", Objects.requireNonNull(fulfilled.properties()).get("reducerKind"));
assertEquals("loadBrokerFromServer.fulfilled", Objects.requireNonNull(fulfilled.properties()).get("trigger"));
assertEquals(2, access(r, EdgeType.WRITES, fulfilled).size());
AstNode thunk = one(r, NodeType.FUNCTION, "loadBrokerFromServer");
AstEdge fires = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(thunk.id()) && e.targetId().equals(fulfilled.id()))
.findFirst().orElseThrow(() -> new AssertionError("the thunk fires its fulfilled case"));
assertEquals("extraReducer", prop(fires, "callSyntax"));
assertEquals("agstamm/loadBroker/fulfilled", prop(fires, "actionType"));
AstNode matcher = one(r, NodeType.FUNCTION, "agstamm/matcher:(action) => action.type.endsWith('/rejected')");
assertEquals("matcher", Objects.requireNonNull(matcher.properties()).get("reducerKind"));
assertNull(Objects.requireNonNull(matcher.properties()).get("actionType"));
}
@Test
void consumersReadStorePlaceholdersByKeyAndField() throws IOException {
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
List<String> reads = access(r, EdgeType.READS, page).stream()
.filter(e -> !"binding".equals(Objects.requireNonNull(e.properties()).get("via"))) // item 195 reads are tested separately
.map(e -> byId(r, e.targetId()).type() + ":" + byId(r, e.targetId()).name() + "[" + prop(e, "path") + "]@" + e.lineNo() + " via " + prop(e, "via"))
.sorted().toList();
assertEquals(List.of("FIELD:broker.agstammUseCaseSvcResult[agstammUseCaseSvcResult]@27 via useAppSelector",
"FIELD:broker.requestStatus[requestStatus]@26 via useAppSelector",
"FIELD:broker.requestStatus[requestStatus]@30 via getState"), reads);
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).findFirst().orElseThrow();
assertEquals("", ph.sourceFile(), "a placeholder: the slice lives in another file");
assertEquals(NodeType.FIELD, ph.type());
assertEquals("true", Objects.requireNonNull(ph.properties()).get("store"), "what the store resolver matches on");
assertEquals(1, r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).count(), "one placeholder per name");
String drawer = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
ParseResult d = parser.parse(drawer, Fixtures.read(drawer), TypeScriptFactsReaderTest.facts());
AstEdge wrapped = access(d, EdgeType.READS, one(d, NodeType.FUNCTION, "HistorieDrawer")).get(0);
assertEquals("broker.agstammUseCaseSvcResult", byId(d, wrapped.targetId()).name());
assertEquals("agstammUseCaseSvcResult.result.purMode", prop(wrapped, "path"), "the wrapper hook's base path plus the selector's own");
assertEquals("useAgstammSelector", prop(wrapped, "via"));
String hook = "pur-r-vstamm/src/store/hooks/useAgstamm.ts";
ParseResult h = parser.parse(hook, Fixtures.read(hook), TypeScriptFactsReaderTest.facts());
assertEquals(1, access(h, EdgeType.READS, one(h, NodeType.FUNCTION, "useAgstammSelector")).size(), "the wrapper's own inner selector is a read too");
AstEdge thunkCall = h.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "loadBrokerFromServer".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
assertEquals("agstamm/loadBroker", prop(thunkCall, "actionType"), "a thunk keeps its FUNCTION as target and carries the type prefix");
}
// ----- item 195: DTO field bindings -----
@Test
void aBoundFieldIsReadAndWrittenThroughAPlaceholderResolvedByOwner() throws IOException {
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("Broker.ebene")).findFirst().orElseThrow();
assertEquals("", ph.sourceFile());
assertEquals(NodeType.FIELD, ph.type());
assertEquals("true", Objects.requireNonNull(ph.properties()).get("binding"));
assertEquals("Broker", Objects.requireNonNull(ph.properties()).get("owner"));
assertEquals("ebene", Objects.requireNonNull(ph.properties()).get("field"));
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces", Objects.requireNonNull(ph.properties()).get("targetModule"));
for (EdgeType type : List.of(EdgeType.READS, EdgeType.WRITES)) {
AstEdge e = r.edges().stream().filter(x -> x.type() == type && x.sourceId().equals(page.id()) && x.targetId().equals(ph.id()))
.findFirst().orElseThrow(() -> new AssertionError("SmartInput binds " + type));
assertEquals("broker.ebene", prop(e, "path"));
assertEquals("AgstammUseCase", prop(e, "rootDto"));
assertEquals("SmartInput", prop(e, "component"));
assertEquals("binding", prop(e, "via"));
assertEquals("false", prop(e, "partial"));
assertEquals(38, e.lineNo());
}
}
// ----- item 196: styling -----
@Test
void themeTokensAreFieldsOfATheme() throws IOException {
String f = "pur-r-vstamm/src/rvTheme.ts";
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
AstNode theme = one(r, NodeType.DATA_STRUCTURE, "theme");
assertEquals("theme", Objects.requireNonNull(theme.properties()).get("kind"));
AstNode dark = one(r, NodeType.FIELD, "theme.palette.primary.dark");
assertEquals("#0054A2", dark.dataType());
assertEquals("true", Objects.requireNonNull(dark.properties()).get("theme"));
assertEquals("palette.primary.dark", Objects.requireNonNull(dark.properties()).get("token"));
assertEquals("path", Objects.requireNonNull(dark.properties()).get("tokenKind"));
assertEquals("PRIMARY_DARK", Objects.requireNonNull(dark.properties()).get("constant"));
assertEquals("constant", Objects.requireNonNull(one(r, NodeType.FIELD, "theme.PRIMARY").properties()).get("tokenKind"));
// item 199: the dark theme in the same file adds palette.mode, shares primary.main/dark (first value wins, variants=2)
assertEquals(10, r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(theme.id())).count());
assertEquals("2", Objects.requireNonNull(dark.properties()).get("variants"));
assertNull(Objects.requireNonNull(one(r, NodeType.FIELD, "theme.palette.mode").properties()).get("variants"));
assertEquals("dark", one(r, NodeType.FIELD, "theme.palette.mode").dataType());
assertTrue(r.nodes().stream().noneMatch(n -> n.sourceFile().isEmpty() && n.name().startsWith("theme.")), "no self-placeholders");
}
@Test
void styleBlocksAreStylesWithTokenReferences() throws IOException {
String f = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
AstNode drawer = one(r, NodeType.FUNCTION, "HistorieDrawer");
AstNode sx = one(r, NodeType.STYLE, "HistorieDrawer.sx@9:14");
assertEquals("sx", Objects.requireNonNull(sx.properties()).get("styleKind"));
assertEquals("Box", Objects.requireNonNull(sx.properties()).get("element"));
assertEquals("color,borderColor,height,&:hover.background,mt", Objects.requireNonNull(sx.properties()).get("properties"));
assertEquals("17px", Objects.requireNonNull(sx.properties()).get("literals"));
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(drawer.id()) && e.targetId().equals(sx.id())));
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(sx.id())).toList();
// the fixture puts color and borderColor on separate lines (IDE reformat), so two edges; the
// item-199 same-line grouping is pinned by StylesIT, whose inline fixture no formatter touches
assertEquals(List.of("theme.PRIMARY[color]", "theme.PRIMARY[borderColor]", "theme.palette.background.paper[&:hover.background]"),
refs.stream().map(e -> byId(r, e.targetId()).name() + "[" + prop(e, "property") + "]").toList());
AstNode ph = byId(r, refs.get(0).targetId());
assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder");
assertEquals("true", Objects.requireNonNull(ph.properties()).get("theme"));
AstEdge prop = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(drawer.id())).findFirst().orElseThrow();
assertEquals("theme.palette.primary.main", byId(r, prop.targetId()).name());
assertEquals("borderColor", prop(prop, "context"));
assertEquals("theme", prop(prop, "via"));
ParseResult page = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
AstNode styled = one(page, NodeType.STYLE, "Panel.styled@14:15");
AstNode panel = one(page, NodeType.FUNCTION, "Panel");
assertTrue(page.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(panel.id()) && e.targetId().equals(styled.id())),
"a styled block hangs under its styled FUNCTION");
assertEquals("padding", Objects.requireNonNull(styled.properties()).get("properties"));
}
}

View File

@@ -0,0 +1,64 @@
package com.agenticcode.parsertypescript;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link TypeScriptProject#scan}: workspaces and dependency names from package.json files (item 192).
*/
class TypeScriptProjectTest {
@Test
void readsWorkspacesAndDependencies(@TempDir Path root) throws IOException {
Files.writeString(root.resolve("package.json"), """
{"name": "fe", "workspaces": ["app-a", "libs/*"],
"dependencies": {"react": "^18", "@mui/material": "^6"}, "devDependencies": {"vite": "6"}}
""");
Files.createDirectories(root.resolve("app-a"));
Files.writeString(root.resolve("app-a/package.json"), "{\"dependencies\": {\"zod\": \"4\", \"lib-x\": \"*\"}}");
Files.createDirectories(root.resolve("libs/lib-x"));
Files.createDirectories(root.resolve("libs/lib-y"));
TypeScriptProject p = TypeScriptProject.scan(root);
assertEquals(Set.of("app-a", "libs/lib-x", "libs/lib-y"), p.workspaces());
assertTrue(p.externalPackages().containsAll(Set.of("react", "@mui/material", "vite", "zod", "lodash")),
"root + workspace deps plus the built-in list");
assertFalse(p.externalPackages().contains("lib-x"), "a dependency that is a workspace is not external");
assertSame(TypeScriptFacts.NONE, p.facts());
}
@Test
void installedPackagesCountAsExternal(@TempDir Path root) throws IOException {
Files.writeString(root.resolve("package.json"), "{\"dependencies\": {\"@reduxjs/toolkit\": \"2\"}}");
Files.createDirectories(root.resolve("node_modules/immer"));
Files.createDirectories(root.resolve("node_modules/redux"));
Files.createDirectories(root.resolve("node_modules/@types/node"));
Files.createDirectories(root.resolve("node_modules/.bin"));
TypeScriptProject p = TypeScriptProject.scan(root);
assertTrue(p.externalPackages().containsAll(Set.of("immer", "redux", "@types/node")), "transitive packages are external");
assertFalse(p.externalPackages().contains(".bin"));
assertNull(TypeScriptModuleNames.resolveImport("app/src/x", "immer", p.workspaces(), p.externalPackages()));
}
@Test
void rootWithoutPackageJsonStillScans(@TempDir Path root) throws IOException {
TypeScriptProject p = TypeScriptProject.scan(root);
assertTrue(p.workspaces().isEmpty());
assertEquals(TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES, p.externalPackages());
}
@Test
void scannerHonoursTheProjectContext() {
TypeScriptCoarseScanner scanner = new TypeScriptCoarseScanner();
TypeScriptProject fe = new TypeScriptProject(Set.of("pur-ui-common"), Set.of("react"), TypeScriptFacts.NONE);
var r = scanner.scan("pur-ui/src/a.ts", "import { x } from 'pur-ui-common'\nimport y from 'react'\n", fe);
assertTrue(r.nodes().stream().anyMatch(n -> n.sourceFile().isEmpty() && n.name().equals("pur-ui-common/src/index")));
assertEquals("react", java.util.Objects.requireNonNull(r.nodes().get(0).properties()).get("externalImports"));
}
}

View File

@@ -0,0 +1,45 @@
package com.agenticcode.parsertypescript;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Item 193: frontend URL templates normalised to the backend's base-less path shape.
*/
class TypeScriptRestPathsTest {
@Test
void legacyTemplateWithQueryString() {
TypeScriptRestPaths p = TypeScriptRestPaths.of("/agstamm/ui/search?vermnr={vermnr}&page={page}");
assertEquals("", p.restBase());
assertEquals("/agstamm/ui/search", p.restPath());
assertEquals(List.of("vermnr", "page"), p.queryParams());
}
@Test
void trailingSlashAndDoubleSlashesAreNormalised() {
assertEquals("/agstamm/ui", TypeScriptRestPaths.of("/agstamm/ui/").restPath());
assertEquals("/agstamm/ui/historie/{vermnr}", TypeScriptRestPaths.of("/agstamm/ui/historie//{vermnr}").restPath());
assertEquals("/", TypeScriptRestPaths.of("/").restPath());
}
@Test
void applicationBaseIsSplitOff() {
TypeScriptRestPaths hey = TypeScriptRestPaths.of("/pur-r-vbuch/v1/account-overview/{agent-code}");
assertEquals("/pur-r-vbuch/v1", hey.restBase());
assertEquals("/account-overview/{agent-code}", hey.restPath());
TypeScriptRestPaths bare = TypeScriptRestPaths.of("/pur-r-vstamm/v1");
assertEquals("/pur-r-vstamm/v1", bare.restBase());
assertEquals("/", bare.restPath());
assertEquals("", TypeScriptRestPaths.of("/v1/x").restBase(), "a base needs a name segment before the version");
}
@Test
void generatedNamespaceQualifiersAreStripped() {
assertEquals("SvcResult<AgstammUseCase>", TypeScriptRestPaths.unqualified("COMMON.SvcResult<API.AgstammUseCase>"));
assertEquals("{ vermnr: number }", TypeScriptRestPaths.unqualified("{ vermnr: number }"));
}
}

View File

@@ -0,0 +1,76 @@
package com.agenticcode.parsertypescript;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Runs the real sidecar when {@code node} and {@code sidecar/node_modules/typescript} are present
* (skipped otherwise), and checks that what it prints for the fixture workspace equals the checked-in
* contract document — the guard that keeps {@code facts-pur-r-vstamm.json} honest.
*/
class TypeScriptSidecarTest {
private static Path nodeBinary() {
String pathEnv = System.getenv("PATH");
if (pathEnv != null) {
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
Path candidate = Path.of(dir, "node");
if (Files.isExecutable(candidate)) {
return candidate;
}
}
}
return Path.of("/usr/bin/node");
}
private static TypeScriptSidecar sidecar() {
return new TypeScriptSidecar(nodeBinary(), Path.of("sidecar/extract.mjs").toAbsolutePath(), 512, Duration.ofMinutes(2));
}
@Test
void sidecarOutputMatchesTheCheckedInContractDocument() throws IOException {
TypeScriptSidecar sidecar = sidecar();
assumeTrue(sidecar.available(), "node + sidecar/node_modules/typescript not installed here");
TypeScriptFacts live = sidecar.extract(Fixtures.ROOT, "pur-r-vstamm", null);
TypeScriptFacts committed = TypeScriptFactsReaderTest.facts();
assertEquals(committed.byFile().keySet(), live.byFile().keySet());
for (String f : committed.byFile().keySet()) {
assertEquals(committed.byFile().get(f), live.byFile().get(f), "facts drifted for " + f
+ " — regenerate facts-pur-r-vstamm.json (see TypeScriptFactsReaderTest)");
}
}
@Test
void fileFilterNarrowsTheDocument() {
TypeScriptSidecar sidecar = sidecar();
assumeTrue(sidecar.available());
TypeScriptFacts f = sidecar.extract(Fixtures.ROOT, "pur-r-vstamm", List.of(Fixtures.PAGE));
assertEquals(java.util.Set.of(Fixtures.PAGE), f.byFile().keySet());
}
@Test
void failureSurfacesStderr() {
TypeScriptSidecar sidecar = sidecar();
assumeTrue(sidecar.available());
TypeScriptSidecar.SidecarException e = assertThrows(TypeScriptSidecar.SidecarException.class,
() -> sidecar.extract(Fixtures.ROOT, "no-such-workspace", null));
assertTrue(String.valueOf(e.getMessage()).contains("no-such-workspace"), String.valueOf(e.getMessage()));
}
@Test
void unavailableWhenTheScriptIsMissing() {
assertFalse(new TypeScriptSidecar(nodeBinary(), Path.of("/nowhere/extract.mjs"), 512, Duration.ofSeconds(1)).available());
try (Stream<Path> s = Stream.empty()) {
assertNotNull(s);
}
}
}

View File

@@ -0,0 +1,44 @@
import React, {useEffect} from 'react'
import {Box, Typography} from '@mui/material'
import {styled} from '@mui/material/styles'
import {SmartInput, theme} from 'pur-ui-common'
import {useAgstamm} from 'store/hooks/useAgstamm'
import {useAppSelector} from 'store/rv-redux-types'
import {store} from 'store/store'
import {resetIfIdle} from 'store/slices/agstammSlice'
import {AgstammUseCaseField} from 'generated/agstamm-interfaces'
import {HistorieDrawer} from './HistorieDrawer'
/* block comment
spanning lines */
const Panel = styled(Box)(({theme}) => ({
padding: theme.spacing(2),
}))
export const useVermnr = (): number => {
const url = 'http://example/agstamm/ui' // not a comment
return Number(url.length)
}
export function AgstammPage() {
const {agstammUseCaseSvcResult, dispatchLoadBrokerFromServer} = useAgstamm()
const vermnr = useVermnr()
const requestStatus = useAppSelector((state) => state.broker.requestStatus)
const {agstammUseCaseSvcResult: fromStore} = useAppSelector((state) => state.broker)
useEffect(() => {
dispatchLoadBrokerFromServer(vermnr)
if (store.getState().broker.requestStatus === 'idle') {
store.dispatch(resetIfIdle())
}
}, [vermnr])
return (
<Panel sx={{mt: 1}}>
{/* JSX comment */}
<Typography style={{color: theme.palette.primary.dark}}>Agstamm</Typography>
<SmartInput field={AgstammUseCaseField.broker.ebene} svcResult={agstammUseCaseSvcResult}/>
<HistorieDrawer/>
</Panel>
)
}
export default React.memo(AgstammPage)

View File

@@ -0,0 +1,19 @@
import {useAgstammSelector} from 'store/hooks/useAgstamm'
import {PRIMARY} from 'rvTheme'
import {Box, useTheme} from '@mui/material'
export function HistorieDrawer() {
const purMode = useAgstammSelector((useCase) => useCase?.result?.purMode)
const theme = useTheme()
return (
<Box sx={{
color: PRIMARY,
borderColor: PRIMARY,
height: '17px',
'&:hover': {background: theme.palette.background.paper},
mt: 2
}} borderColor={theme.palette.primary.main}>
Historie {purMode}
</Box>
)
}

View File

@@ -0,0 +1,37 @@
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
/* eslint-disable */
// @ts-nocheck
import * as COMMON from 'pur-ui-common';
import * as API from './agstamm-interfaces';
interface Endpoint {
baseUrl: string;
}
interface GetMethodWithParameters<R, P> {
get: (params: P) => Promise<R>;
}
interface PostMethod<R, B> {
post: (body: B) => Promise<R>;
}
export class AgstammControllerEndpoint implements Endpoint {
baseUrl: string = '/agstamm/ui/';
public searchBroker: GetMethodWithParameters<COMMON.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
get: (params) => {
return COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${params.vermnr}`));
},
};
public saveBroker: PostMethod<COMMON.SvcResult<API.AgstammUseCase>, API.AgstammUseCase> = {
post: (body) => {
return COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body);
},
};
}
export type AgstammAction = 'ADD' | 'UPDATE';
export enum AgstammKind { A, B }

View File

@@ -0,0 +1,36 @@
/**
* Generated by EndpointGenerator in Pur-Devtools
*/
/* eslint-disable */
// @ts-nocheck
export interface Broker {
vermnr: number;
ebene: string;
}
export interface AgstammUseCase {
broker: Broker;
brokerName: string;
}
export class Fields<TRoot, TSelf> {
$$parent?: Fields<TRoot, any>;
$$name: string = '';
get(): string {
return this.$$parent?.get() ? this.$$parent.get() + '.' + this.$$name : this.$$name;
}
}
export class BrokerFields<TRoot, TSelf> extends Fields<TRoot, TSelf> {
vermnr: Fields<TRoot, number> = new Fields();
ebene: Fields<TRoot, string> = new Fields();
}
export class AgstammUseCaseFields<TRoot, TSelf> extends Fields<TRoot, TSelf> {
broker: BrokerFields<TRoot, Broker> = new BrokerFields();
brokerName: Fields<TRoot, string> = new Fields();
}
export const AgstammUseCaseField: AgstammUseCaseFields<AgstammUseCase, never> = new AgstammUseCaseFields();

View File

@@ -0,0 +1 @@
export const client = {get: <R, E, T>(opts: { url: string }): Promise<R> => Promise.reject(opts.url) as Promise<R>};

View File

@@ -0,0 +1,9 @@
export type TDataShape = { path?: Record<string, unknown> };
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = TData & {
throwOnError?: ThrowOnError
};
export type RequestResult<TData, TError, ThrowOnError extends boolean> = Promise<{
data: TData;
error: TError;
throwOnError: ThrowOnError
}>;

View File

@@ -0,0 +1,22 @@
// This file is auto-generated by @hey-api/openapi-ts
import type {Options as Options2, RequestResult, TDataShape} from './client';
import {client} from './client.gen';
import type {
GetPurRVstammV1AgstammUiHistorieByVermnrData,
GetPurRVstammV1AgstammUiHistorieByVermnrResponses
} from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> =
Options2<TData, ThrowOnError>
& { client?: unknown };
/**
* Historie eines Vermittlers
*/
export const getPurRVstammV1AgstammUiHistorieByVermnr = <ThrowOnError extends boolean = false>(options: Options<GetPurRVstammV1AgstammUiHistorieByVermnrData, ThrowOnError>): RequestResult<GetPurRVstammV1AgstammUiHistorieByVermnrResponses, unknown, ThrowOnError> => {
return (options.client ?? client).get<GetPurRVstammV1AgstammUiHistorieByVermnrResponses, unknown, ThrowOnError>({
url: '/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}',
...options
});
};

View File

@@ -0,0 +1,2 @@
export type GetPurRVstammV1AgstammUiHistorieByVermnrData = { path: { vermnr: number } };
export type GetPurRVstammV1AgstammUiHistorieByVermnrResponses = { 200: { result?: { eintraege: string[] } } };

View File

@@ -0,0 +1,21 @@
import {createTheme} from '@mui/material'
export const PRIMARY = '#005CA9'
const PRIMARY_DARK = '#0054A2'
export const WHITE = '#FFFFFF'
export const rvTheme = createTheme({
palette: {
primary: {main: PRIMARY, dark: PRIMARY_DARK, contrastText: '#FFFFFF'},
background: {paper: WHITE},
},
shape: {borderRadius: 4},
typography: {fontFamily: 'OpenSans', h1: {fontWeight: 300}},
})
export const rvDarkTheme = createTheme({
palette: {
mode: 'dark',
primary: {main: PRIMARY, dark: '#003366'},
},
})

View File

@@ -0,0 +1,19 @@
import {loadBrokerFromServer, selectAgstamm} from 'store/slices/agstammSlice'
import {useAppSelector} from 'store/rv-redux-types'
import type {SvcResult} from 'pur-ui-common'
import type {AgstammUseCase} from 'generated/agstamm-interfaces'
export const useAgstamm = () => {
const agstammUseCaseSvcResult = selectAgstamm({
broker: {
agstammUseCaseSvcResult: undefined,
requestStatus: 'idle'
}
} as any)
const dispatchLoadBrokerFromServer = (vermnr: number) => loadBrokerFromServer(vermnr)
return {agstammUseCaseSvcResult, dispatchLoadBrokerFromServer}
}
export const useAgstammSelector = <R>(selector: (useCase: SvcResult<AgstammUseCase> | undefined) => R): R => {
return useAppSelector((state) => selector(state.broker.agstammUseCaseSvcResult))
}

View File

@@ -0,0 +1,8 @@
import {useSelector} from 'react-redux'
import type {RootState} from 'store/store'
export interface RvRootState {
broker: { agstammUseCaseSvcResult?: unknown; requestStatus: string }
}
export const useAppSelector = useSelector.withTypes<RootState>()

View File

@@ -0,0 +1,63 @@
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
import {AgstammUseCase} from 'generated/agstamm-interfaces'
import {AgstammControllerEndpoint} from 'generated/agstamm-endpoints'
import {createAppAsyncThunk, RequestStatus, SvcResult} from 'pur-ui-common'
import type {RvRootState} from '../rv-redux-types'
import {getPurRVstammV1AgstammUiHistorieByVermnr} from 'generated/client/sdk.gen'
export interface AgstammState {
requestStatus: RequestStatus
agstammUseCaseSvcResult?: SvcResult<AgstammUseCase>
}
const initialState: AgstammState = {requestStatus: 'idle'}
const agstammSlice = createSlice({
name: 'agstamm',
initialState,
reducers: {
updateAgstammUseCaseSvcResult(state, action: PayloadAction<SvcResult<AgstammUseCase> | undefined>) {
state.agstammUseCaseSvcResult = action.payload // a "//" inside a comment is fine
},
resetIfIdle(state) {
if (state.requestStatus === 'idle') {
state.agstammUseCaseSvcResult = undefined
}
},
},
extraReducers: (builder) => {
builder.addCase(loadBrokerFromServer.fulfilled, (state, action) => {
state.requestStatus = 'ok'
state.agstammUseCaseSvcResult = action.payload
})
builder.addMatcher((action) => action.type.endsWith('/rejected'), (state) => {
state.requestStatus = 'error'
})
},
})
const agstammUiApi = new AgstammControllerEndpoint()
export const loadBrokerFromServer = createAppAsyncThunk<SvcResult<AgstammUseCase>, number>(
`agstamm/loadBroker`,
(vermnr) => agstammUiApi.searchBroker.get({vermnr: String(vermnr)}),
)
export const saveBrokerToServer = createAppAsyncThunk<SvcResult<AgstammUseCase>, AgstammUseCase>(
'agstamm/saveBroker',
(useCase: AgstammUseCase) => agstammUiApi.saveBroker.post(useCase),
)
export const selectAgstamm = (state: RvRootState) => state.broker.agstammUseCaseSvcResult
export const loadHistorie = createAppAsyncThunk<unknown, number>(
'agstamm/loadHistorie',
async (vermnr) => (await getPurRVstammV1AgstammUiHistorieByVermnr({path: {vermnr}})).data,
)
function statusOf(state: AgstammState): RequestStatus {
return state.requestStatus
}
export const {updateAgstammUseCaseSvcResult, resetIfIdle} = agstammSlice.actions
export default agstammSlice.reducer

View File

@@ -0,0 +1,10 @@
import {configureStore} from '@reduxjs/toolkit'
import agstammReducer from 'store/slices/agstammSlice'
export const store = configureStore({
reducer: {
broker: agstammReducer,
},
})
export type RootState = ReturnType<typeof store.getState>

View File

@@ -0,0 +1,9 @@
/* fonts */
@font-face {
font-family: 'OpenSans';
src: url('/fonts/OpenSans.woff2') format('woff2'); /* not a comment: */
}
body {
margin: 0;
}

View File

@@ -17,6 +17,12 @@ services:
# generic placeholder resolution exceeded on K3, failing the refresh at step 2 of 38 with
# "allocation of an extra 2.0 MiB would use more than the limit 1.4 GiB". The parse and persist
# phases were unaffected; it is one enrichment transaction that outgrew the cap.
# 2G -> 4G (2026-09-23): a deep ingest of upms2 (6311 Natural files) failed at batch 1601-1800
# with "allocation of an extra 34.2 MiB would use more than the limit 1.4 GiB ...
# dbms.memory.transaction.total.max threshold reached". That limit defaults to 70% of the heap,
# and one 200-file persist transaction now writes ~64k nodes / ~159k relationships (item 160
# positional nodes, copycode copies), which no longer fits in 1.4 GiB. 4G lifts the ceiling to
# 2.8 GiB. The idle-heap concern above still holds: G1PeriodicGCInterval returns it.
NEO4J_server_memory_heap_max__size: "4G"
NEO4J_server_memory_heap_initial__size: "512M"
# 512M -> 1G -> 3G -> 2G. The store has grown to 2.4 GB (1.2 GB of it range indexes), so 1G
@@ -46,6 +52,7 @@ services:
# 6g -> 8g, following the heap: Neo4j's rule of thumb is heap + page cache + ~1G, so 4G + 2G + 1G
# with a gigabyte of reserve. An OOM-kill mid-refresh leaves the graph half-updated, which is far
# worse than a spare GB. Do not raise the heap or the page cache without raising this too.
# raising this too. 6g -> 8g with the heap raise to 4G (same rule: 4G heap + 2G cache + reserve).
mem_limit: 8g
volumes:
- neo4j-data:/data
@@ -58,8 +65,10 @@ services:
ac-code-server:
build:
context: ./ac-code-server
dockerfile: src/main/docker/Dockerfile.jvm
# Repository root, not ./ac-code-server: the image also carries the TypeScript sidecar from
# ac-parser-typescript/sidecar (item 192). The root .dockerignore limits what is sent.
context: .
dockerfile: ac-code-server/src/main/docker/Dockerfile.jvm
container_name: agenticcode-server
ports:
- "8787:8787"
@@ -82,9 +91,12 @@ services:
-Xmx2500m
-XX:G1PeriodicGCInterval=300000
-Djava.util.logging.manager=org.jboss.logmanager.LogManager
# Heap 2G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
# heap cap, i.e. ~1.9 GB non-heap, so the ceiling stays at 4g even though the heap shrank.
mem_limit: 4g
# Heap 2.5G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
# heap cap, i.e. ~1.9 GB non-heap. Item 192 adds the TypeScript sidecar, a Node process the JVM
# runs per npm workspace during a deep pass: measured ~0.75 GB RSS per workspace, capped at
# --max-old-space-size=1024 and run before the JVM's own persist/enrichment peak — so 4g would
# have been tight only if the two peaks coincided; 5g leaves room for that case.
mem_limit: 5g
volumes:
# Mounted at the same absolute host path so project roots registered via
# the API (which store absolute host paths) resolve inside the container too.
@@ -97,6 +109,9 @@ services:
- /home/ingo/deve/K3:/home/ingo/deve/K3:ro
- /home/ingo/deve/uniqa/uniqa-upms-app:/home/ingo/deve/uniqa/uniqa-upms-app:ro
- /home/ingo/deve/uniqa/pur-sources/backend:/home/ingo/deve/uniqa/pur-sources/backend:ro
# Item 192: the pur frontend (project `purfe`, language typescript). Read-only like the others;
# the sidecar runs with noEmit and reads the frontend's own node_modules for library typings.
- /home/ingo/deve/uniqa/pur-sources/frontend:/home/ingo/deve/uniqa/pur-sources/frontend:ro
- /home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:ro
depends_on:
neo4j:

10
pom.xml
View File

@@ -10,7 +10,9 @@
<packaging>pom</packaging>
<name>AgenticCode</name>
<description>Parsing, storing, and agentically querying source code (Natural/Software AG and Java)</description>
<description>Parsing, storing, and agentically querying source code (Natural/Software AG, Java and
TypeScript/React)
</description>
<modules>
<module>ac-mvn-plugins</module>
@@ -19,6 +21,7 @@
<module>ac-parser-natural</module>
<module>ac-parser-java</module>
<module>ac-parser-natjav</module>
<module>ac-parser-typescript</module>
<module>ac-neo4j-store</module>
<module>ac-code-server</module>
<module>ac-cli</module>
@@ -80,6 +83,11 @@
<artifactId>ac-parser-natjav</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-parser-typescript</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.agenticcode</groupId>
<artifactId>ac-neo4j-store</artifactId>

View File

@@ -230,8 +230,44 @@ GET /modules/{name}/functions?includeInherited=&kind={abstract|final|overridable
GET /modules/{name}/functions/{fn}/overrides → one hook's subclass overrides
GET /modules/{name}/functions/overrides → bulk: every abstract method's overrides (adds `method`)
GET /modules/{name}/dispatch-table → [{ guardField, guardValue, assignedField, assignedValue, lineNo }]
GET /rest-endpoints?module= → [{ httpMethod, path, module, handler, outbound }] (outbound=true: a call this project MAKES — a TypeScript generated client, item 193)
GET /counterparts?module=&kind=rest|dto|field&unmatched=
→ [{ kind, name, module, sourceFile, startLine, httpMethod, path, counterpartProject, counterpartName, counterpartModule, counterpartSourceFile, counterpartStartLine }]
GET /store?slice= → [{ slice, sliceName, module, sourceFile, stateType, fields: [{ name, type, optional, reads, writes }], reducers, reads, writes }] (item 194: the Redux store, one row per slice = reducer key)
GET /store/{slice}/accesses?field=&mode=reads|writes&module=
→ [{ mode, slice, field, path, function, functionType, functionKind, module, sourceFile, lineNo, via }] (reducers write, components/hooks read; via = useAppSelector | wrapper hook | getState | reducer)
GET /bindings?dto=&field=&mode=reads|writes&module=&partial=
→ [{ mode, dto, field, path, rootDto, kind, partial, component, attribute, function, functionType, functionKind, module, sourceFile, lineNo, counterpartProject, counterpartModule, counterpartField }] (item 195: DTO field bindings via generated Fields path objects; counterpart* = the backend field)
GET /theme?unused= → [{ token, kind, value, constant, declared, module, lineNo, uses }] (item 196: MUI theme tokens; declared=false = read but declared nowhere; uses = project references only)
GET /theme/{token}/usages → [{ function, functionKind, module, sourceFile, lineNo, styleKind, element, property, context }]
GET /styles?module=&kind=sx|style|styled|css&withLiterals=
→ [{ name, styleKind, element, selector, function, module, sourceFile, lineNo, properties, literals, dynamic, tokens }] (item 196: style inventory; withLiterals=true = blocks that hard-code colours/lengths)
```
`counterparts` (item 193) needs the project setting `counterparts: [<other project>]`: a frontend's
web-service calls are linked to the backend handlers serving them (verb + path shape), its generated
DTOs to the Java classes of the same simple name, their fields by name. `unmatched=true` lists what
has no twin — the planning question. Paged like the search endpoints.
`store` (item 194, TypeScript projects): a slice is named by its reducer key (`state.<slice>`), its
state keys are `FIELD`s named `<slice>.<field>` — so `variables/<slice>.<field>/reads|writes` works
too — and every reducer is a `FUNCTION` of `kind=reducer` named by the action type it handles
(`schluesseltabelle/suche/fulfilled`). Consumer reads through selectors, wrapper hooks and
`getState()` are resolved onto the slice's own fields across files; `path` is the full sub-path read.
`bindings` (item 195, TypeScript projects): `<SmartInput field={AgstammUseCaseField.broker.ebene}>`
is a WRITES (input components) or READS binding of the generated interface field `Broker.ebene`
(`dto` = the declaring interface, `rootDto` = where the path starts, `path` = `broker.ebene`);
`counterpart*` is the Java field it mirrors, so "which page edits Java Broker.ebene" is
`bindings?dto=Broker&field=ebene&mode=writes` on the frontend project. `partial=true` = rooted at a
prop, only the tail of the path is known; `kind=prefix` = a sub-object handed on, not a scalar leaf.
`theme` / `styles` (item 196, TypeScript projects): every `sx`/`style`/`styled` block is a `STYLE`
node under its component with its CSS keys, hard-coded literals and the theme tokens it reads; the
theme's tokens are `FIELD`s `theme.<path>` with values. "Where is token X used" =
`theme/<token>/usages`; "what bypasses the theme" = `styles?withLiterals=true`; `unused=true` on
`theme` means no project reference, not dead (MUI consumes tokens itself).
`kind` is null for Natural subroutines and Java constructors; `kind=` filters by Java modifier
(source-derived). `includeInherited=true` adds ancestor methods, each tagged `declaredIn`.
`viaCopycode=true` ⇒ Natural subroutine from an INCLUDEd copycode, so its lines are in `sourceFile`

View File

@@ -83,11 +83,12 @@ reengineered"* — which it answered for services reengineered months earlier.
Three routes now reach comment text. Pick one before concluding "not present":
| Question | Call |
|---|---|
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
| "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` |
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk |
| Question | Call |
|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
| "Where is this query / SQL / JSON text defined?" (Java) | `GET /search/value?value=…&contains=true` — since item 139 a `static final String` built from text blocks, literals, same-class constants and `+` carries its full text as `value`; a `"…".formatted(...)` constant carries its template (with `%s`) and `valueKind: template`. Anything built from a method call or another class's constant stays unresolved (no value) |
| "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` |
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk |
```
GET /pur/search/value?value=WPARTX0S&contains=true → [] (code only)
@@ -175,6 +176,16 @@ or `/context` when you want an overview rather than an enumeration.
as they stand, and a `limit` that ever acquired a default would reintroduce exactly the silent
truncation item 131 removed.)*
## `callers` / `callees` say when they are cut (item 181)
`callers`, `callees` and `functions/{fn}/callers` now send `X-AC-Total-Count` and `X-AC-Truncated`
like the search endpoints, and their body carries `total` and `truncated` next to `sourceFiles` /
`items` (also for `fields=name`; `ac callers` / `ac callees` print the usual "truncated" warning).
The default page is still 50: `upms/modules/DPARTFN0/callees` answers 50 of 58 with
`X-AC-Truncated: true` — before, the 8 missing callees (among them the module that writes the
partner) looked like an inconsistency with `digest`. **Read `truncated` before concluding "X does not
call Y"**; ask with `limit=1000` or narrow with `scope=external`.
## Truncation is now visible on the search endpoints (item 131)
`search/identifier`, `search/value`, `search/annotation`, `search/references` and
@@ -314,6 +325,17 @@ already ran are committed, so queries keep answering, just from a half-updated g
self-heal (a killed process clears nothing) and does not distinguish "running right now" from "died an
hour ago" — both mean the same thing to a caller. A completed refresh clears it.
## Renaming a project (item 202)
`POST /api/projects/{p}/rename` with `{"newName": "…"}` (CLI `ac project rename <old> <new>`)
moves the project key on every node and override and in other projects' `counterparts` lists, then
the shell; the old name answers `404` afterwards and nothing needs re-ingesting. Refusals:
`400 INVALID_REQUEST` (blank or unchanged), `404`, `409 PROJECT_EXISTS`. The node rewrite is batched;
an interrupted rename is finished by running it again (97 s for the 939k-node `upms`). Use it to
keep a reference graph next to a fresh ingest (`ac project rename upms upms_alt`, then create `upms`
again and compare). A full `DELETE` of a project now also removes its manual overrides; `recreate`
keeps them.
## Is this project's graph any good? (item 126)
`GET /api/projects` and `GET /api/projects/{p}` (CLI `ac project list` / `ac project show <p>`)
@@ -498,7 +520,10 @@ pinned target replaces it. `DELETE .../dynamic-calls/overrides?originFile=&lineN
resets one site (omit both = all), clearing the manual edges and un-hiding the placeholder inline (no
refresh). A target that is not a real `MODULE` is rejected `400 UNKNOWN_TARGET`. **Bug B fix:** the
`callees` items now carry `unresolved` (mirroring what `graph` already exposed), so an unresolved
dynamic target is machine-distinguishable from a resolved one without inspecting `sourceFile`.
dynamic target is machine-distinguishable from a resolved one without inspecting `sourceFile`. Since item 200 the apply
and the reset also rebuild the calling
modules' derived `CALLS_MODULE` edges in the same transaction, so `reaches` and `field-flow` honour
a pinned target without a refresh, like `callees` and `call-tree` already did.
**Dispatch guards: read `guards` — it is the only complete condition (item 72).** A `dispatch-table` row's
`guardField`/`guardValue`/`guardValues` describe the **innermost** `DECIDE` only. Natural nests
@@ -1240,10 +1265,16 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
| `GET /counterparts?module=&kind=&unmatched=` (`ac counterparts`) | **Item 193:** this project's web-service calls / generated DTOs / fields with their twin in the counterpart project; `unmatched=true` = what nothing serves or mirrors yet |
| `GET /store?slice=` (`ac store`) | **Item 194:** the frontend Redux store — one row per slice (reducer key, RTK `sliceName`, `stateType`, the top-level state keys with type/optional/read/write counts, reducer count, total access sites) |
| `GET /store/{slice}/accesses?field=&mode=reads\|writes&module=` (`ac store-accesses`) | **Item 194:** who reads/writes a slice — reducers (`functionKind=reducer`, `via=reducer`) and the components/hooks/thunks selecting from it (`via` = `useAppSelector`, a wrapper hook, `getState`), with the full sub-`path` and line. Store fields also answer `variables/<slice>.<field>/reads\|writes` |
| `GET /bindings?dto=&field=&mode=reads\|writes&module=&partial=` (`ac bindings`) | **Item 195:** which component reads/writes which DTO field through the generated `Fields` path objects (`<SmartInput field={X.broker.ebene}>`), with the field's backend counterpart — `--dto Broker --field ebene --mode writes` = which page edits Java `Broker.ebene`. `data-structures/{dto}/fields` carries `boundReads`/`boundWrites` |
| `GET /theme?unused=` · `GET /theme/{token}/usages` (`ac theme`, `ac theme-usages`) | **Item 196:** the MUI theme's tokens (createTheme leaves + theme constants, value, `uses`; `declared=false` = read by the code but declared by no theme) and where one token is read (style block + CSS `property`, or plain `context`) |
| `GET /styles?module=&kind=sx\|style\|styled\|css&withLiterals=` (`ac styles`) | **Item 196:** the style inventory — every `sx`/`style`/`styled` block and CSS rule with CSS keys, hard-coded `literals` and the theme `tokens` it reads; `withLiterals=true` = what bypasses the theme |
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. **All three see code only by default — an empty result is not evidence that the string is absent.** `search/value` takes **`includeComments=true`** (CLI `--include-comments`, item 141) to search comment blocks as well; those hits come back as `kind: "COMMENT"`, so a comment is never read as code. It is opt-in because a comment hit is different evidence from a literal, and folding it in silently would move every existing completeness count (item 131). `search/identifier` and `search/annotation` never match comments at all — use `includeComments`, `/modules/{name}/comments` or `/search/source` before concluding "not present" (see "Comments: reachable, but never by default" above). `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). **Item 125:** a Java **type declaration** is matched by its **short name** as well as by the fully-qualified identity the graph stores (item 117) — `name=PartnerUpdateLogic` finds `com.example.PartnerUpdateLogic`; before this it answered `[]`, which reads as "no such name". Every match carries `simpleName` and `moduleKind` (`CLASS`/`INTERFACE`/`ENUM`/`RECORD`, `PROGRAM`/`SUBPROGRAM` for Natural), both `null` for non-`MODULE` hits — so "is this name a type or a method?" needs no second call. `contains=true` (CLI `--contains`) switches to a case-insensitive **substring** match, as on `/search/value`; it was previously accepted and silently dropped. It matches the FQN too, so a package fragment also hits — filter with `type=MODULE`/`moduleKind` if that is noise. `contains` without a `name` is `400 MISSING_NAME` (a substring search for nothing is a full node dump). The substring scan is **unindexed**: it is bounded to `offset+limit` rows, so keep a `limit` on large projects. Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type --contains` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
Full endpoint list, request params, and response field details:
`x-docs/agent-api-system-prompt.md`.
@@ -1287,6 +1318,349 @@ What it is **not**: a substitute for `mvn test`. The integration tests pin seman
"the deployed thing is not obviously broken". A green run is not a quality gate. All assertions are
invariants, never fixed counts — counts move with every refresh.
## TypeScript / React projects (item 192)
A project may declare `language: typescript` (`ac project create purfe --root … --language typescript`).
Its `.ts`/`.tsx` files (not `.d.ts`) and plain `.css` files are ingested; `node_modules` and `dist`
are excluded by default. **Only a `typescript` project ingests TypeScript** — a Java project with a
bundled web UI (`ac` has `ac-ui/`) never parses it. Java and Natural files stay language-agnostic.
**Identities are paths.** A TypeScript `MODULE` is named by its root-relative path without the script
extension — `pur-r-vstamm/src/store/slices/agstammSlice` — with `simpleName` = the file stem
(`agstammSlice`), `workspace` = the first path segment, `moduleKind` = `ts`/`tsx`/`css`, and
`generated=true` + `generator` (`typescript-generator` for the Java-side EndpointGenerator output
under `generated/`, `hey-api` for `@hey-api/openapi-ts`). A CSS module keeps its extension
(`pur-ui/src/index.css`). Every `/modules/{name}/…` endpoint accepts either form (item 117), so
`ac context agstammSlice` works — until two workspaces have a file with the same stem, then use the
path.
**Two tiers, like Java/Natural.** Project creation and `refresh` without `--deep` run the **Tier-1
regex outline** in Java: module shell (`sourceHash`, `loc`/`sloc`), one `FUNCTION` per top-level
function / arrow / class (`kind` = `function` | `component` | `hook` | `thunk` | `styled` | `class`,
`exported`), one `DATA_STRUCTURE` per `interface`/`type`/`enum` (`dataType` says which), and a
`REFERENCES` edge per import to the module it resolves to (`value` = the import clause, `specifier`
= as written). npm packages are not placeholders; they are listed on the module as
`externalImports`. **A deep pass** (`refresh --deep`, `ingest` by name) runs the **Node sidecar**
(`ac-parser-typescript/sidecar/extract.mjs`, TypeScript compiler API, one whole-program run per npm
workspace, 3–5 s and ~0.5 GB each on the pur frontend) and replaces the outline with the checker's
view: exact positions, imports resolved against the file system, and **calls**:
- a callee owned by a top-level declaration of the same file → `FUNCTION -CALLS-> FUNCTION`;
- a callee in another module → `MODULE -CALLS-> MODULE` carrying `callKind` (`METHOD_CALL`, or
`CONSTRUCTOR` for `new`), `callerFn` (the calling function, absent at module level), `calleeMethod`
(the owning top-level declaration in the target), `callSyntax` (`call`/`new`/`tagged`/`jsx` — a JSX
element `<HistorieDrawer/>` is a call), and on a member call `receiver` (type of the innermost
object, e.g. `AgstammControllerEndpoint`) and `member` (`saveBroker.post`);
- calls into npm packages and the language library are **not** edges.
These are the exact properties the Java parser writes, so `callers`/`callees`/`call-tree`,
`functions/{fn}/callers` (item 52) and the placeholder rewiring work unchanged. A module that got its
Tier-2 pass carries `ingestTier=2`.
**Sidecar failure is visible, not silent.** If `node`, the script or its `node_modules/typescript`
are missing, or a workspace run fails or times out, the refresh still completes at Tier-1 for those
files and the response lists a failure with the pseudo-path `sidecar` or `sidecar:<workspace>`.
Config: `agenticcode.typescript.node`, `.sidecar-script`, `.max-heap-mb` (1024), `.timeout-seconds`
(600); the image carries node and the sidecar (`Dockerfile.jvm`), dev mode expects
`npm ci` run once in `ac-parser-typescript/sidecar/`. The project root is read-only in the
container; the sidecar reads the project's own `node_modules` for library typings and writes nothing.
**Scope of the pur frontend project.** The registered project covers the `pur-ui` and
`pur-ui-common` workspaces only; `pur-r-vstamm` and `pur-r-vbuch` are excluded via `excludeDirs`,
and the sidecar does not load an excluded workspace. Both workspaces call the `pur` backend through
the legacy generated client (`generated/endpoints.ts`, backend `pur`); the hey-api client shape is
recognised too but is not in scope.
**Resolution notes (verified on `purfe`, 2026-09-22).** An import of a workspace consumed through
its package.json `exports` resolves into its build output (`pur-ui-common/dist/x.d.ts`); the sidecar
maps that to the source twin (`pur-ui-common/src/x.ts`) so the edge lands on a real module. A bare
specifier the checker resolves to neither a file nor a package (`immer`, `redux` — transitive
dependencies the project does not list) is recorded as an external import, not a placeholder.
Transitive packages are known from `root/node_modules` (directory names), so Tier-1 treats them
as external too.
**Stale parsed edges are reaped on a deep refresh (item 198).** Every edge the parser emits is
stamped with the run's `ingestGen` at merge time; after a deep re-parse of a file, the edges from
that file's nodes whose stamp is older than the run's (the fresh parse did not re-emit them) are
deleted before the node sweep, for every language and edge type. An import or call the new parse
names differently (a renamed class, a `dist`→`src` mapping fix) therefore no longer keeps its old
placeholder alive next to the fresh edge, and a placeholder left edgeless falls to the usual
placeholder sweep. Tier-1 (`changedOnly` or non-deep) refreshes do not reap, because a Tier-1 pass
emits fewer edges than a deep one. Edges persisted before the stamp was introduced carry no
generation and are never reaped; the first deep refresh after upgrading stamps them, the next one
reaps — so a project never needs recreating after a parser change any more, two deep refreshes do.
**A module node never lives in a copycode (item 201).** A program whose body is a single `INCLUDE`
used to get a second `MODULE` node named after it with the `.cpy` as `sourceFile` (`ZDTSTBP6` in
`upms`), so `modules` and `search/identifier` listed the name twice. Fixed in the parser; a graph
ingested before the fix keeps the stray node until the project is recreated, or you remove it by hand:
`MATCH (m:MODULE {project: $p}) WHERE m.sourceFile ENDS WITH '.cpy' AND m.ownerModule = '' DETACH DELETE m`
(Natural copycodes are never modules of their own, so the match is exact).
**Known limits of 192** (the later items fill them): no field bindings (195), no styles (196);
the store is item 194 below. A `changedOnly` refresh re-runs the sidecar over the
whole workspace but re-persists only the changed files, so an unchanged file's facts can lag one
refresh (same class of caveat as 46a). The by-name deep ingest resolves dependencies by file stem, so
a dependency whose stem exists in several workspaces (`index`) is reported as a duplicate and skipped
— use `refresh --deep` for the whole frontend.
## Web-service calls and the counterpart link (item 193)
**`rest-endpoints` lists the frontend's calls.** Every member of a generated Endpoint class
(`AgstammControllerEndpoint.saveBroker`) and every hey-api sdk function is a `FUNCTION` of
`kind=endpoint` carrying the same `restPath`/`httpMethod` the Java parser writes for a handler, plus
`outbound=true` — so `GET /projects/purfe/rest-endpoints` answers with `outbound: true` rows
(`handler` = `AgstammControllerEndpoint.saveBroker`, `path` = `/agstamm/ui`). Who calls it:
`modules/{generated module}/callers` names the calling slices/components (module level), and the
member call `api.saveBroker.post(...)` is retargeted from the generic `PostMethod.post` signature to
the endpoint function, so the module-to-module `CALLS` edge carries `calleeMethod =
AgstammControllerEndpoint.saveBroker` and `callerFn = <thunk>`. Since item 203 the synthetic
class-hierarchy edges (`resolvedVia: INHERITANCE`, caller → each implementation of the called
interface/base) exist once per originating call site with its real `lineNo`, `originFile`,
`calleeMethod` and `callerFn` — before, one edge per pair took whichever call line the merge met first.
So `callees` lists every real line for an implementation, and `functions/{impl-method}/callers` also
names callers that go through the interface (`RepoImpl.save` ← `Service.store` via `Repo.save`).
Since item 197
`functions/{fn}/callers` joins these module-to-module edges back to the calling function, so
`purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers`
names the thunk in `generalAgreementSlice`, and on the Java side
`pur/modules/…AgstammLogic/functions/handleMerge/callers` names `AgstammController.mergeBroker`
(a REST controller method itself has no Java callers — it is the HTTP entry point). Extra properties on the node (
`GET /nodes/{id}`): `restUrl` (as composed,
with placeholders and query string), `restBase` (an application base such as `/pur-r-vbuch/v1`
split off so paths compare with the backend's base-less `@Path`), `backend` (`pur`, `pur-r-vstamm`,
`dynamic` — from the URL builder), `queryParams`, `requestType`, `responseType`, `paramsType`,
`generator`, `owner`, `member`. A generated interface's properties are `FIELD`s under its
`DATA_STRUCTURE`, **named `<Interface>.<member>`** (`Broker.ebene`; props `field` = the bare member,
`owner` = the interface — since item 195: a node's identity is type + name + file, and one generated
file declares hundreds of interfaces, so a bare `vid` used to be a single node under six interfaces),
so `GET /data-structures/AgstammUseCase/fields` answers for the frontend too (bare member names;
since item 195 — before, the query filtered `FIELD` out and returned `[]` for an interface), and
`counterparts?kind=field` rows are named `Broker.ebene`.
**`COUNTERPART_OF`: the same thing in another project.** A project setting
`counterparts: ["pur"]` (`ac project create purfe … --counterpart pur`, `ac project update purfe
--counterpart pur`, `GET /projects/purfe` shows it) makes enrichment link, after every refresh of
either side:
| this project | → counterpart | matched on |
|-----------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| outbound endpoint `FUNCTION` | backend handler `FUNCTION` | `httpMethod` + path shape (every `{param}` segment compares as `{}`, class + method `@Path` composed as `rest-endpoints` does); several matches → the handler whose source lives under the frontend's `backend` name |
| `DATA_STRUCTURE` in a `generated=true` module | Java `MODULE` with the same `simpleName` | unique name only; an ambiguous name stays unlinked |
| its `FIELD`s | the class's `FIELD`s | name |
The edges are rebuilt from scratch on each run (never accumulated) and re-run for the frontend when
the **backend** refreshes, because a refreshed handler node is deleted together with the edges
pointing at it. Roadmap item 143 (Natural ↔ Java counterparts) will use the same edge.
`GET /projects/{p}/counterparts?module=&kind=rest|dto|field&unmatched=&countOnly=&limit=&offset=`
(CLI `ac counterparts [--module] [--kind] [--unmatched] [--count-only]`) lists
`{kind, name, module, sourceFile, startLine, httpMethod, path, counterpartProject, counterpartName,
counterpartModule, counterpartSourceFile, counterpartStartLine}`; the `counterpart*` fields are null
for an unlinked row, and **`unmatched=true` is the planning question**: which calls does nothing
serve, which generated DTOs / fields have no backend twin. Paged with `X-AC-Total-Count` /
`X-AC-Truncated` like the search endpoints; unknown `kind` → `400 KIND_UNSUPPORTED`; a project
naming itself as counterpart → `400 COUNTERPART_SELF`.
## The Redux store (item 194)
**What is modelled.** Every `createSlice` / `createAppSlice` in a `typescript` project is a
`STORE_SLICE` node **named by the reducer key it is mounted under** in the project's
`configureStore` (`state.<key>`; `gruppenprovision` for `generalAgreementSlice`, whose RTK name is
`generalAgreement`) — the key is what every selector path starts with, so it is the identity; the
RTK name is kept as `sliceName`. The sidecar traces each `reducer: { key: xReducer }` entry through
`xReducer = xSlice.reducer` (also `export default xSlice.reducer`) back to the slice, across
workspaces; a slice no store mounts is named by its own `sliceName`. Under the slice, one **store
`FIELD` per top-level state key**, named `<key>.<field>` (`schluesseltabelle.sucheStatus`, from the
checker's type of `initialState`, so keys only the state type declares are present too) with
`dataType` = the TS type, `optional`, `store=true`, `slice`, `field`. Every reducer is a **`FUNCTION`
of `kind=reducer`** in the slice's module, named by the **action type it handles**:
`schluesseltabelle/updateX` for a case reducer (`reducerKind=reducer`), `schluesseltabelle/suche/fulfilled`
for `builder.addCase(sucheByServer.fulfilled, …)` (`reducerKind=case`, `trigger` = the expression),
`schluesseltabelle/matcher:isSlicePending(sliceName)` for `addMatcher` (`reducerKind=matcher`). A
thunk whose lifecycle action a case handles `CALLS` that case (`callSyntax=extraReducer`), when both
live in the same file.
**Reads and writes.** Inside a reducer every `state.a.b` chain is a `READS`/`WRITES` edge from the
reducer `FUNCTION` to the store `FIELD` of its first key (`state` alone → the `STORE_SLICE`), carrying
`path` (the full sub-path as written, `keyTableUseCaseSvcResult.result.tableId`), `lineNo`,
`via=reducer`. A write is an assignment target (compound assignments also read), `++`/`--`, `delete`,
a mutating method on the chain (`push`, `splice`, `set`, `delete`, …), `Object.assign(state.x, …)`,
or a `return { … }` (each key written) / `return other` (the whole slice). Outside reducers every
store read is a `READS` edge from the reading function (component, hook, thunk; the module when at
top level) to a **placeholder** `<key>.<field>` that the finalize step `resolve-store-placeholder`
redirects onto the real field by name and then drops — so a read in `KeyTablePage.tsx` lands on the
field declared in `keytableSlice.ts` without either file knowing the other. Recognised read forms:
`useSelector`/`useAppSelector((state) => state.a.b)` (every chain rooted at the arrow's parameter;
`const { x, y } = useAppSelector((s) => s.a)` reads `a.x` and `a.y`), **wrapper hooks** such as
`useSchluesseltabelleSelector((useCase) => useCase?.result?.purMode)` — the sidecar finds the wrapper's
inner `useAppSelector((state) => selector(state.a.b))`, so the read is `a.b.result.purMode` with
`via=useSchluesseltabelleSelector` (the wrapper's own inner read is recorded too) — and
`store.getState().a.b` / `const s = thunkAPI.getState(); s.a.b` chains (`via=getState`; 81 sites on
`pur-ui`). A read of a key the store does not declare stays a placeholder and is listed by
`search/identifier` with an empty `sourceFile`.
**Dispatch.** A call to a slice action creator (`dispatch(updateX(…))`, a binding of
`xSlice.actions`) carries `actionType = <sliceName>/updateX` and is a cross-module `CALLS` edge to the
slice module with `calleeMethod = <sliceName>/updateX` — the reducer `FUNCTION` — so module
`callees` of a component list the slices it dispatches into; a thunk call keeps the thunk `FUNCTION`
as target and carries the thunk's type prefix as `actionType`. (Function-level exposure of these
cross-module edges is item 197.)
**Endpoints.** `GET /projects/{p}/store?slice=` (CLI `ac store [--slice]`) → `[{slice, sliceName,
module, sourceFile, startLine, endLine, stateType, fields: [{name, type, optional, reads, writes}],
reducers, reads, writes}]`, all slices or one by reducer key / RTK name.
`GET /projects/{p}/store/{slice}/accesses?field=&mode=reads|writes&module=&countOnly=&limit=&offset=`
(CLI `ac store-accesses <slice> [--field] [--mode] [--module] [--count-only]`) → `[{mode, slice,
field, path, function, functionType, functionKind, module, sourceFile, lineNo, via}]`, one row per
access site, `field` null for a whole-slice access; paged like `counterparts`; unknown `mode` →
`400 MODE_UNSUPPORTED`, unknown slice → `404 SLICE_NOT_FOUND`. Because store fields are `FIELD`
nodes with a unique name, the generic `GET /variables/<key>.<field>/reads|writes` and
`search/identifier?name=<key>.<field>` answer too.
**Limits.** Reads through `getState()` aliases are followed only inside the file that created the
alias; a thunk→case `CALLS` edge is emitted only when thunk and slice share a file (the pur slices
do); a case whose trigger cannot be folded to an action type (a predicate matcher) is named by its
expression text; the sidecar reads the store of every workspace in scope — a key mounted only by a
workspace outside the project (`excludeDirs`) falls back to the slice's own name (matches on `pur`).
No `USES_TYPE` from a store field to the DTO it holds yet — the field's `dataType` says
`SvcResult<KeyTableUseCase>`, the link is item 195. Only a **top-level** `createSlice` is a slice: a
slice built inside a factory function (`pur-ui-common`'s `filetransferSlice`, created per instance
and not mounted in the `pur-ui` store) is not modelled.
**Verified on `purfe` (2026-09-22, server 318, recreated + `refresh --deep`, 285 files, 0
failures, 0 placeholders left):** 9 slices — `error`, `global`, `healthTables`, `metadata`
(pur-ui-common) and `gruppenprovisionSuche`, `gruppenprovision` (RTK name `generalAgreement`),
`schluesseltabelle`, `multilinguism`, `translationdata` (pur-ui) — 43 reducer functions, 216 read
and 82 write sites. `schluesseltabelle` alone: 62 sites, 25 through `useSchluesseltabelleSelector`,
19 through `getState()`, 5 through `useAppSelector`, 13 in reducers.
## DTO field bindings (item 195)
**What a binding is.** The generator emits, next to every DTO interface, a `Fields` class tree
(`AgstammUseCaseField = new AgstammUseCaseFields<AgstammUseCase, never>()`, members
`broker = new BrokerFields<TRoot, Broker>(this, "broker")`, list members as `keyTableList = (index?) =>
new KeyTableDOFields(...)`). A path expression on it — `AgstammUseCaseField.broker.ebene` on a
`<SmartInput field={…}>`, `<SmartOutput field={…}>`, a table's `fieldTermForRowData`, a column's
`field:`, or a `Fields`-typed prop such as `useCaseFieldPrefix` — is a binding. The sidecar types
every hop through the checker (`XFields<TRoot, TSelf>`): the **root DTO** is `TRoot`, the **owner**
of the leaf is the `TSelf` of the hop before it, the leaf name is the field. Each binding becomes a
`READS` edge (plus a `WRITES` edge when the component tag matches `Input$|Dropzone$|Editor$`) from
the binding function (component/hook; the module at top level) to the **`FIELD` of the generated
interface** that item 193 already creates (`Broker` → `ebene`), carrying `path` (dotted hops from the
root, `brokerList[]` for a list hop), `rootDto`, `kind`, `partial`, `component`, `attribute`,
`via=binding`, `lineNo`.
- `kind=field`: the leaf is a scalar (`ebene`); `kind=prefix`: a whole sub-object is handed on
(`useCaseFieldPrefix={X.tab.translationData}`, `fieldTermForRowData={X.keyTableList()}`) —
recorded as a read of the container field so nothing is silently dropped.
- `partial=true`: the expression is rooted at a prop or local (`props.useCaseFieldPrefix.dataName`,
`tabPrefix.x(idx).gausVal`), so the leaf and its owner are exact but the prefix of the path is
unknown (only the tail is given). A carrier prop's own name is not part of the path.
Cross-file targets are placeholders `<Dto>.<field>` (`binding=true`, `owner`, `field`,
`targetModule`) resolved by the finalize step `resolve-binding-placeholder` **exactly** — module →
`DATA_STRUCTURE` → `FIELD` — and dropped afterwards; a leaf the interface does not declare stays a
placeholder (listed by `search/identifier` with empty `sourceFile`). The item-74 stale-edge sweep
covers binding targets on a deep re-ingest.
**Endpoints.** `GET /projects/{p}/bindings?dto=&field=&mode=reads|writes&module=&partial=&countOnly=&limit=&offset=`
(CLI `ac bindings [--dto] [--field] [--mode] [--module] [--partial] [--count-only]`) → one row per
binding site: `{mode, dto, field, path, rootDto, kind, partial, component, attribute, function,
functionType, functionKind, module, sourceFile, lineNo, counterpartProject, counterpartModule,
counterpartField}`. The `counterpart*` columns are the field's `COUNTERPART_OF` twin (item 193), so
**"which page edits Java `Broker.ebene`"** is `ac bindings --dto Broker --field ebene --mode writes
-p purfe` and needs no query on the backend project. `dto` is the *declaring* interface (`Broker`),
not the root (`AgstammUseCase`) — filter on `rootDto` client-side when you need the latter. Paged like
`counterparts`; unknown `mode` → `400 MODE_UNSUPPORTED`. `GET /data-structures/{dto}/fields` now
returns TypeScript interface fields (`type=FIELD`) and carries `boundReads`/`boundWrites` per field
(0 for Natural/Java). The generic `variables/<Interface>.<field>/reads|writes` sees binding edges
too. The leaf's owner is the interface that *declares* the member — `datStart` bound through
`GeneralAgreementDO` lands on `AbstractHistorizedDO.datStart`.
**Limits.** String-form `field="…"` bindings and the lodash-path bindings of `pur-r-vbuch` (excluded
workspace) are not modelled; a `partial` binding cannot say which list element or tab; no
`USES_TYPE` from the component to the root DTO (the `rootDto` edge property answers that). A
binding placeholder and a store placeholder share the `FIELD` type, so a DTO named exactly like a
store key would merge their placeholders (`Dto.field` vs `key.field`) — not the case on `pur`.
**Verified on `purfe` (2026-09-22, server 322, recreated + `refresh --deep`, 285 files, 0
failures):** 257 binding sites (196 reads, 61 writes; 226 scalar leaves, 31 prefixes; 84 partial)
over 23 declaring DTOs, every one linked to its `pur` counterpart field; `SmartInput` 122,
`SmartOutput` 64, tables 32, column definitions 36. `counterparts?kind=field`: 744 fields, exactly
one twin each (six-fold fan-out before the qualified names), 112 unmatched.
## Styling: theme tokens and the style inventory (item 196)
**The theme.** The file with `createTheme({...})` (`pur-ui-common/src/theme.ts`) gets a
`DATA_STRUCTURE theme` (`kind=theme`) with one `FIELD` per token: `theme.<path>` for every leaf of the
literal (`palette.primary.dark`, `typography.h1.fontWeight`, `shape.borderRadius`, `sizes.*`; props
`token`, `tokenKind=path`, `value` folded through constants — `#0054A2` — and `constant` when the
leaf names one, `PRIMARY_DARK`) and `theme.<NAME>` for each exported string/number constant of that
file (`tokenKind=constant`, `PRIMARY`). MUI `components.styleOverrides` are recorded as tokens too, not
interpreted.
**Style blocks.** Every `sx={…}`, `style={…}` and `styled(X)(…)` block is a `STYLE` node under its
component `FUNCTION` (a `styled` block under the `kind=styled` function), named
`<function>.<sx|style|styled>@<line>:<col>`, with `styleKind`, `element` (the JSX tag or styled base:
`Box`, `'div'`), `properties` (the CSS keys, nested selectors flattened: `&:hover.color`,
`& .MuiPaper-root.background`), `literals` (hard-coded colours/lengths: `#005CA9`, `17px`, `-2%`,
`calc(100% - 16px)` — `mt: 2` is theme-relative and no literal), `dynamic` (a value the sidecar could
not classify, or a whole `sx={props.sx}`), `spread`. Plain `.css` files get one `STYLE` per rule from
the Tier-1 scanner (`body@7`, `@font-face@2`; `styleKind=css`, `selector`, `properties`, `literals`).
**Token reads.** Inside a style block every chain on a theme value is a `REFERENCES` edge `STYLE →
theme.<token>` with `property` (the CSS key it feeds) and `via=theme`; a theme value is anything typed
`Theme` (`useTheme()`, a `({ theme }) =>` styled parameter, the theme object imported under any name)
or named `theme`. `theme.spacing(2)` ends at `spacing`, `theme.palette.grey['200']` is
`palette.grey.200`; a theme constant (`color: PRIMARY`) references `theme.PRIMARY`. A token read outside
a style block (`borderColor={theme.palette.grey['200']}`, code) is the same edge from the enclosing
`FUNCTION` with `context` = the JSX attribute or `code`. Cross-file targets are placeholders
`theme.<token>` (`theme=true`) resolved by exact name at finalize when exactly one theme declares the
token; **a token no theme declares keeps its placeholder on purpose** — `GET /theme` lists it with
`declared=false` (MUI defaults such as `palette.grey.200`, `palette.common.white`, the `spacing`
function, or a typo).
**Endpoints.** `GET /projects/{p}/theme?unused=` (CLI `ac theme [--unused]`) → `[{token, kind, value,
constant, declared, module, lineNo, uses}]`, declared tokens first. `uses` counts **project**
references (style blocks and code); MUI's own consumption of a token is invisible, so `unused=true`
means "no project code references it", never "safe to delete" (`palette.primary.main` colours every
Button whether or not a component names it). `GET /projects/{p}/theme/{token}/usages` (`ac
theme-usages palette.primary.dark`, token = dotted path or constant name) → `[{function,
functionKind, module, sourceFile, lineNo, styleKind, element, property, context}]`; unknown token →
`404 TOKEN_NOT_FOUND`.
`GET /projects/{p}/styles?module=&kind=sx|style|styled|css&withLiterals=&countOnly=&limit=&offset=`
(`ac styles [--module] [--kind] [--with-literals]`) → `[{name, styleKind, element, selector,
function, module, sourceFile, lineNo, properties, literals, dynamic, tokens}]`, paged like
`counterparts`; **`withLiterals=true` is the review question**: which blocks hard-code colours and
lengths instead of using the theme. Unknown `kind` → `400 KIND_UNSUPPORTED`.
**Several themes (item 199).** Every `createTheme({...})` in a file is read; a token two themes of
one file declare is one row with the first theme's value and `variants: 2`. A token declared by two
theme *files* (light/dark) is one row per file, and a read of it resolves onto both, so each row
counts the use and `?unused=true` stays honest; `theme/{token}/usages` and `styles[].tokens`
report such a read once. Two reads of one token on one line of a style block (`color: PRIMARY,
borderColor: PRIMARY`) are one usage whose `property` is the comma list of the keys they feed.
CSS rules: a block-less `@import`/`@charset` line is not part of the next selector, braces inside
string values do not open blocks, a nested rule head inside an at-rule body is not a declaration,
and a selector repeated on one line (minified CSS) gets a `:col` suffix in its name.
**Limits.** `className` strings are not matched to CSS rules; Emotion `css` templates and MUI
`styleOverrides` are not modelled; a token read from a component-level function (not a style
block) that survives a re-parse keeps its resolved edge until the file's nodes are re-created
(same class as item 198).
**Verified on `purfe` (2026-09-22, server 326, recreated + `refresh --deep`, 285 files, 0
failures):** 129 declared tokens (111 paths, 18 constants), 93 of them with no project reference;
12 undeclared tokens the code reads (`palette.common.white` 4, `palette.grey.200` 4, `spacing` 3,
`palette.divider`, `palette.text.secondary`, `applyStyles`, `transitions.create`, …);
`palette.primary.dark` is the most-used token (26 reads: 17 `style`, 3 `sx`, 1 `styled`, 5 as a
plain prop such as `confirmColor`). Style inventory: 304 blocks (196 `sx`, 78 `style`, 27 `styled`,
3 CSS rules), 109 with hard-coded literals (`100%` 29, `1px` 21, `12px` 14, `17px` 14, …), 75
reading theme tokens, 41 dynamic. The only placeholders left in the project are the 12 undeclared
theme tokens — by design.
## Missing capability?
If the API/CLI genuinely cannot answer a question (not just

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