Typescript

This commit is contained in:
Ingo Schnabel
2026-09-22 17:01:35 +02:00
parent 7043c8ab0b
commit 10971915e9
90 changed files with 11238 additions and 357 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
@@ -390,15 +394,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

@@ -59,13 +59,17 @@ final class ProjectCommand implements Callable<Integer> {
@SuppressWarnings("NullAway.Init")
@Option(names = {"-l", "--language"}, required = true,
description = "Project source language (natural/java)")
description = "Project source language (natural/java/typescript)")
String language;
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
String userExitDir = "";
@@ -75,12 +79,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 +102,17 @@ final class ProjectCommand implements Callable<Integer> {
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
List<String> excludeDirs = List.of();
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java)")
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/typescript)")
String language = "";
@Option(names = {"-g", "--generated-dir"},
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
String generatedDir = "";
@Option(names = {"-c", "--counterpart"},
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
List<String> counterparts = List.of();
@Option(names = {"-u", "--user-exit-dir"},
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
String userExitDir = "";
@@ -116,7 +125,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)));
}
}

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=294
version=329

View File

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

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

@@ -827,6 +827,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)))

View File

@@ -61,7 +61,7 @@ public class ProjectResource {
.build();
}
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java");
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "typescript");
private static @Nullable String normalize(@Nullable String value) {
return value == null || value.isBlank() ? null : value.strip();
@@ -136,8 +136,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 +191,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",
@@ -229,14 +241,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

@@ -15,6 +15,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;
@@ -39,6 +44,12 @@ public class AstIngestService {
private final NaturalCoarseScanner naturalScanner = new NaturalCoarseScanner();
private final LineCounter javaLineCounter = new JavaLineCounter();
private final LineCounter naturalLineCounter = new NaturalLineCounter();
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
private final LineCounter cssLineCounter = new CssLineCounter();
/**
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
@@ -110,10 +121,21 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
if (language == SourceFiles.Language.JAVA) {
return javaParser.parse(sourceFile, content);
}
return naturalParser.parse(sourceFile, content, copycodes);
return parse(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
}
/**
* Parses {@code content} with the parser for {@code language}; {@code copycodes} serves Natural
* (item 46a), {@code typescript} serves TypeScript/CSS (item 192). The switch is exhaustive on
* purpose: a new {@link SourceFiles.Language} must be routed here, not fall through to a default.
*/
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaParser.parse(sourceFile, content);
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
};
}
/**
@@ -131,10 +153,16 @@ public class AstIngestService {
*/
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes) {
if (language == SourceFiles.Language.JAVA) {
return javaScanner.scan(sourceFile, content);
}
return naturalScanner.scan(sourceFile, content, copycodes);
return coarseScan(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
}
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
CopycodeResolver copycodes, TypeScriptProject typescript) {
return switch (language) {
case JAVA -> javaScanner.scan(sourceFile, content);
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
};
}
/**
@@ -142,7 +170,12 @@ public class AstIngestService {
* the Tier-1 coarse scanners use, so a module's metrics are identical at any ingest depth.
*/
public LocMetrics count(SourceFiles.Language language, String content) {
LineCounter counter = language == SourceFiles.Language.JAVA ? javaLineCounter : naturalLineCounter;
LineCounter counter = switch (language) {
case JAVA -> javaLineCounter;
case NATURAL -> naturalLineCounter;
case TYPESCRIPT -> typeScriptLineCounter;
case CSS -> cssLineCounter;
};
return counter.count(content);
}

View File

@@ -6,6 +6,7 @@ import com.agenticcode.neo4jstore.graph.ProjectInfo;
import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
import com.agenticcode.parsercore.ast.model.*;
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
import com.agenticcode.parsertypescript.TypeScriptProject;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
@@ -52,10 +53,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,
@@ -64,6 +68,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);
@@ -203,13 +208,13 @@ 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) 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)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ts)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
return new Parsed(candidate, result);
@@ -235,9 +240,12 @@ public class ProjectIngestService {
* Build-output directories always skipped when scanning a root (J6: their generated sources
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
*/
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
private static List<Candidate> walk(Path root, List<String> excludeDirs) throws IOException {
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
throws IOException {
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
for (String dir : DEFAULT_EXCLUDE_DIRS) {
if (!effectiveExcludes.contains(dir)) {
@@ -254,7 +262,7 @@ public class ProjectIngestService {
continue;
}
SourceFiles.Kind kind = SourceFiles.classify(file);
if (kind != null) {
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
candidates.add(new Candidate(file, kind));
}
}
@@ -493,12 +501,17 @@ public class ProjectIngestService {
markIngestStarted(project, coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT));
Path root = Path.of(project.root());
List<String> excludeDirs = ingestExcludeDirs(project);
List<Candidate> allCandidates = walk(root, excludeDirs);
List<Candidate> allCandidates = walk(root, excludeDirs, project.language());
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, excludeDirs);
// Item 129: opt-in skip of files whose content is byte-identical to what the graph holds.
List<Candidate> candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates)
: allCandidates;
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
// Tier-1, so the refresh completes and the response says what is missing.
List<IngestSummary.Failure> failed = new ArrayList<>();
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
List<String> examinedFiles = candidates.stream()
.map(c -> relativeSourceFile(root, c.file()))
@@ -509,11 +522,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)))
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project)))
.toList();
for (int i = 0; i < futures.size(); i++) {
try {
@@ -630,8 +642,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 (!"java".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());
@@ -753,7 +766,7 @@ public class ProjectIngestService {
int depthLimit = Math.min(Math.max(1, maxDepth != null ? maxDepth : defaultDeepDepth), maxDeepDepth);
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
Path root = Path.of(project.root());
List<Candidate> candidates = walk(root, ingestExcludeDirs(project));
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
// Reverse index (Java): interface/base simple name -> files that implement/extend it, so a
// targeted ingest also pulls in the implementations/subclasses of any interface it reaches.
@@ -787,7 +800,7 @@ public class ProjectIngestService {
@Nullable Integer maxNodes) throws IOException {
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
Path root = Path.of(project.root());
List<Candidate> candidates = walk(root, ingestExcludeDirs(project));
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
Map<String, Candidate> byRelPath = new HashMap<>();
@@ -867,6 +880,10 @@ public class ProjectIngestService {
long startedAt = System.nanoTime();
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
List<IngestSummary.Failure> failed = new ArrayList<>();
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
// workspace, run once for this call).
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
int ingested = 0;
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
@@ -874,7 +891,6 @@ public class ProjectIngestService {
Set<String> inferredCallTargets = new HashSet<>();
Set<String> staticCallTargets = new HashSet<>();
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
List<String> examinedFiles = new ArrayList<>();
Set<String> ingestedModuleNames = new HashSet<>();
@@ -923,7 +939,7 @@ public class ProjectIngestService {
String sourceFile = relativeSourceFile(root, candidate.file());
examinedFiles.add(sourceFile);
ParseResult result = withUserExitMetrics(withShellMetrics(
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
content, candidate.kind().language()), project.generatedDir(), userExit);
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);
// Full deep parse of this module — reconcile so a by-name refresh purges its stale

View File

@@ -18,8 +18,15 @@ import java.util.Locale;
*
* <p>Java classes carry {@link NodeType#MODULE}; Natural {@code .nat}/{@code .nsn} programs are
* {@code MODULE}s and {@code .lda}/{@code .pda}/{@code .gda} data areas (local/parameter/global) are
* {@link NodeType#DATA_STRUCTURE}s. Files with any other extension are not ingestible and classify
* to {@code null}.
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
* ingestible and classify to {@code null}.
*
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
* project of a given declared language picks a file up. Java and Natural files are ingested by every
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
*/
final class SourceFiles {
@@ -41,9 +48,27 @@ final class SourceFiles {
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
}
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
}
if (name.endsWith(".css")) {
return new Kind(Language.CSS, NodeType.MODULE);
}
return null;
}
/**
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
* — see the class comment.
*/
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
return switch (language) {
case JAVA, NATURAL -> true;
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
};
}
/**
* @return the uppercased filename stem (name without extension), used as the case-insensitive
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
@@ -84,7 +109,10 @@ final class SourceFiles {
return false;
}
enum Language {JAVA, NATURAL}
/**
* The parser a file goes to. {@code CSS} shares the TypeScript parser but has its own line counter.
*/
enum Language {JAVA, NATURAL, TYPESCRIPT, CSS}
/**
* Classification of an ingestible source file.

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=294
agenticcode.version=329
# 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,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

@@ -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,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,194 @@
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, '&: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());
get("/theme/PRIMARY/usages").then().statusCode(200)
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color")).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,&: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
@@ -38,6 +40,32 @@ class SourceFilesTest {
}
}
@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

@@ -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
""";
@@ -1459,19 +1479,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
""";
@@ -1526,12 +1566,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}/
@@ -2702,7 +2745,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
@@ -2716,12 +2760,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,
@@ -2750,6 +2796,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,
@@ -2847,17 +2894,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
@@ -4008,6 +4064,383 @@ 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
""";
/**
* 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 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);
@@ -4026,6 +4459,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

@@ -103,192 +103,11 @@ public class GraphRepository {
// -------------------------------------------------------------------------
// Call graph
// -------------------------------------------------------------------------
/**
* The ordered enrichment statements: placeholder resolution → bare-include redirection →
* placeholder cleanup → dataflow → polymorphic fan-out. The order matters (e.g. dataflow and
* fan-out must see resolved {@code CALLS} edges), but the steps need not be atomic together —
* {@link #finalizeProject} runs each in its own transaction, and each is idempotent.
*
* <p>When {@code scoped} is {@code true} (a per-program deep ingest, see
* {@link #finalizeProjectScoped}) the field-resolution and dataflow steps use the
* {@code $names}-scoped query variants, while call-graph resolution, placeholder cleanup, and
* CHA fan-out remain project-wide (cheap, idempotent). The step list is otherwise identical, so
* a scoped run produces the same graph as the project-wide FULL run restricted to {@code $names}.
*/
private static List<EnrichmentStep> enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) {
String sfx = scoped ? "-scoped" : "";
List<EnrichmentStep> statements = new ArrayList<>();
// Call-graph / module-level placeholder resolution (cheap) — always run, including for a
// call-graph-only ingest. Resolves CALLS (callers/callees), INCLUDES, USES_TYPE,
// EXTENDS/IMPLEMENTS. Project-wide in both modes (no scoped variant). Note: when field
// resolution is skipped we deliberately do NOT delete placeholders, so a later (scoped) deep
// ingest can still resolve the field placeholders left behind.
for (EdgeType type : CypherQueries.RESOLVABLE_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-placeholder " + type, CypherQueries.resolvePlaceholderTargets(type)));
}
// 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,
// idempotent (plain SET).
// Item 117: attach references the parser could not qualify (no import proved the type) to the
// module they mean. Runs FIRST: every later step joins on module edges or on a module's
// sourceFile — the Java DB resolvers, the inheritance materialization, the cross-class
// argument→parameter wiring and the CHA fan-out. Placed late, it left those joins reading a
// placeholder that was about to disappear, which silently emptied cross-class dataflow.
statements.add(new EnrichmentStep("resolve-simple-name-references",
CypherQueries.RESOLVE_SIMPLE_NAME_REFERENCES));
statements.add(new EnrichmentStep("resolve-panache-inherited-entity", CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY));
// J1: resolve Java JPA/Panache DB_ACCESS candidates to the entity's DB_TABLE (READS/WRITES for
// db-accesses, USES_TYPE for sql-statements). Depends only on persisted MAPS_TO + node
// properties, so it runs in every mode; project-wide and idempotent (all MERGE).
statements.add(new EnrichmentStep("resolve-java-db-access", CypherQueries.RESOLVE_JAVA_DB_ACCESS));
// J1b: resolve @Query JPQL/native-SQL DB_ACCESS candidates the same way. JPQL needs MAPS_TO
// (entity -> table); native SQL matches a literal DB_TABLE name directly, no dependency.
statements.add(new EnrichmentStep("resolve-java-query-jpql", CypherQueries.RESOLVE_JAVA_QUERY_JPQL));
statements.add(new EnrichmentStep("resolve-java-query-native-sql", CypherQueries.RESOLVE_JAVA_QUERY_NATIVE_SQL));
// Item 140: a project with no DB_TABLE cannot resolve a single Java DB_ACCESS candidate, so
// everything the over-approximating parse-time heuristic emitted there is a false positive.
// Reap it (Java only — a Natural READ/FIND is a real access even with an unresolved view).
// Must follow all three resolvers above.
statements.add(new EnrichmentStep("reap-java-db-access-without-tables",
CypherQueries.REAP_JAVA_DB_ACCESS_WITHOUT_TABLES));
// Reap self-EXTENDS/IMPLEMENTS edges (a class cannot extend/implement itself) before any step
// traverses the inheritance graph — clears stale name-collision edges a non-wiping refresh leaves.
statements.add(new EnrichmentStep("delete-self-inheritance-edges", CypherQueries.DELETE_SELF_INHERITANCE_EDGES));
// J3: materialize interface -> implementation edges (needs IMPLEMENTS pointing at real nodes,
// i.e. after the placeholder loop above). Project-wide, idempotent.
statements.add(new EnrichmentStep("build-implemented-by", CypherQueries.BUILD_IMPLEMENTED_BY));
// J4: materialize base-method -> subclass-override edges (needs EXTENDS resolved). Idempotent.
statements.add(new EnrichmentStep("build-overridden-by", CypherQueries.BUILD_OVERRIDDEN_BY));
// Reap prior synthetic INHERITANCE edges so a refresh (which does not wipe the graph) rebuilds
// them fresh with current properties/gates — otherwise a stale phantom or a missing originFile
// survives ON CREATE. Must precede every *_TO_SUBCLASSES / *_TO_IMPLEMENTATIONS materializer.
statements.add(new EnrichmentStep("delete-synthetic-inheritance-edges", CypherQueries.DELETE_SYNTHETIC_INHERITANCE_EDGES));
// Item 31: materialize ancestor-declared REFERENCES/INJECTS wiring onto concrete subclasses
// (needs EXTENDS/REFERENCES/INJECTS resolved). Project-wide, idempotent. Each inherited edge
// carries originFile=base file so callees sites attribute the base-class lineNo to the base file.
statements.add(new EnrichmentStep("link-references-to-subclasses", CypherQueries.LINK_REFERENCES_TO_SUBCLASSES));
statements.add(new EnrichmentStep("link-injects-to-subclasses", CypherQueries.LINK_INJECTS_TO_SUBCLASSES));
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA));
// Item 83: constant-fold string-assembled dynamic CALLNAT targets (base literal + SUBSTR
// overlays → folded module name). Cheap (bounded by dynamic call sites), all modes. Skips sites
// with a manual override so item-82 overrides still win. Runs right after the direct-literal
// resolver and before apply-manual/placeholder cleanup.
statements.add(new EnrichmentStep("resolve-dynamic-callnat-fold" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD));
if (resolveFields) {
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT));
// FIELD-level resolution (expensive on a whole codebase — ~minutes per step on a large
// project, dominated by the WRITES pass). Done globally only for a deep whole-root ingest;
// otherwise deferred to a scoped per-program deep ingest (scoped=true).
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-field-placeholder" + sfx + " " + type,
scoped ? CypherQueries.resolvePlaceholderFieldTargetsScoped(type) : CypherQueries.resolvePlaceholderFieldTargets(type)));
}
// Fallback by global name for the remainder whose module has no matching INCLUDES edge
// (GLOBAL USING / copycode). Runs after the INCLUDES-scoped pass deleted what it
// resolved, so it only sees the small leftover set.
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-field-placeholder-by-name" + sfx + " " + type,
scoped ? CypherQueries.resolvePlaceholderFieldTargetsByNameScoped(type) : CypherQueries.resolvePlaceholderFieldTargetsByName(type)));
}
statements.add(new EnrichmentStep("delete-resolved-field-contains", CypherQueries.DELETE_RESOLVED_PLACEHOLDER_FIELD_CONTAINS));
// Item 18: redirect bare (unqualified) references to an included field onto the matching
// real field, when uniquely resolvable through the module's includes.
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-bare-included" + sfx + " " + type,
scoped ? CypherQueries.resolveBareIncludedFieldTargetsScoped(type) : CypherQueries.resolveBareIncludedFieldTargets(type)));
}
statements.add(new EnrichmentStep("delete-bare-placeholder-contains", CypherQueries.DELETE_RESOLVED_BARE_PLACEHOLDER_CONTAINS));
// Only safe once field placeholders are resolved — otherwise it would reap placeholder
// structures a later scoped deep ingest still needs to resolve their fields.
statements.add(new EnrichmentStep("delete-resolved-placeholders", CypherQueries.DELETE_RESOLVED_PLACEHOLDERS));
}
if (dataflow) {
// Dataflow: map CALLNAT arguments to callee parameters by position. Expensive on a whole
// codebase (on `upms`, link-args-to-params ran ~25 min), so — like field resolution — it
// is NOT in the fast whole-root pass; it runs only for a `deep=true` whole-root ingest or,
// scoped, for a per-program deep ingest (which fans out top-down from the analyzed
// program). Cross-module dynamic CALLNAT dispatch builds on the ARG_TO_PARAM edges below,
// so it is likewise deferred to those paths.
statements.add(new EnrichmentStep("link-args-to-params" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS));
statements.add(new EnrichmentStep("link-args-to-params-java" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA));
// J5: cross-class (MODULE->MODULE) Java arg->param dataflow, so flow-forward/backward span classes.
statements.add(new EnrichmentStep("link-args-to-params-java-cross" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS));
// Cross-module dynamic CALLNAT resolution: follows ARG_TO_PARAM (built just above).
statements.add(new EnrichmentStep("resolve-dynamic-callnat-cross" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS));
}
// Polymorphic call resolution: fan class-level CALLS edges that target an interface/base
// type out to its implementations/subclasses (CHA). Runs last so it sees resolved
// CALLS + IMPLEMENTS/EXTENDS edges and doesn't perturb dataflow. Project-wide in both modes.
// Item 116b: resolve calls on inherited fields before the polymorphic fan-out, so an edge
// recovered here is itself eligible for CHA expansion — a repository interface reached through
// an inherited field should fan out to its implementations like any other.
statements.add(new EnrichmentStep("resolve-inherited-field-receivers",
CypherQueries.RESOLVE_INHERITED_FIELD_RECEIVERS));
statements.add(new EnrichmentStep("delete-unresolved-field-receivers",
CypherQueries.DELETE_UNRESOLVED_FIELD_RECEIVERS));
statements.add(new EnrichmentStep("link-calls-to-implementations", CypherQueries.LINK_CALLS_TO_IMPLEMENTATIONS));
// Item 82: apply human/agent-set manual overrides for dynamic CALLNAT sites the auto-resolvers
// couldn't reach. Runs after the auto dynamic-CALLNAT resolvers and before the placeholder
// cleanup below, so a manually-resolved marker is flagged manualHidden (kept for inline reset)
// rather than deleted. Project-wide in both modes; idempotent (MERGE); a no-op when there are
// no overrides. The :DynamicCallOverride nodes it reads survived the refresh (not :AstNode).
// Item 83: a manual override wins over an auto-fold — drop a stale folded edge at an overridden
// site so apply-manual (next) can pin the human/agent target. Runs right before apply-manual.
statements.add(new EnrichmentStep("delete-folded-overridden-dynamic-callnat", CypherQueries.DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT));
statements.add(new EnrichmentStep("apply-manual-dynamic-callnat", CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT));
// Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx,
scoped ? CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES_SCOPED : CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES));
// Item 62: reap placeholder MODULEs that are really data literals (browse keys) mis-recovered as
// call targets by dynamic-CALLNAT constant propagation or include-macro accessor extraction.
// Runs after the marker cleanup above (a marker that resolved is already gone) and before the
// stamping below (so the flags reflect the reaped graph). Project-wide in both modes: the test
// is name-based and globally true, and it only removes edges that are false everywhere.
statements.add(new EnrichmentStep("delete-data-literal-call-placeholders",
CypherQueries.DELETE_DATA_LITERAL_CALL_PLACEHOLDERS));
// Item 88: reap DB_TABLE/WORKFILE placeholders left edgeless by item-86 edge reaping (a former
// phantom `WORK`/`NUMBER` whose access edges are gone) — the node sweep never touches
// sourceFile="" placeholders, so they would otherwise linger in the DB-table inventory forever.
// Item 98: redirect DML that names a view alias declared in a `USING` data area onto the real
// table. Must run before the orphan reaper below, which then removes the alias DB_TABLE left
// edgeless by the redirect. Project-wide: the join is scoped by each module's own USING set, so
// it is correct regardless of which modules were re-ingested.
statements.add(new EnrichmentStep("resolve-view-alias-tables READS",
CypherQueries.resolveViewAliasTables(EdgeType.READS)));
statements.add(new EnrichmentStep("resolve-view-alias-tables WRITES",
CypherQueries.resolveViewAliasTables(EdgeType.WRITES)));
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
// Item 68: project the finished call graph onto module->module CALLS_MODULE edges, so a
// traversal can be bounded in module hops instead of raw CALLS hops. Must come after every step
// that adds CALLS edges (placeholder + dynamic-CALLNAT resolution, link-calls-to-implementations)
// or removes them (the item-62 data-literal reaping above) — it is a projection, so it is only
// as correct as the graph at the moment it runs.
// The DELETE is what makes it correct across refreshes, not just idempotent: MERGE re-creates
// what still exists but never removes what no longer should (the item-58 sweep deletes stale
// *nodes* only, so a surviving module's edges are never reaped).
statements.add(new EnrichmentStep("delete-calls-module" + sfx,
scoped ? CypherQueries.DELETE_CALLS_MODULE_SCOPED : CypherQueries.DELETE_CALLS_MODULE));
statements.add(new EnrichmentStep("build-calls-module" + sfx,
scoped ? CypherQueries.BUILD_CALLS_MODULE_SCOPED : CypherQueries.BUILD_CALLS_MODULE));
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.
@@ -901,19 +720,206 @@ 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());
/**
* The ordered enrichment statements: placeholder resolution → bare-include redirection →
* placeholder cleanup → dataflow → polymorphic fan-out. The order matters (e.g. dataflow and
* fan-out must see resolved {@code CALLS} edges), but the steps need not be atomic together —
* {@link #finalizeProject} runs each in its own transaction, and each is idempotent.
*
* <p>When {@code scoped} is {@code true} (a per-program deep ingest, see
* {@link #finalizeProjectScoped}) the field-resolution and dataflow steps use the
* {@code $names}-scoped query variants, while call-graph resolution, placeholder cleanup, and
* CHA fan-out remain project-wide (cheap, idempotent). The step list is otherwise identical, so
* a scoped run produces the same graph as the project-wide FULL run restricted to {@code $names}.
*/
private static List<EnrichmentStep> enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) {
String sfx = scoped ? "-scoped" : "";
List<EnrichmentStep> statements = new ArrayList<>();
// Call-graph / module-level placeholder resolution (cheap) — always run, including for a
// call-graph-only ingest. Resolves CALLS (callers/callees), INCLUDES, USES_TYPE,
// EXTENDS/IMPLEMENTS. Project-wide in both modes (no scoped variant). Note: when field
// resolution is skipped we deliberately do NOT delete placeholders, so a later (scoped) deep
// ingest can still resolve the field placeholders left behind.
for (EdgeType type : CypherQueries.RESOLVABLE_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-placeholder " + type, CypherQueries.resolvePlaceholderTargets(type)));
}
// 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,
// idempotent (plain SET).
// Item 117: attach references the parser could not qualify (no import proved the type) to the
// module they mean. Runs FIRST: every later step joins on module edges or on a module's
// sourceFile — the Java DB resolvers, the inheritance materialization, the cross-class
// argument→parameter wiring and the CHA fan-out. Placed late, it left those joins reading a
// placeholder that was about to disappear, which silently emptied cross-class dataflow.
statements.add(new EnrichmentStep("resolve-simple-name-references",
CypherQueries.RESOLVE_SIMPLE_NAME_REFERENCES));
statements.add(new EnrichmentStep("resolve-panache-inherited-entity", CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY));
// J1: resolve Java JPA/Panache DB_ACCESS candidates to the entity's DB_TABLE (READS/WRITES for
// db-accesses, USES_TYPE for sql-statements). Depends only on persisted MAPS_TO + node
// properties, so it runs in every mode; project-wide and idempotent (all MERGE).
statements.add(new EnrichmentStep("resolve-java-db-access", CypherQueries.RESOLVE_JAVA_DB_ACCESS));
// J1b: resolve @Query JPQL/native-SQL DB_ACCESS candidates the same way. JPQL needs MAPS_TO
// (entity -> table); native SQL matches a literal DB_TABLE name directly, no dependency.
statements.add(new EnrichmentStep("resolve-java-query-jpql", CypherQueries.RESOLVE_JAVA_QUERY_JPQL));
statements.add(new EnrichmentStep("resolve-java-query-native-sql", CypherQueries.RESOLVE_JAVA_QUERY_NATIVE_SQL));
// Item 140: a project with no DB_TABLE cannot resolve a single Java DB_ACCESS candidate, so
// everything the over-approximating parse-time heuristic emitted there is a false positive.
// Reap it (Java only — a Natural READ/FIND is a real access even with an unresolved view).
// Must follow all three resolvers above.
statements.add(new EnrichmentStep("reap-java-db-access-without-tables",
CypherQueries.REAP_JAVA_DB_ACCESS_WITHOUT_TABLES));
// Reap self-EXTENDS/IMPLEMENTS edges (a class cannot extend/implement itself) before any step
// traverses the inheritance graph — clears stale name-collision edges a non-wiping refresh leaves.
statements.add(new EnrichmentStep("delete-self-inheritance-edges", CypherQueries.DELETE_SELF_INHERITANCE_EDGES));
// J3: materialize interface -> implementation edges (needs IMPLEMENTS pointing at real nodes,
// i.e. after the placeholder loop above). Project-wide, idempotent.
statements.add(new EnrichmentStep("build-implemented-by", CypherQueries.BUILD_IMPLEMENTED_BY));
// J4: materialize base-method -> subclass-override edges (needs EXTENDS resolved). Idempotent.
statements.add(new EnrichmentStep("build-overridden-by", CypherQueries.BUILD_OVERRIDDEN_BY));
// Reap prior synthetic INHERITANCE edges so a refresh (which does not wipe the graph) rebuilds
// them fresh with current properties/gates — otherwise a stale phantom or a missing originFile
// survives ON CREATE. Must precede every *_TO_SUBCLASSES / *_TO_IMPLEMENTATIONS materializer.
statements.add(new EnrichmentStep("delete-synthetic-inheritance-edges", CypherQueries.DELETE_SYNTHETIC_INHERITANCE_EDGES));
// Item 31: materialize ancestor-declared REFERENCES/INJECTS wiring onto concrete subclasses
// (needs EXTENDS/REFERENCES/INJECTS resolved). Project-wide, idempotent. Each inherited edge
// carries originFile=base file so callees sites attribute the base-class lineNo to the base file.
statements.add(new EnrichmentStep("link-references-to-subclasses", CypherQueries.LINK_REFERENCES_TO_SUBCLASSES));
statements.add(new EnrichmentStep("link-injects-to-subclasses", CypherQueries.LINK_INJECTS_TO_SUBCLASSES));
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA));
// Item 83: constant-fold string-assembled dynamic CALLNAT targets (base literal + SUBSTR
// overlays → folded module name). Cheap (bounded by dynamic call sites), all modes. Skips sites
// with a manual override so item-82 overrides still win. Runs right after the direct-literal
// resolver and before apply-manual/placeholder cleanup.
statements.add(new EnrichmentStep("resolve-dynamic-callnat-fold" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD));
if (resolveFields) {
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT));
// FIELD-level resolution (expensive on a whole codebase — ~minutes per step on a large
// project, dominated by the WRITES pass). Done globally only for a deep whole-root ingest;
// otherwise deferred to a scoped per-program deep ingest (scoped=true).
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-field-placeholder" + sfx + " " + type,
scoped ? CypherQueries.resolvePlaceholderFieldTargetsScoped(type) : CypherQueries.resolvePlaceholderFieldTargets(type)));
}
// Fallback by global name for the remainder whose module has no matching INCLUDES edge
// (GLOBAL USING / copycode). Runs after the INCLUDES-scoped pass deleted what it
// resolved, so it only sees the small leftover set.
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-field-placeholder-by-name" + sfx + " " + type,
scoped ? CypherQueries.resolvePlaceholderFieldTargetsByNameScoped(type) : CypherQueries.resolvePlaceholderFieldTargetsByName(type)));
}
statements.add(new EnrichmentStep("delete-resolved-field-contains", CypherQueries.DELETE_RESOLVED_PLACEHOLDER_FIELD_CONTAINS));
// Item 18: redirect bare (unqualified) references to an included field onto the matching
// real field, when uniquely resolvable through the module's includes.
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
statements.add(new EnrichmentStep("resolve-bare-included" + sfx + " " + type,
scoped ? CypherQueries.resolveBareIncludedFieldTargetsScoped(type) : CypherQueries.resolveBareIncludedFieldTargets(type)));
}
statements.add(new EnrichmentStep("delete-bare-placeholder-contains", CypherQueries.DELETE_RESOLVED_BARE_PLACEHOLDER_CONTAINS));
// Only safe once field placeholders are resolved — otherwise it would reap placeholder
// structures a later scoped deep ingest still needs to resolve their fields.
statements.add(new EnrichmentStep("delete-resolved-placeholders", CypherQueries.DELETE_RESOLVED_PLACEHOLDERS));
}
if (dataflow) {
// Dataflow: map CALLNAT arguments to callee parameters by position. Expensive on a whole
// codebase (on `upms`, link-args-to-params ran ~25 min), so — like field resolution — it
// is NOT in the fast whole-root pass; it runs only for a `deep=true` whole-root ingest or,
// scoped, for a per-program deep ingest (which fans out top-down from the analyzed
// program). Cross-module dynamic CALLNAT dispatch builds on the ARG_TO_PARAM edges below,
// so it is likewise deferred to those paths.
statements.add(new EnrichmentStep("link-args-to-params" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS));
statements.add(new EnrichmentStep("link-args-to-params-java" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA));
// J5: cross-class (MODULE->MODULE) Java arg->param dataflow, so flow-forward/backward span classes.
statements.add(new EnrichmentStep("link-args-to-params-java-cross" + sfx,
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS));
// Cross-module dynamic CALLNAT resolution: follows ARG_TO_PARAM (built just above).
statements.add(new EnrichmentStep("resolve-dynamic-callnat-cross" + sfx,
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS));
}
// Polymorphic call resolution: fan class-level CALLS edges that target an interface/base
// type out to its implementations/subclasses (CHA). Runs last so it sees resolved
// CALLS + IMPLEMENTS/EXTENDS edges and doesn't perturb dataflow. Project-wide in both modes.
// Item 116b: resolve calls on inherited fields before the polymorphic fan-out, so an edge
// recovered here is itself eligible for CHA expansion — a repository interface reached through
// an inherited field should fan out to its implementations like any other.
statements.add(new EnrichmentStep("resolve-inherited-field-receivers",
CypherQueries.RESOLVE_INHERITED_FIELD_RECEIVERS));
statements.add(new EnrichmentStep("delete-unresolved-field-receivers",
CypherQueries.DELETE_UNRESOLVED_FIELD_RECEIVERS));
statements.add(new EnrichmentStep("link-calls-to-implementations", CypherQueries.LINK_CALLS_TO_IMPLEMENTATIONS));
// Item 82: apply human/agent-set manual overrides for dynamic CALLNAT sites the auto-resolvers
// couldn't reach. Runs after the auto dynamic-CALLNAT resolvers and before the placeholder
// cleanup below, so a manually-resolved marker is flagged manualHidden (kept for inline reset)
// rather than deleted. Project-wide in both modes; idempotent (MERGE); a no-op when there are
// no overrides. The :DynamicCallOverride nodes it reads survived the refresh (not :AstNode).
// Item 83: a manual override wins over an auto-fold — drop a stale folded edge at an overridden
// site so apply-manual (next) can pin the human/agent target. Runs right before apply-manual.
statements.add(new EnrichmentStep("delete-folded-overridden-dynamic-callnat", CypherQueries.DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT));
statements.add(new EnrichmentStep("apply-manual-dynamic-callnat", CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT));
// Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx,
scoped ? CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES_SCOPED : CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES));
// Item 62: reap placeholder MODULEs that are really data literals (browse keys) mis-recovered as
// call targets by dynamic-CALLNAT constant propagation or include-macro accessor extraction.
// Runs after the marker cleanup above (a marker that resolved is already gone) and before the
// stamping below (so the flags reflect the reaped graph). Project-wide in both modes: the test
// is name-based and globally true, and it only removes edges that are false everywhere.
statements.add(new EnrichmentStep("delete-data-literal-call-placeholders",
CypherQueries.DELETE_DATA_LITERAL_CALL_PLACEHOLDERS));
// Item 88: reap DB_TABLE/WORKFILE placeholders left edgeless by item-86 edge reaping (a former
// phantom `WORK`/`NUMBER` whose access edges are gone) — the node sweep never touches
// sourceFile="" placeholders, so they would otherwise linger in the DB-table inventory forever.
// Item 98: redirect DML that names a view alias declared in a `USING` data area onto the real
// table. Must run before the orphan reaper below, which then removes the alias DB_TABLE left
// edgeless by the redirect. Project-wide: the join is scoped by each module's own USING set, so
// it is correct regardless of which modules were re-ingested.
statements.add(new EnrichmentStep("resolve-view-alias-tables READS",
CypherQueries.resolveViewAliasTables(EdgeType.READS)));
statements.add(new EnrichmentStep("resolve-view-alias-tables WRITES",
CypherQueries.resolveViewAliasTables(EdgeType.WRITES)));
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
// Item 68: project the finished call graph onto module->module CALLS_MODULE edges, so a
// traversal can be bounded in module hops instead of raw CALLS hops. Must come after every step
// that adds CALLS edges (placeholder + dynamic-CALLNAT resolution, link-calls-to-implementations)
// or removes them (the item-62 data-literal reaping above) — it is a projection, so it is only
// as correct as the graph at the moment it runs.
// The DELETE is what makes it correct across refreshes, not just idempotent: MERGE re-creates
// what still exists but never removes what no longer should (the item-58 sweep deletes stale
// *nodes* only, so a surviving module's edges are never reaped).
statements.add(new EnrichmentStep("delete-calls-module" + sfx,
scoped ? CypherQueries.DELETE_CALLS_MODULE_SCOPED : CypherQueries.DELETE_CALLS_MODULE));
statements.add(new EnrichmentStep("build-calls-module" + sfx,
scoped ? CypherQueries.BUILD_CALLS_MODULE_SCOPED : CypherQueries.BUILD_CALLS_MODULE));
return statements;
}
public Uni<List<DataStructureField>> dbTableColumns(String project, String name) {
@@ -1404,20 +1410,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);
}
/**
@@ -1838,15 +1845,22 @@ public class GraphRepository {
}
/**
* Deep-enrich only the field references of {@code moduleNames} (a just-ingested program tree),
* via the scoped field-resolution queries. Lets a deep ingest stay fast even in a project that
* already holds the whole call graph — instead of re-resolving all ~100k placeholder edges.
* Call-graph resolution, placeholder cleanup, and CHA fan-out still run project-wide (cheap and
* idempotent).
* 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).
*/
public Uni<Void> finalizeProjectScoped(String project, List<String> moduleNames) {
Map<String, Object> params = Map.of("project", project, "names", moduleNames);
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep");
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);
}
/**
@@ -1858,13 +1872,189 @@ public class GraphRepository {
return finalizeProject(project, level, false);
}
/**
* Deep-enrich only the field references of {@code moduleNames} (a just-ingested program tree),
* via the scoped field-resolution queries. Lets a deep ingest stay fast even in a project that
* already holds the whole call graph — instead of re-resolving all ~100k placeholder edges.
* Call-graph resolution, placeholder cleanup, and CHA fan-out still run project-wide (cheap and
* idempotent).
*/
public Uni<Void> finalizeProjectScoped(String project, List<String> moduleNames) {
Map<String, Object> params = Map.of("project", project, "names", moduleNames);
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep")
.chain(() -> linkCounterparts(project));
}
/**
* @param profile diagnostic profiling of the slow steps — see
* {@link #runEnrichment(String, List, Map, String, boolean)}.
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
Map.of("project", project), level.name().toLowerCase(java.util.Locale.ROOT), profile);
Map.of("project", project), 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)));
}
/**
@@ -2170,6 +2360,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 -> {
@@ -2184,6 +2384,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;
});
@@ -2201,6 +2402,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 -> {
@@ -2215,6 +2426,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;
});
@@ -2751,6 +2963,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.

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

@@ -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,338 @@
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();
// item 199: PRIMARY feeds color and borderColor on one line -> one edge, both keys
assertEquals(List.of("theme.PRIMARY[color,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

@@ -49,8 +49,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"
@@ -73,9 +75,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.
@@ -84,6 +89,9 @@ services:
- /home/ingo/deve/agenticCode:/home/ingo/deve/agenticCode: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>
@@ -18,6 +20,7 @@
<module>ac-parser-core</module>
<module>ac-parser-natural</module>
<module>ac-parser-java</module>
<module>ac-parser-typescript</module>
<module>ac-neo4j-store</module>
<module>ac-code-server</module>
<module>ac-cli</module>
@@ -74,6 +77,11 @@
<artifactId>ac-parser-java</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

@@ -994,32 +994,38 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
## Endpoint quick reference
| Endpoint | Use for |
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
| `GET /modules/{name}/comments?kind=&limit=&offset=` (`ac comments`) | **Item 141:** the module's **comment blocks** — `{text, kind, sourceFile, startLine, endLine, target, targetType, truncated}`, one row per contiguous block, ordered by line. `target`/`targetType` name the declaration the block documents: the declaration immediately below it, else the one enclosing it (so a file header banner documents the `MODULE`, a `/*` comment on a field's own line documents that field). `kind` is `JAVADOC`\|`LINE`\|`BLOCK` (Java) or `NATURAL_BANNER`\|`NATURAL_INLINE`\|`SAG` (Natural); `?kind=` filters to one. **`SAG` is excluded by default** — `**SAG` directives are generator metadata, not human notes, and would otherwise be most of the answer for every generated Natural module. Text is cut at 4 000 chars (`truncated:true`); read the file for the rest. Natural copycode comments belong to the **copycode's own module**, not to each includer. **Deep-gated:** comments come from the full parse, not the Tier-1 coarse scan, so the module is deep-ingested on demand and a still-shallow module answers `409 NOT_DEEPLY_INGESTED` rather than a misleading `[]` |
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
| `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 |
| Endpoint | Use for |
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (`typescript`/`css` too, item 192) (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java, TypeScript — same-module **and** cross-module, item 197) a specific subroutine/method, with call-site `lineNos`. Cross-module callers come from the module-to-module `CALLS` edge's `callerFn`/`calleeMethod`, matched by name (overloads over-approximate; a call from top-level code with no enclosing function shows only in the module-level `/callers`). Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
| `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 /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
| `GET /modules/{name}/comments?kind=&limit=&offset=` (`ac comments`) | **Item 141:** the module's **comment blocks** — `{text, kind, sourceFile, startLine, endLine, target, targetType, truncated}`, one row per contiguous block, ordered by line. `target`/`targetType` name the declaration the block documents: the declaration immediately below it, else the one enclosing it (so a file header banner documents the `MODULE`, a `/*` comment on a field's own line documents that field). `kind` is `JAVADOC`\|`LINE`\|`BLOCK` (Java) or `NATURAL_BANNER`\|`NATURAL_INLINE`\|`SAG` (Natural); `?kind=` filters to one. **`SAG` is excluded by default** — `**SAG` directives are generator metadata, not human notes, and would otherwise be most of the answer for every generated Natural module. Text is cut at 4 000 chars (`truncated:true`); read the file for the rest. Natural copycode comments belong to the **copycode's own module**, not to each includer. **Deep-gated:** comments come from the full parse, not the Tier-1 coarse scan, so the module is deep-ingested on demand and a still-shallow module answers `409 NOT_DEEPLY_INGESTED` rather than a misleading `[]` |
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
| `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 /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`.
@@ -1063,6 +1069,336 @@ 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.
**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 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

View File

@@ -20,6 +20,11 @@ store cost ~935k of 1.6M dbHits (58%) in a profiled `upms` traversal, because th
an 11-property chain, whereas a label lives in the node record. Query sites move to `:LABEL` one at
a time under measurement (roadmap 111b); the property is dropped only once none read it (111c).
**Generation stamps.** Nodes and, since item 198, every parser-emitted edge carry `ingestGen`,
the persist run that last wrote them (a re-emitted edge is re-stamped through its MERGE key). A
deep refresh deletes a re-parsed file's edges whose stamp is older than the run's; edges the
finalize builds (resolved counterparts, links) carry no stamp unless copied from a parser edge.
Writing your own Cypher: prefer `MATCH (n:MODULE {project: $p})` over
`MATCH (n:AstNode {type: 'MODULE', project: $p})` — both are correct today, the first is cheaper.
@@ -70,6 +75,78 @@ graph LR
`DB_ACCESS -USES_TYPE-> DB_TABLE` (for `sql-statements`) — the same shape Natural uses.
- Java emits no `DATA_STRUCTURE` edges.
## TypeScript / React — `TypeScriptParser` (item 192)
```mermaid
graph LR
M[MODULE<br/>.ts / .tsx file — name = root-relative path without extension]
C[MODULE<br/>.css file — name keeps .css]
F[FUNCTION<br/>function / component / hook / thunk / styled / class]
D[DATA_STRUCTURE<br/>interface / type / enum]
M -->|CONTAINS|F
M -->|CONTAINS|D
M -->|" REFERENCES (import; value = clause, specifier) "|M
F -->|" CALLS — same file, callSyntax=call|new|tagged|jsx "|F
M -->|" CALLS — cross-module, callKind=METHOD_CALL|CONSTRUCTOR,<br/>callerFn, calleeMethod, receiver, member "|M
```
- Module properties: `simpleName`, `workspace`, `moduleKind` (`ts`/`tsx`/`css`), `generated`,
`generator`, `externalImports` (npm packages, comma list — never placeholders), `sourceHash`,
`loc`/`sloc`, `ingestTier=2` after a sidecar pass.
- `FUNCTION.kind` and `exported`; `DATA_STRUCTURE.dataType` = `interface`/`type`/`enum`.
- Tier-1 (regex, Java) emits the shells and import placeholders; Tier-2 (Node sidecar on the
TypeScript compiler API) replaces declarations/imports from the checker and adds the `CALLS`
edges, shaped exactly like the Java parser's so every call-graph query and the placeholder
rewiring apply unchanged. A `slice`/`const` declaration is not a node in 192 (item 194).
- **Item 193:** a generated web-service call is a `FUNCTION` with `kind=endpoint`, `outbound=true`,
`httpMethod`, `restPath` (base-less, like a Java handler's composed `@Path`), `restBase`, `restUrl`,
`backend`, `queryParams`, `requestType`, `responseType`, `generator`, `owner`, `member`; an
interface's properties are `FIELD`s under its `DATA_STRUCTURE` (`dataType` = the TS type,
`optional`). A member call on an Endpoint instance carries `calleeMethod = <Class>.<member>`.
- **`COUNTERPART_OF`** (item 193, cross-project, built by enrichment, never by a parser):
endpoint `FUNCTION` → handler `FUNCTION` in a counterpart project (`via=rest`),
generated `DATA_STRUCTURE` → Java `MODULE` of the same simple name (`via=dto`),
`FIELD` → `FIELD` by name (`via=field`). Rebuilt on every refresh of either project; the
project node carries the `counterparts` list.
- **Item 194 (Redux store):**
```mermaid
graph LR
M[MODULE<br/>slice file]
S[STORE_SLICE<br/>name = reducer key, sliceName, stateType, store=true]
SF[FIELD<br/>name = key.field, dataType, optional, store=true, slice, field]
R[FUNCTION<br/>kind=reducer, reducerKind=reducer|case|matcher|default,<br/>name = action type, slice, trigger, actionType]
T[FUNCTION<br/>kind=thunk]
C[FUNCTION<br/>component / hook — any file]
M -->|CONTAINS|S
M -->|CONTAINS| R
S -->|CONTAINS|SF
R -->|" READS / WRITES — path, lineNo, via=reducer "|SF
R -->|" READS / WRITES — whole state "|S
T -->|" CALLS — callSyntax=extraReducer, actionType "|R
C -->|" READS — path, lineNo, via=useAppSelector|wrapper hook|getState "|SF
```
A consumer's read is emitted against a placeholder `<key>.<field>` (`sourceFile=""`, `store=true`)
and redirected by the finalize step `resolve-store-placeholder` onto the real field of the same
type and name (unique by construction), which then drops the placeholder. A dispatched action
creator is a cross-module `CALLS` edge with `actionType` and `calleeMethod` = the reducer's name.
- **Item 195 (DTO field bindings):** `FUNCTION`(component/hook) `-READS->` / `-WRITES->` the generated
interface's `FIELD` (`Broker` → `ebene`), edge props `path` (`broker.ebene`), `rootDto`, `kind`
(`field`/`prefix`), `partial`, `component`, `attribute`, `via=binding`, `lineNo`. Cross-file targets
are `FIELD` placeholders `<Dto>.<field>` with `binding=true`, `owner`, `field`, `targetModule`,
resolved exactly (module → structure → field) by `resolve-binding-placeholder` and then dropped.
- **Item 196 (styling):** the theme file's `DATA_STRUCTURE theme` (`kind=theme`) `-CONTAINS->`
`FIELD theme.<token>` (`theme=true`, `token`, `tokenKind=path|constant`, `value`, `constant`);
`FUNCTION`(component) `-CONTAINS->` `STYLE` (`styleKind=sx|style|styled`, `element`, `properties`,
`literals`, `dynamic`, `spread`; a CSS `MODULE -CONTAINS-> STYLE` per rule with `styleKind=css`,
`selector`); `STYLE -REFERENCES-> FIELD theme.<token>` (`via=theme`, `property`) and
`FUNCTION -REFERENCES-> FIELD theme.<token>` (`via=theme`, `context`). Cross-file token targets are
placeholders resolved by exact name by `resolve-theme-placeholder`; an undeclared token keeps its
placeholder (`declared=false` in `GET /theme`).
- No `DB_*` for TypeScript.
## Natural — `NaturalParser`
```mermaid

View File

@@ -5474,6 +5474,296 @@ reproduced the bug.)*
**Historical `commit` values in these docs are not comparable with the ones printed from here on** —
they were the sum of all three parts. Noted in the javadoc as well.
## Styling: theme tokens and the style inventory — item 196 (2026-09-22)
- [x] **196. Styling: theme-token usage and sx/styled inventory**
MUI v6 + Emotion: 244 `sx={}`, 27 `styled()`, 11 `className`, one theme in
`pur-ui-common/src/theme.ts`, three `index.css` (fonts + body reset). Decided scope: theme-token
usage and per-component inline inventory; plain `.css` only as `MODULE` with a `STYLE` per
selector. `theme.ts` → `DATA_STRUCTURE` with a `FIELD` per token (`palette.primary.dark`,
`spacing`, …); each `sx`/`styled`/`style` block → `NodeType.STYLE` under the component with its
CSS property keys and `REFERENCES` to the tokens it uses; hard-coded literals (`#005CA9`, `16px`)
recorded as `literals`. Answers: where is a token used, which tokens are dead, which components
bypass the theme, which components override `height`/`zIndex`. `GET /modules/{name}/styles`,
`GET /projects/{p}/styles/theme-usage?token=`; `ac styles`, `ac theme-usage`. Static only — no
cascade or rendered-layout claims.
**Implemented 2026-09-22.** As planned, with the VALIDATE adjustments: three theme-root forms
(`Theme`-typed values, `{ theme }` styled parameters, the theme object imported under any name);
`uses` counts project references only and `unused` is documented as "no project reference", never
"dead" (MUI consumes tokens itself); tokens the code reads that no theme declares keep their
placeholder and are listed with `declared=false` (MUI defaults, `spacing`, typos); `sx={props.sx}`
is a `dynamic` block; nested selectors flatten to `&:hover.color`; compound values yield their
literal parts (`1px solid #D2D2D2` → `1px`, `#D2D2D2`). Endpoints `GET /theme`,
`GET /theme/{token}/usages`, `GET /styles`; CLI `ac theme`, `ac theme-usages`, `ac styles`; CSS
rules from the Tier-1 scanner (`STYLE` per rule). Sidecar contract version 4 (`themeTokens`,
`styles`, `tokenRefs`). Sidecar dry run on `pur-ui-common`: 129 tokens (111 paths, 18 constants),
169 style blocks (87 sx, 55 style, 27 styled; 66 with literals, 42 reading tokens), 89 token reads
outside blocks. Verified in `StylesIT` and on `purfe` (server 326, recreate + deep refresh): 129
declared tokens, 12 undeclared ones the code reads, 304 style blocks (109 with hard-coded literals,
75 reading tokens), `palette.primary.dark` read 26 times; no placeholder left except the undeclared
tokens. The item-195 inherited-field fix is confirmed on the same run (its 8 placeholders are gone).
## Styling robustness — item 199 (2026-09-22)
- [x] **199. Styling review findings: several themes, repeated token reads, CSS scanner edge cases**
From the code review of item 196. (1) The sidecar read only the first `createTheme` per file and
the resolver demanded exactly one declaring token, so a light/dark pair in one file lost the dark
tokens and a pair of theme files left every shared token an unresolved placeholder with
`?unused=true` reporting used tokens as unused. Now every call is read (one fact per token and
file, first value wins, `variants` counts the themes), and the placeholder resolver redirects a
read onto every real token of the name; `theme/{token}/usages` and `styles[].tokens` deduplicate
the fan-out. (2) Two reads of one token on one line of a style block merged into one edge that
kept only the last key; the parser now groups them and `property` is the comma list. (3) The CSS
rule scanner: quotes are tracked so a `content: "{"` no longer unbalances the depth counter and
drops every following rule; a `;` at depth zero ends a block-less at-statement so `@import` no
longer leaks into the next selector; a nested rule head inside an at-rule body is stripped before
declaration matching (`a:hover {` was read as property `a`); a selector repeated on one line
(minified CSS) gets a `:col` suffix instead of collapsing.
**Tests.** `TypeScriptCoarseScannerTest.cssRulesSurviveAtStatementsStringsNestingAndMinification`,
the dark theme and the doubled `PRIMARY` read in the parser fixture (facts regenerated, contract
still 4 — `variants` is optional), `StylesIT` with a second theme file asserting both rows count
the read and neither is unused.
## Function-level callers across modules — item 197 (2026-09-22)
- [x] **197. `functions/{fn}/callers` cannot see cross-module calls — Java and TypeScript alike**
A cross-module call (Java cross-class, TypeScript import + call, an item-193 endpoint call from a
thunk) is a `MODULE -CALLS-> MODULE` edge carrying `callerFn` and `calleeMethod`; the function-level
callers query followed only direct `FUNCTION -CALLS-> FUNCTION` edges (Natural `PERFORM`, same-class
Java), so `…/AgstammLogic/functions/handleMerge/callers` answered `[]` although the module-level
`callers` listed `AgstammController`. (The roadmap's own Java example, a REST controller method, has
no Java callers because it is the HTTP entry point; the gap showed on the logic class it calls.)
**Fix.** Query only, no enrichment: `FUNCTION_CALLERS` gained a second `UNION` branch that joins the
module edges into the target module on `calleeMethod = callee.name` and resolves `callerFn` to the
FUNCTION of the calling module, honouring `manualHidden`; the same-module branch is untouched. Rows
keep the `CallRefResponse` shape (`edgeKind` = the edge's `callKind`, sites from `lineNo` + the
caller module's file). Name matching over-approximates overloads, and a call from top-level code with
no enclosing function has no row here (the module-level `callers` still shows it). REST path and
`ac function-callers` unchanged.
**Test.** `FunctionCallersCrossModuleIT`: a Java method called from its own class and from another
class lists both callers with their lines; a TypeScript function called from a component in another
module lists the component. `AnalysisResourceIT` (Natural PERFORM callers, zero-caller case) stays
green.
**Verified on `pur`/`purfe`** (server 328, no re-ingest): `AgstammLogic.handleMerge` →
`mergeBroker` at line 98; `GeneralAgreementUiControllerEndpoint.createNew` → the thunk in
`generalAgreementSlice` at line 92 — both empty before.
## Stale parsed edges are reaped on a deep refresh — item 198 (2026-09-22)
- [x] **198. Stale edges to placeholder modules survive a re-parse for Java and TypeScript**
Found while verifying item 193 on `purfe`: after the sidecar fix that maps `pur-ui-common/dist/x`
to `…/src/x`, a deep refresh still showed 1 022 `CALLS`/`REFERENCES` edges into 46 `dist`
placeholders next to the fresh `src` edges. The per-file reconcile (item 58) sweeps stale *nodes*
of a re-parsed file, but the stale-*edge* reaps were Natural-only, so an edge from a surviving
module that the new parse no longer produces lived forever — and its placeholder, having an edge,
escaped the placeholder sweep. Only recreating the project cleared it.
**Fix.** `mergeEdgesBatch` stamps `r.ingestGen = $ingestGen` on every parser-emitted edge (a
re-emitted edge is re-stamped through its MERGE key). A new language-agnostic step
`reap-stale-parsed-edges` (`CypherQueries.DELETE_STALE_PARSED_EDGES`) runs after `merge-edges`
and before `sweep-stale-file-nodes`, keyed on the item-160 `(sourceFile, ownerModule)` pairs of
the re-parsed files, and deletes every edge from those nodes whose stamp is older than the run's.
Deep only (`reconcile`), like the node sweep. Finalize-built edges carry no stamp unless a resolver
copied it from a parser edge, and the deep finalize that follows rebuilds those. The three Natural
reaps stay (they run *before* the merge and gate on statement kinds). Pre-existing edges without a
stamp are never reaped: the first deep refresh after the upgrade stamps, the second reaps — no
project recreation needed any more; the usage doc's "recreate after a parser change" note is retired.
**Test.** `StaleParsedEdgeReapIT`: a TypeScript component retargeted from `b` to `c` loses
`app/src/b` from `callees` while an untouched file keeps it; a retarget onto a missing
`./missing/d` mints a placeholder that disappears once the import is retargeted again; the same
for a Java class switching its call target from `B` to `C`. Existing reap ITs
(`StaleCallEdgeReapIT`, `StaleTableEdgeReapIT`, `RefreshReconciliationIT`) and the whole server IT
suite stay green.
## DTO field bindings — item 195 (2026-09-22)
- [x] **195. DTO field binding: which component reads/writes which backend field**
No mappers exist: `*UseCase` DTOs sit verbatim in Redux as `SvcResult<T>`. Bindings are typed
path expressions (`<SmartInput field={AgstammUseCaseField.broker.ebene}/>`, generated `Fields`
classes) resolved by the sidecar to the dotted path `broker.ebene` and linked to the `FIELD` of the
DTO interface; `pur-r-vbuch` binds lodash paths into the whole state. `SmartInput` → `WRITES`,
`SmartOutput` and plain reads → `READS`, from the component `FUNCTION` to the `FIELD`. With 193's
`COUNTERPART_OF` on the `DATA_STRUCTURE` this answers "which page edits `AgstammUseCase.broker.ebene`"
across the frontend/backend boundary. `GET /data-structures/{name}/fields` gains `boundBy`
counts; `ac data-structure-fields` follows.
**Implemented 2026-09-22.** As planned, with these decisions from VALIDATE: the target is the
generated interface's `FIELD` (declaring DTO `Broker`, not the root), reached through a
`binding=true` placeholder resolved exactly by module → structure → field (the generic resolver
never resolves module-owned structures, item 74); every hop is typed by the checker
(`XFields<TRoot, TSelf>`), list hops are the call's result type; a prop-rooted expression is
`partial` and its carrier prop is not part of the path; `kind=prefix` records a handed-on
sub-object as a read of the container field; WRITES for tags matching `Input$|Dropzone$|Editor$`.
One endpoint `GET /bindings` (with the field's `COUNTERPART_OF` columns) instead of per-field
endpoints; `data-structures/{dto}/fields` gained `boundReads`/`boundWrites` and — a bug found on the
way — now returns TypeScript `FIELD`s at all (the query filtered them out; item 193's doc claim was
wrong). A second 193 flaw surfaced on real data: interface `FIELD`s were named by the bare member,
and the node identity is type + name + file, so `vid` was ONE node under six interfaces of the
generated file (six `COUNTERPART_OF` twins, six-fold binding rows). Fields are now
`<Interface>.<member>` with props `field`/`owner`; `data-structures/{dto}/fields` and `bindings`
report the bare member, `counterparts` the qualified name. Sidecar contract version 3 (`bindings`). Sidecar dry run on
`pur-ui`: 204 bindings (174
field, 30 prefix; 50 partial) over 22 DTOs — 61 `SmartInput`, 64 `SmartOutput`, 23
`fieldTermForRowData`. Verified in `BindingsIT` (frontend + backend, counterpart columns, counts,
no placeholder left) and on `purfe` (server 322, recreate + deep refresh): 257 sites over 23 DTOs,
all linked to `pur`; `counterparts?kind=field` 744 fields with exactly one twin each. A leaf
inherited from a base interface (`datStart` on `AbstractHistorizedDO`) resolves to the declaring
interface — the single-hop case was fixed after that run and is covered by the next deploy.
## The Redux store — item 194 (2026-09-22)
- [x] **194. Store: `STORE_SLICE` + `FIELD`, `READS`/`WRITES`/`CALLS` from reducers, selectors, dispatch**
Redux Toolkit: 16 `createSlice`/`createAppSlice` files, thunks via `createAppAsyncThunk` named
`<slice>/<op>`, status via `isSlicePending/Fulfilled/Rejected` matchers, three-hop access
slice → facade hook (`useAgstamm`, `useAgstammSelector`) → component; `pur-r-vbuch` selects by
lodash path into the whole state. New `NodeType.STORE_SLICE` per slice with `FIELD` children named
slice-qualified (`agstamm.agstammUseCaseSvcResult`) so `/variables/{name}/reads|writes` and
`flow-forward` work unchanged. Reducer assignments → `WRITES`, selectors → `READS`,
`dispatch(action)` → `CALLS` to the reducer/thunk `FUNCTION`. The store field is **not** flattened
into the DTO: it holds `SvcResult<X>` and `USES_TYPE` the DTO `DATA_STRUCTURE`.
`GET /projects/{p}/store`, `GET /projects/{p}/store/{slice}/fields/{field}/reads|writes`;
`ac store`, `ac store-reads`, `ac store-writes`. `context` extended with store reads/writes.
**Implemented 2026-09-22.** Deviations from the plan above, decided while reading the real store:
the slice is named by its **reducer key** (`gruppenprovision`), not the RTK name
(`generalAgreement`) — the key is what every selector path starts with; the store mapping is
traced by the sidecar through `xReducer = xSlice.reducer` / `export default`, across workspaces.
Reducers are `FUNCTION`s named by the **action type** they handle (`schluesseltabelle/updateX`,
`schluesseltabelle/suche/fulfilled`, `…/matcher:isSlicePending(sliceName)`), so a dispatched
action's `calleeMethod` is the reducer. Reads come from three forms: selector arrows (incl.
destructured results), **wrapper hooks** (`useSchluesseltabelleSelector`, resolved to their base
path) and `getState()` chains (81 sites on `pur-ui`). Instead of one endpoint per field, one
`GET /store` (slices with fields and counts) and one `GET /store/{slice}/accesses?field=&mode=`;
CLI `ac store`, `ac store-accesses`. `USES_TYPE` field → DTO deferred to 195 (the field's
`dataType` already says `SvcResult<X>`); `context` is not extended (the accesses endpoint and
`variables/<slice>.<field>/reads|writes` answer). Sidecar facts contract bumped to **version 2**
(`slices`, `store`, `stateAccesses`, `calls[].actionType`). Cross-file reads are placeholders
`<key>.<field>` (`store=true`) resolved by a new cheap finalize step in every mode; the item-74
stale-edge sweep covers store targets on a deep re-ingest. Verified on the fixtures (sidecar,
parser, reader tests) and end-to-end in `StoreIT`; on `purfe` (server 318, recreate + deep refresh, 29 s): 9 slices,
43 reducers, 216 reads / 82
writes, no placeholder left; the reducer key ≠ slice name case (`gruppenprovision` /
`generalAgreement`) resolves correctly. Not modelled: a slice created inside a factory function
(`filetransferSlice`), which is also not mounted in the store.
## Web-service calls and the counterpart link — item 193 (2026-09-22)
- [x] **193. Webservice calls: outbound `rest-endpoints` on the frontend and `COUNTERPART_OF` to `pur`**
Two generated client generations exist: legacy `generated/*endpoints.ts` classes (one per backend
controller, `baseUrl` + `get`/`post` members, `AgstammControllerEndpoint.saveBroker.post(...)`)
and, in `pur-r-vbuch`, a hey-api `sdk.gen.ts` (URL literal per function, consumed via TanStack
Query hooks and thunks). Both become `FUNCTION`s carrying `restPath` (composed from `baseUrl` +
member path), `httpMethod`, `outbound=true`, `requestType`, `responseType` — the same properties
the Java parser writes (item 130), so `GET /rest-endpoints` lists the frontend's outbound calls
with no new endpoint. Thunks and components reach them through ordinary `CALLS`.
New `EdgeType.COUNTERPART_OF` and a counterpart enrichment step (Cypher in `CypherQueries`, driven
like the other steps — there is no `GraphEnricher` SPI): outbound frontend `FUNCTION` → `pur`
handler matched on `httpMethod` + `restPath`; generated `DATA_STRUCTURE` → Java class by simple
name; `FIELD` → `FIELD` by name. Per-file reconcile `DETACH DELETE`s incoming edges, so the step
re-runs at the end of every refresh of either project; a project setting `counterparts: [..]`
names the partner. First cross-project MERGE in the codebase. `GET /projects/{p}/counterparts?
module=&limit=&offset=` + `ac counterparts`. Same edge serves item 143.
**Implemented 2026-09-22.** Sidecar (`extract.mjs`, contract v1 + additive `endpoints` and
`declarations[].members`): the legacy generator's Endpoint classes (`baseUrl` + `get`/`post`
members, URL template reconstructed from `build<Backend>URL(`…`)`, `{param}` for substitutions,
request/response/params types from the member's type arguments) and hey-api sdk functions
(`url:` literal, verb from the client call). Parser: `TypeScriptRestPaths` splits an application
base (`/<name>/v<n>`) off and strips the query string; endpoint `FUNCTION`s carry
`restPath`/`httpMethod`/`outbound` exactly like Java handlers, so `rest-endpoints` lists them with
no query change beyond `outbound = f.outbound = 'true'`; interface members become `FIELD`s; a
member call on an Endpoint instance is retargeted to the endpoint function
(`calleeMethod = AgstammControllerEndpoint.saveBroker` on the module-to-module edge; note that
`functions/{fn}/callers` does not follow such edges for Java either — item 197). Store: `EdgeType.COUNTERPART_OF`,
project property `counterparts` (create/update/get/list, REST `ProjectRequest.counterparts`, CLI
`--counterpart`, self-reference → `400 COUNTERPART_SELF`), enrichment steps
`delete-counterpart-edges` / `link-counterparts-rest` (path shape key: `{param}` → `{}`, class +
method `@Path` composed as `REST_ENDPOINTS` does — keep in step) / `link-counterparts-dto` /
`link-counterparts-field`, run after every finalize for the project **and for every project
listing it** (`COUNTERPART_HOLDERS`), the first cross-project MERGE in the codebase.
`GET /projects/{p}/counterparts` + `ac counterparts` (`--kind`, `--unmatched`, `--count-only`,
paging). `CounterpartsIT` builds a Java backend and a TypeScript frontend as two projects and
pins: outbound rows, call → handler incl. `{vermnr}` shape, `unmatched` = the one call nothing
serves, DTO and field links, survival of a backend refresh, the setting and the two 400s.
Scope note: the registered frontend is `pur-ui` + `pur-ui-common` (backend `pur`, base `''`), so
the base-stripping matters only for the excluded vstamm/vbuch clients. **Verified on the deployed
server (version 312) 2026-09-22:** `purfe` deep refresh 29 s (sidecar 6.1 s + 8.6 s for the two
workspaces), 285 files, 0 failures; `rest-endpoints` 51 outbound rows, **51 of 51 linked** to `pur`
handlers, 243 DTOs and 632 fields linked; the 52 unmatched DTOs are mirrors of JDK/framework types
(`Class`, `Comparable`, `Annotation`, …) with no class in `pur`. Two defects found on real data and
fixed the same day: imports of `pur-ui-common` resolved into its `dist` typings (now mapped to the
`src` twin) and transitive packages (`immer`, `redux`) became placeholders (now external imports;
Tier-1 reads `node_modules` directory names as externals). After the fix (server 314, project
recreated because of item 198): 0 edges into `dist`, 7 placeholders left (from the Tier-1 pass
before the node_modules rule), counterparts unchanged 51/51.
## TypeScript / React analysis — item 192 (2026-09-22)
- [x] **192. `ac-parser-typescript`: language wiring, coarse scan, sidecar, import/call graph, LoC**
New Maven module `ac-parser-typescript` implementing `LanguageParser`, `CoarseScanner` and
`LineCounter` for language `typescript` (`.ts`/`.tsx`) plus `css` (`.css`). Frontend registered
as project `purfe`. Delivers `MODULE` per file, `FUNCTION` per exported function / React component
/ hook (property `kind`), `CALLS` and `REFERENCES` from imports and call sites, `DATA_STRUCTURE` +
`FIELD` per exported interface/type (generated ones carry `generated=true` and
`javaCounterpart=<simpleName>`), and LoC/SLoC so `GET /loc?language=typescript` works.
Validated design decisions (do not re-derive):
- **Sidecar** = `ac-parser-typescript/sidecar/` (own `package.json`, pinned `typescript`, built in a
`node:24-slim` image stage and copied into `Dockerfile.jvm` together with the `node` binary). The
JVM starts it per workspace with `ProcessBuilder`, a timeout and `--max-old-space-size=1024`, reads
a per-file JSON facts document from stdout, and the process ends. `noEmit`, no `incremental` (the
project root is mounted `:ro`). `excludeDirs` (`node_modules`, `dist` by default) applies to the
file walk only; the sidecar still reads the project's `node_modules` for library typings.
- **Project context** like `CopycodeLibrary`: a `TypeScriptFacts` built once per ingest and passed
into every per-file `parse()`; the Tier-1 coarse scanner is pure Java regex and needs no Node.
- **`SourceFiles.Language` gains `TYPESCRIPT` and `CSS`**, and every `== JAVA` / "else Natural"
branch (`AstIngestService.parse/coarseScan/count`, `ProjectIngestService` ~223 / ~1054, the seven
`language: 'natural'|'java'` literals in `CypherQueries`) becomes an exhaustive switch.
`ProjectResource.SUPPORTED_LANGUAGES` gains `typescript`.
- **Anonymous nodes** (a `sx` block, a store write) use the `startLine` MERGE variant like
`DB_ACCESS`, named `<owner>@<line>`.
- `docker-compose.yml`: `mem_limit` 4g → 5g, frontend root mounted `:ro`.
- Fixtures under `ac-parser-typescript/src/test/resources/fixtures/typescript/`; the JSON facts
format is the tested contract between the two halves, so Java unit tests need no Node.
- Accepted: a `changedOnly` refresh re-runs the sidecar over the whole workspace but re-persists
only the changed files; unchanged files' facts may lag one refresh (same class as item 46a).
**Implemented 2026-09-22.** New module `ac-parser-typescript` (`TypeScriptCoarseScanner`,
`TypeScriptParser`, `TypeScriptLineCounter`, `CssLineCounter`, `TypeScriptModuleNames`,
`TypeScriptProject`, `TypeScriptFacts` + `TypeScriptFactsReader`, `TypeScriptSidecar`) and the
sidecar `ac-parser-typescript/sidecar/extract.mjs` (pinned `typescript` 5.9.3, `npm ci`; the JSON
facts contract v1 is documented at the top of the script and pinned by the checked-in
`facts-pur-r-vstamm.json`, which `TypeScriptSidecarTest` regenerates live and compares). Server:
`SourceFiles.Language` gained `TYPESCRIPT`/`CSS` with exhaustive switches in `AstIngestService`
(`parse`/`coarseScan`/`count`); `SourceFiles.ingestedBy` gates TypeScript/CSS to `typescript`
projects; `DEFAULT_EXCLUDE_DIRS` = `target, node_modules, dist`; `TypeScriptSidecarService` builds
the per-ingest context (Tier-1: package.json only; deep: sidecar per workspace, failures reported
as `sidecar:<workspace>` pseudo-paths); the copycode stand-down of item 129 is now Natural-only by
name. `ProjectResource.SUPPORTED_LANGUAGES` and `ac project create --language` accept
`typescript`. `Dockerfile.jvm` is a two-stage build (node + sidecar copied from `node:24-slim`),
the compose build context moved to the repository root with a root `.dockerignore`,
`mem_limit` 4g → 5g, the frontend root mounted `:ro`. Config `agenticcode.typescript.*` in
`application.properties` (`%prod` points into the image). Measured on the real frontend: 413
files, 15 s for all four workspaces, imports resolved 99.5 % (the rest: `index.css`, two deep
`moment` locale paths). Decision taken while implementing: a CSS module keeps its `.css` in the
identity, because `index.css` next to `index.ts` would otherwise collide on `…/src/index` and be
skipped as a duplicate. 35 unit tests in the module, 232 across the build.
## Closing the performance campaign (items 173-175) — 2026-09-06
- [x] **173. Where the deep refresh stands after items 153-172, and what is left** (written 2026-09-06)

View File

@@ -239,6 +239,25 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
Neither the node cost nor the Natural side (does the same gap exist for long `MOVE`/`COMPRESS` text?)
has been measured.
- [ ] **181. `callees` / `callers` truncate silently at 50 rows — no header, no body flag** (found
2026-09-17, `PartnerCopy` Java↔Natural verification)
```
GET /upms/modules/DPARTFN0/callees → 50 items (17 MODULE), no X-AC-Truncated header
GET /upms/modules/DPARTFN0/callees?limit=1000 → 58 items (25 MODULE)
GET /upms/modules/DPARTFN0/callees?scope=external → complete
```
The unscoped default drops `YPARTBN0`, `YPARTGNH`, `YPARTMN0`, `YPARTMNH`, `YPHONBNH`, `YPHONMNH`,
`ZINCLGET`, `ZINERR01` — among them the module that actually writes the partner. `digest` lists them,
and `callers` of `YPHONMNH` does include `DPARTFN0`, so the analysing agent reported it as an
*inconsistency* between endpoints; it is the 50-row page, and nothing in the response says so.
Item 131 added `X-AC-Total-Count` / `X-AC-Truncated` to the search endpoints (item 135 to two more);
`callees`/`callers` return an object with `sourceFiles`/`items` and carry neither the headers nor a
`truncated` field. A wrong answer, not a missing one: the caller cannot tell a complete fan-out from a
cut one. Either send the headers (and a `truncated` field, as `call-tree` does since item 67) or
return all rows when `limit` is absent, as `db-accesses` does.
## Agent API gaps
- [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom —
@@ -413,6 +432,181 @@ after probing and are recorded at the end, so nobody re-files them.
MODULE_NOT_FOUND`), while `viaCopycode`/`includePath` on other responses hand back exactly that
name. Accepting a copycode name would close the loop.
Normal priority. Reported 2026-09-17 after verifying `PartnerController.insertPartnerCopyHauptwohnsitz`
(`pur`) against the Natural service it replaces, `WPARTX1S` → `WPARTD1S` → `DPARTFN0`/`DPARTEN0`/`YPARTMNH`
plus the nested ADDR level `WADDRX0S` → `WADDRD1S` → `DADDRFN0`/`DADDREN0` (`upms`), with the old adapter
`UpmsPvwPartnerHauptwohnsitzInsert` (`app`). Four agents compared mapping, validation, main address and
sample-partner copy; every gap below forced a fall-back to reading source. Two further gaps from the same
run are not re-filed: *"which Java method implements which Natural rule"* is item **143**, and *"is message
4136 raised on the Java side at all"* is item **149** — both were needed in this run (two missing 4136
rules were found only by reading `PartnerValidator`). The silent 50-row cut on `callees` is item **181**
under Known bugs.
- [ ] **182. No call order and no guard conditions — "does X run before Y, and only on ADD?" cannot be
asked**
The two most consequential findings of the run were ordering questions:
* `DPARTFN0.DO-ACTION` → `PROCESS-OBJECT` → `BEFORE-ET` → `COPY-ADDR-COMM-BANK-DATA` runs **inside** the
PART object call, i.e. before `WPARTX1S.CALL-NEXT-LEVEL-PUT` inserts the main address. The Java does it
the other way round, so `DADDREN0.CHECK-DOUBLES` (8012) sees different data.
* Are `ACCESS-MDCL`, `CHECK-RELEASE`, `VMDCLN01`, `YMDCLMN0` reached on `#ADD`? They are not —
`DPARTFC0.cpy` sends `C-MOD-ADD` to `WHEN NONE → CALL-MAINTAIN` — but `digest`, `callees` and
`call-tree` list them unconditionally.
`callees` returns a set with `lineNos`; `call-tree` a depth-annotated set. Neither says in which order a
function performs its callees, nor under which `DECIDE`/`IF` branch. The `DECIDE` guard already exists on
`dispatch-table` rows and `CONTROL_FLOW` nodes exist (P2-a), but no query joins them to a call edge.
**Proposed shape.** `GET /modules/{name}/functions/{fn}/sequence` → ordered
`[{ lineNo, kind: PERFORM|CALLNAT|INCLUDE, target, guards: [{ construct, condition }] }]`, and an optional
`?guard=CDAOBJ2.#FUNCTION=C-MOD-ADD` on `call-tree` that prunes branches whose literal guard contradicts
the value. Item 111e (per-statement `CONTROL_FLOW`) and item 75 (`CONTAINS` traversal cost) are the
constraints; item 142 (`live`) is the sibling question for commented-out `PERFORM`s. Report a guard that
cannot be evaluated statically as `null`, never as satisfied.
- [ ] **183. Transaction boundaries are not graph data**
*"Is the partner rolled back when the address fails?"* needed four files: `WPARTX1S:230` forces
`P-OPT-NO-ET := 'X'`, `WPARTD1S:356` passes it on, `USIX044C.cpy` suppresses `END TRANSACTION` in
`DPARTFN0` (`#OMIT-ET`) and `YPARTMN0`, and `WPARTX1S:235-240` finally does one `END TRANSACTION` or
`BACKOUT TRANSACTION` for the whole PUT. No endpoint returns ET/BT sites, let alone the flags that
suppress them.
**Proposed shape.** `GET /modules/{name}/transactions?depth=N` →
`[{ module, function, lineNo, kind: END|BACKOUT, guards, viaCopycode, via }]`, guards as in item 182.
The Java counterpart (`@RunInTransaction`, `@Transactional`) is the annotation half of item 190.
- [ ] **184. Data flow between modules called one after another, and qualified PDA names, return an empty
`200`**
* `W-WIF-A1.P-NIF-PERSONA` is written in `WPARTD1S:374` and read in `WADDRD1S:1056`. Both are called in
sequence from `WPARTX1S` with the same PDA by reference; neither calls the other. `field-flow` finds no
edge, because it pairs a producer with consumers reachable **via `CALLS` from the producer**.
* `MSG-INFO.##MSG-NR` travels `ISINSOIN` (writes 4218) → `DPARTEN0` → `DPARTFN0` → `YPARTMNH`
(`RESET MSG-INFO`). This chain is how an invalid social insurance number is silently accepted by
Natural — the single most surprising finding of the run — and it was found by reading.
Probes (all `200 []`):
```
GET /upms/variables/NIF-PERSONA-SAMPLE/flow-backward?module=DPARTFN0
GET /upms/variables/%23%23MSG-NR/flow-forward?module=ISINSOIN
GET /upms/variables/MSG-INFO.%23%23ERROR-FIELD/reads?module=DPARTEN0 (unqualified ##ERROR-FIELD → 21 rows)
```
Item 146 covers the positional argument→parameter hop; this is the case beside it — **sibling calls
sharing a by-reference PDA**, where the producer's caller is the consumer's caller. Two asks:
(a) derive producer→consumer pairs across sibling `CALLNAT`s of one caller (order-imprecise is acceptable
if flagged, as `field-flow` already is); (b) a qualified name `GROUP.FIELD` that resolves to nothing
should answer `404` or a hint, not an empty `200` that reads as "nobody reads it".
- [ ] **185. Validation rules are not extractable — per action: condition, message, error field, return
code**
Rebuilding `DPARTEN0`'s rule list for `#ADD` (≈ 40 rules) and `DADDREN0`'s (≈ 12) was the bulk of the
run, entirely by reading. The rules live half in copycode — `ISI173C1` (4080/4216), `ISICMAND` (126),
`USIX058C` (8011) — and `dispatch-table` shows only some of the expanded assignments. Item 46a already
splices copycode at ingest with `&n&` substitution, so the graph has the material; there is no view on it.
What decides correctness is not the message number alone but **what the caller does with it**:
`DPARTEN0:217-222` raises only `IF MSG-INFO.##ERROR-FIELD NE ' '`, so `ISINSOIN`, which sets `##MSG-NR`
without an error field, never stops the insert. A rule list without the "error field set / return code
set" columns would have missed it.
**Proposed shape.** `GET /modules/{name}/rules?action=ADD` →
`[{ lineNo, viaCopycode, includedAt, condition, msgNr, errorField, returnCodeSet, escape }]`, plus
`GET /modules/{name}/source?expandCopycode=true&includedAt=<line>` for the substituted text of one
include. Pairs naturally with item 149 (the Java side of the same message number).
- [ ] **186. Assignments carry no formats — implicit conversions are invisible; `MOVE BY NAME` is not
resolved**
* `DPARTFN0:480 YPARTMA1.NIF-PERSONA := VNUMEGAA.P-NUMBER` assigns `N12` to `A14`. Whether the new
partner id is `000000012345` or `12345` decides whether the Java port (`String.valueOf(…)`) is correct —
rated critical in the run, but left a hypothesis for lack of exactly this information.
`variables/NIF-PERSONA/writes` returns
`assignedValue: "VNUMEGAA.P-NUMBER"` and nothing about either format.
* `DPARTFN0:1063/1114/1165/1216 MOVE BY NAME Y…ROW TO Y…MA1` copies every same-named field, including the
sort keys `VAL-SK-*` and `LOG-*-END` that the Java recomputes or resets. Which fields match had to be
worked out by diffing two `data-structures/…/fields` responses by hand.
**Proposed shape.** On `writes`: `sourceDataType`, `targetDataType` and `conversion`
(`N→A zero-padded`, `A→N`, `truncating`, …) where both sides resolve. For `MOVE BY NAME`:
`GET /variables/{struct}/move-by-name?module=&lineNo=` → matched field pairs, plus later writers of each
target field in the same module.
- [ ] **187. XML web-service routing and the tag contract are not queryable**
P1-m resolves `W-MNT-N0 → KDWWIFN0 → Wxxxx*S` into edges, but the **routing key** is lost:
`GET /upms/modules/WPARTX1S/callers` → `[W-LST-N0, W-MNT-N0]` (`CALLNAT_DYNAMIC`), with no trace that
objects `PartnerCopy` **and** `PartnerAddress` both route here (`KDWWIFN0.nat:888-891`). The file
`x-docs/kdwwifn0-prog-routing.csv` already holds this mapping outside the graph.
The second half crosses projects: the old adapter (`app`) emits tags such as `cod_salut_id`, `ind_copyable`
(always `'0'`/`'1'` when a Vermittler exists — the root of a Java 8011 regression) and `cod_addrtype_id`
= `"1"`; `WPARTD1S` consumes them in `DECIDE ON #W-TAG`. Which request field reaches which PDA field was
reconstructed from both sources.
**Proposed shape.** (a) the routing key on the dynamic edge or `dispatch-table` rows
(`{ objectName, adapter, target }`); (b) `GET /modules/{name}/xml-tags` → consumed tags with target field
and line; the producing side in `app` rides on item 143's cross-project edge, not a traversal.
- [ ] **188. `sql-statements` cannot be filtered to one call site**
*"Which statement, and which `ORDER BY`, does the browse at `DPARTFN0:1048` run?"* (`YADDRBNH`, two
leading key components). `sql-statements?depth=2` on `DPARTFN0` returns 555 statements. Suggest
`?via=YADDRBNH` and `?callSite=DPARTFN0:1048` (or `?function=COPY-ADDR-COMM-BANK-DATA`), returning only
the statements reachable from there. The ordering mattered: Natural browses by
`NUM_ADDRESS, DAT_START, V_ISN`, the Java repositories by number only.
- [ ] **189. Java `call-tree` and `db-accesses` on `pur` are too noisy to use for "what does this logic
reach"**
```
GET /pur/modules/PartnerCopyLogic/call-tree?depth=3 → 178 items, all type MODULE, among them var,
String, Math, Integer, LOGGER, java.util.List,
constants (COUNTRY_AT, PARTTYPE_LEGAL, …)
GET /pur/modules/AddressLogic/db-accesses?depth=3 → 203 rows over 157 tables: 184 DECLARES,
14 READS, 5 WRITES
```
Both agents on the Java side gave up and read the classes. Suggest excluding JDK/`java.lang` types,
local-variable type names and constant holders from `call-tree` by default (a `?includeTypeRefs=true`
escape hatch), and restricting `DECLARES` at `depth>0` to entities actually used by a reached repository
method.
- [ ] **190. Annotation semantics — which interceptor runs, and what it does — are not reachable**
`@RunInTransaction(type = READ_WRITE)` and `@RequiresRole(roles = {})` on the endpoint decided two
findings: whether an `IllegalArgumentException` after the partner store rolls it back, and whether the
open role list matches Natural's `ZINXSEC` (it does). `search/annotation` finds the annotation sites but
not the `@InterceptorBinding` → interceptor class → `@AroundInvoke` method chain. Suggest
`GET /pur/annotations/{name}/interceptors` → `[{ interceptor, aroundInvoke, sourceFile, lineNo }]`; the
rollback rule itself stays a source read, but finding the class should not be.
- [ ] **191. Reference data behind the code is out of reach** (low priority — may be out of scope)
Four findings stay hypotheses because they depend on DB content, not code: key-table formats
(`IN-FORM-CLAVE`, `IND-CLIENT` — does `CODES-TO-INT` zero-pad this code?), `CONSTDAT` values per client
(`NATPERS`, `AUSTRIA`, `NONTRADE`, `UNKTAXOF`, `MAINADDR`, hard-coded in the Java), whether `'00000001'
IS (N1)` holds, and the INSTLDA routine suffix for number range `PART-NR`. Not a graph problem; filed so
the boundary is explicit. If a reference-data export exists, a read-only `GET /reference-data/{table}`
would turn four hypotheses into lookups.
## Frontend — React/TypeScript analysis (2026-09-22)
The `pur` backend (Java, ingested) is driven by a React/TypeScript frontend at
`/home/ingo/deve/uniqa/pur-sources/frontend` (npm workspaces `pur-ui`, `pur-ui-common`,
`pur-r-vstamm`, `pur-r-vbuch`; 253 `.tsx` + 213 `.ts`, ~31k hand-written lines). **Scope decided
2026-09-22: only `pur-ui` and `pur-ui-common` are registered (project `purfe`, `excludeDirs`
`pur-r-vstamm`, `pur-r-vbuch`); both use the legacy generated client against backend `pur`.** Nothing of it is
in the graph. The questions that cannot be asked today: *which component reads/writes which store
field*, *which backend endpoint does this page call*, *which DTO field is bound where*, *which
theme token is used by whom*. Items 192–196 add a `typescript` language with a Tier-1 regex coarse
scanner in Java and a Tier-2 **Node sidecar** on the TypeScript compiler API (whole-program,
type-aware; measured 3–5 s and ~0.5 GB heap per workspace). Decided 2026-09-22 after PROPOSAL +
VALIDATE. **192 (parser module, sidecar, wiring, import/call graph, LoC), 193 (web-service calls
in `rest-endpoints`, `COUNTERPART_OF` to `pur`), 194 (the Redux store), 195 (DTO field bindings)
and 196 (styling) are implemented — 2026-09-22, see `features.md`**; the validated design notes
live there.
## Ingest performance
- [ ] **180. The `DOCUMENTS` edge carries two strings and nothing else — 20.2 % of all edges**

View File

@@ -162,6 +162,16 @@ paging_check "search/annotation" "search/annotation?name=ApplicationScoped"
paging_check "search/value" "search/value?value=project"
paging_check "search/references" "search/references?name=Logger"
paging_check "rest-endpoints" "rest-endpoints?"
paging_check "counterparts" "counterparts?"
# Item 194: the store endpoints answer for every project (an empty list on a non-TypeScript one).
get "$API/projects/$PROJECT/store"
check "store: answers 200 with a list" "$([[ $STATUS == 200 && $(head -c1 "$BODY") == '[' ]] && echo 0 || echo 1)" "status=$STATUS"
# Item 195: bindings is a paged list on every project (empty on a non-TypeScript one).
paging_check "bindings" "bindings?"
# Item 196: theme and styles answer on every project (empty on a non-TypeScript one).
get "$API/projects/$PROJECT/theme"
check "theme: answers 200 with a list" "$([[ $STATUS == 200 && $(head -c1 "$BODY") == '[' ]] && echo 0 || echo 1)" "status=$STATUS"
paging_check "styles" "styles?"
# --- 4. data plausibility -----------------------------------------------------------------
group "4. Data plausibility"