diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bdac48 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/README.md b/README.md index 6feda70..d3cfe3e 100644 --- a/README.md +++ b/README.md @@ -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 --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` ( diff --git a/ac-cli/src/main/java/com/agenticcode/cli/AgenticCodeCli.java b/ac-cli/src/main/java/com/agenticcode/cli/AgenticCodeCli.java index 2893d60..127a844 100644 --- a/ac-cli/src/main/java/com/agenticcode/cli/AgenticCodeCli.java +++ b/ac-cli/src/main/java/com/agenticcode/cli/AgenticCodeCli.java @@ -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, diff --git a/ac-cli/src/main/java/com/agenticcode/cli/BindingsCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/BindingsCommand.java new file mode 100644 index 0000000..8731254 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/BindingsCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/CounterpartsCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/CounterpartsCommand.java new file mode 100644 index 0000000..439dc40 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/CounterpartsCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java index 7b1d3ec..bb7280d 100644 --- a/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java +++ b/ac-cli/src/main/java/com/agenticcode/cli/ProjectCommand.java @@ -59,13 +59,17 @@ final class ProjectCommand implements Callable { @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 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 { 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 { description = "Directory name to skip when scanning the root (case-insensitive); repeatable") List 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 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 { excludeDirs.isEmpty() ? null : excludeDirs, language.isBlank() ? null : language, generatedDir.isBlank() ? null : generatedDir, - userExitDir.isBlank() ? null : userExitDir))); + userExitDir.isBlank() ? null : userExitDir, + counterparts.isEmpty() ? null : counterparts))); } } diff --git a/ac-cli/src/main/java/com/agenticcode/cli/ProjectRequest.java b/ac-cli/src/main/java/com/agenticcode/cli/ProjectRequest.java index 62e13c6..00b8161 100644 --- a/ac-cli/src/main/java/com/agenticcode/cli/ProjectRequest.java +++ b/ac-cli/src/main/java/com/agenticcode/cli/ProjectRequest.java @@ -14,5 +14,6 @@ import java.util.List; * leave the stored values unchanged. */ record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List excludeDirs, - @Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) { + @Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir, + @Nullable List counterparts) { } diff --git a/ac-cli/src/main/java/com/agenticcode/cli/StoreAccessesCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/StoreAccessesCommand.java new file mode 100644 index 0000000..372a57a --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/StoreAccessesCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java new file mode 100644 index 0000000..1e3dbd3 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java new file mode 100644 index 0000000..a9a2eb8 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java new file mode 100644 index 0000000..bd11573 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/java/com/agenticcode/cli/ThemeUsagesCommand.java b/ac-cli/src/main/java/com/agenticcode/cli/ThemeUsagesCommand.java new file mode 100644 index 0000000..bad1414 --- /dev/null +++ b/ac-cli/src/main/java/com/agenticcode/cli/ThemeUsagesCommand.java @@ -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; + } + } +} diff --git a/ac-cli/src/main/resources/agenticcode.properties b/ac-cli/src/main/resources/agenticcode.properties index 10b6d37..2033d44 100644 --- a/ac-cli/src/main/resources/agenticcode.properties +++ b/ac-cli/src/main/resources/agenticcode.properties @@ -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 diff --git a/ac-code-server/pom.xml b/ac-code-server/pom.xml index 0ea91f8..c48e8e3 100644 --- a/ac-code-server/pom.xml +++ b/ac-code-server/pom.xml @@ -29,6 +29,10 @@ com.agenticcode ac-parser-java + + com.agenticcode + ac-parser-typescript + com.agenticcode ac-neo4j-store diff --git a/ac-code-server/src/main/docker/Dockerfile.jvm b/ac-code-server/src/main/docker/Dockerfile.jvm index 4061bd3..f2e5597 100644 --- a/ac-code-server/src/main/docker/Dockerfile.jvm +++ b/ac-code-server/src/main/docker/Dockerfile.jvm @@ -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 diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java index 9e097cc..be79498 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/AnalysisResource.java @@ -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 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 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 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 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 (``) " + + "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 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.`), " + + "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 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 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))) diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java index 6a0d008..a0fea8d 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/api/ProjectResource.java @@ -61,7 +61,7 @@ public class ProjectResource { .build(); } - private static final List SUPPORTED_LANGUAGES = List.of("natural", "java"); + private static final List 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 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 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 excludeDirs, @Nullable String language, - @Nullable String generatedDir, @Nullable String userExitDir) { + @Nullable String generatedDir, @Nullable String userExitDir, + @Nullable List counterparts) { + + /** + * Pre-item-193 shape (no counterparts). + */ + public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List 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 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 normalizedCounterparts() { + if (counterparts == null) { + return null; + } + return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList(); } List excludeDirsOrEmpty() { diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/AstIngestService.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/AstIngestService.java index 4f1a848..aa15212 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/AstIngestService.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/AstIngestService.java @@ -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); } diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/ProjectIngestService.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/ProjectIngestService.java index 4e4881f..2ecaad4 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/ProjectIngestService.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/ProjectIngestService.java @@ -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 userExit, + CopycodeLibrary copycodes, TypeScriptProject ts, Map 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 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 DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist"); - private static List walk(Path root, List excludeDirs) throws IOException { + private static List walk(Path root, List excludeDirs, @Nullable String projectLanguage) + throws IOException { List 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 excludeDirs = ingestExcludeDirs(project); - List allCandidates = walk(root, excludeDirs); + List 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 candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates) : allCandidates; Map 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 failed = new ArrayList<>(); + TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed); List 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 = new ArrayList<>(); - List failed = new ArrayList<>(); try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) { List> 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 candidates = walk(root, ingestExcludeDirs(project)); + List candidates = walk(root, ingestExcludeDirs(project), project.language()); Map> 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 candidates = walk(root, ingestExcludeDirs(project)); + List candidates = walk(root, ingestExcludeDirs(project), project.language()); Map> index = nameIndex(candidates); Map> implementorsByBase = buildImplementorIndex(root, candidates); Map byRelPath = new HashMap<>(); @@ -867,6 +880,10 @@ public class ProjectIngestService { long startedAt = System.nanoTime(); CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project)); Map userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService); + List 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 inferredCallTargets = new HashSet<>(); Set staticCallTargets = new HashSet<>(); List duplicates = new ArrayList<>(); - List failed = new ArrayList<>(); List examinedFiles = new ArrayList<>(); Set 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 diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/SourceFiles.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/SourceFiles.java index 7c3e58a..9abd151 100644 --- a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/SourceFiles.java +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/SourceFiles.java @@ -18,8 +18,15 @@ import java.util.Locale; * *

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}. + * + *

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. diff --git a/ac-code-server/src/main/java/com/agenticcode/codeserver/service/TypeScriptSidecarService.java b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/TypeScriptSidecarService.java new file mode 100644 index 0000000..7234181 --- /dev/null +++ b/ac-code-server/src/main/java/com/agenticcode/codeserver/service/TypeScriptSidecarService.java @@ -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:}; its files are then + * parsed at Tier-1, so the ingest completes and the response says what is missing. + * + *

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 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 excludeDirs, boolean coarse, + List 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 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 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)); + } +} diff --git a/ac-code-server/src/main/resources/application.properties b/ac-code-server/src/main/resources/application.properties index 71b61cf..fdd21af 100644 --- a/ac-code-server/src/main/resources/application.properties +++ b/ac-code-server/src/main/resources/application.properties @@ -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= 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 diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/BindingsIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/BindingsIT.java new file mode 100644 index 0000000..d1426a5 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/BindingsIT.java @@ -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 }, 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 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 { + constructor(parent?: Fields, name?: string, index?: number) {} + get(): string { return '' } + } + export class BrokerFields extends Fields { + constructor(parent?: Fields, name?: string, index?: number) { super(parent, name, index); } + vermnr = new Fields(this, "vermnr"); + ebene = new Fields(this, "ebene"); + } + export class AgstammUseCaseFields extends Fields { + constructor(parent?: Fields, name?: string, index?: number) { super(parent, name, index); } + brokerName = new Fields(this, "brokerName"); + broker = new BrokerFields(this, "broker"); + brokerList = (index?: number) => new BrokerFields(this, "brokerList", index); + } + export const AgstammUseCaseField: AgstammUseCaseFields = new AgstammUseCaseFields(); + """); + write(src.resolve("components"), "Smart.tsx", """ + import { Fields } from 'generated/api-interfaces' + export function SmartInput(props: { field: Fields }) { return } + export function SmartOutput(props: { field: Fields }) { return } + export function HealthTable(props: { fieldTermForRowData: Fields; columns: { field: Fields }[] }) { return } + """); + 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 }) { + return ( +
+ + +
+ ) + } + """); + 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 ( +
+ + + + +
+ ) + } + """); + + 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")); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CounterpartsIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CounterpartsIT.java new file mode 100644 index 0000000..fcd8244 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/CounterpartsIT.java @@ -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(url: string): Promise { return Promise.reject(url) } + export function executePostRequest(url: string, body: B): Promise { 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 { + 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 { get: () => Promise; } + interface GetMethodWithParameters { get: (params: P) => Promise; } + interface PostMethod { post: (body: B) => Promise; } + + export class AgstammControllerEndpoint implements Endpoint { + baseUrl: string = '/agstamm/ui/'; + public getBroker: GetMethodWithParameters, { vermnr: number }> = { + get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}${COMMON.encodePathParams(params.vermnr)}`)), + }; + public searchBroker: GetMethodWithParameters, { vermnr: number }> = { + get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${COMMON.encodePathParams(params.vermnr)}`)), + }; + public saveBroker: PostMethod, API.AgstammUseCase> = { + post: (body) => COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body), + }; + public ping: GetMethod = { + 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")); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/FunctionCallersCrossModuleIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/FunctionCallersCrossModuleIT.java new file mode 100644 index 0000000..e3b14c1 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/FunctionCallersCrossModuleIT.java @@ -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
{format(' a ')}
}\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)); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StaleParsedEdgeReapIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StaleParsedEdgeReapIT.java new file mode 100644 index 0000000..384946c --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StaleParsedEdgeReapIT.java @@ -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")); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StoreIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StoreIT.java new file mode 100644 index 0000000..d77e1f1 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StoreIT.java @@ -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(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 = (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
{requestStatus} {purMode} {String(loading)}
+ } + """); + + 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)); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java new file mode 100644 index 0000000..05042c8 --- /dev/null +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/StylesIT.java @@ -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 ( + + x + + + ) + } + """); + 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")); + } +} diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/service/SourceFilesTest.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/service/SourceFilesTest.java index 568a765..be7ed2c 100644 --- a/ac-code-server/src/test/java/com/agenticcode/codeserver/service/SourceFilesTest.java +++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/service/SourceFilesTest.java @@ -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"))); diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Binding.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Binding.java new file mode 100644 index 0000000..e29fca1 --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Binding.java @@ -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) { +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Counterpart.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Counterpart.java new file mode 100644 index 0000000..2cef134 --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/Counterpart.java @@ -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) { +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java index 190a0f3..415fbd3 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java @@ -157,6 +157,26 @@ public final class CypherQueries { * {@code (project, ownerModule)} would go further still, but has to earn its write-side cost * against this 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 . (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) — keep the two in step. 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 generated 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 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 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..} — onto the real node of the same type and name (the slice + * file declares {@code .} 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 buildResolveStorePlaceholderQueries() { + Map 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 buildResolveBindingPlaceholderQueries() { + Map 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; diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/DataStructureField.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/DataStructureField.java index ad3d3b7..119f769 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/DataStructureField.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/DataStructureField.java @@ -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) { } diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java index 84a5fdc..3090554 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/GraphRepository.java @@ -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. - * - *

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 enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) { - String sfx = scoped ? "-scoped" : ""; - List 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 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 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. + * + *

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 enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) { + String sfx = scoped ? "-scoped" : ""; + List 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 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> 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 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 finalizeProjectScoped(String project, List moduleNames) { - Map 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 excludeDirs = record.get("excludeDirs").isNull() + ? List.of() + : record.get("excludeDirs").asList(value -> value.asString()); + List 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 finalizeProjectScoped(String project, List moduleNames) { + Map 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 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 linkCounterparts(String project) { + Map params = new HashMap<>(); + params.put("project", project); + return read(CypherQueries.COUNTERPART_HOLDERS, params, record -> record.get("name").asString()) + .chain(holders -> { + Uni 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> storeSlices(String project, @Nullable String slice) { + Map params = new HashMap<>(); + params.put("project", project); + params.put("slice", slice); + return read(CypherQueries.STORE_SLICES, params, record -> { + List 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> storeAccessesPage(String project, String slice, @Nullable String field, @Nullable String mode, + @Nullable String module, int limit, int offset) { + Map 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> themeTokens(String project, boolean unused) { + Map 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.} name) is read. + */ + public Uni> themeUsages(String project, String token) { + Map 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> stylesPage(String project, @Nullable String module, @Nullable String kind, boolean withLiterals, + int limit, int offset) { + Map 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> bindingsPage(String project, @Nullable String dto, @Nullable String field, @Nullable String mode, + @Nullable String module, @Nullable Boolean partial, int limit, int offset) { + Map 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> counterpartsPage(String project, @Nullable String module, @Nullable String kind, + boolean unmatched, int limit, int offset) { + Map 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 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 createProject(String name, @Nullable String description, + String root, List excludeDirs, + @Nullable String language, @Nullable String generatedDir, + @Nullable String userExitDir, List 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 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 updateProject(String name, @Nullable String description, + @Nullable String root, @Nullable List excludeDirs, + @Nullable String language, @Nullable String generatedDir, + @Nullable String userExitDir, @Nullable List 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. diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ProjectInfo.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ProjectInfo.java index 1ba4750..caf76b6 100644 --- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ProjectInfo.java +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ProjectInfo.java @@ -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}). * + *

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). + * *

{@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 excludeDirs, @Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir, - @Nullable ProjectIngestInfo ingest) { + @Nullable ProjectIngestInfo ingest, List counterparts) { + + /** + * Pre-item-193 shape: no counterparts. + */ + public ProjectInfo(String name, @Nullable String description, String root, List 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 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 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()); } } diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreAccess.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreAccess.java new file mode 100644 index 0000000..5d9411b --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreAccess.java @@ -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) { +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreSlice.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreSlice.java new file mode 100644 index 0000000..57e4045 --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StoreSlice.java @@ -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.}), {@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 fields, int reducers, int reads, int writes) { + + /** + * A top-level key of the slice state ({@code state..}). + */ + public record Field(String name, @Nullable String type, boolean optional, int reads, int writes) { + } +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StyleBlock.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StyleBlock.java new file mode 100644 index 0000000..7cc55fb --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/StyleBlock.java @@ -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 tokens) { +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeToken.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeToken.java new file mode 100644 index 0000000..d679bf4 --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeToken.java @@ -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) { +} diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeUsage.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeUsage.java new file mode 100644 index 0000000..c41d5c8 --- /dev/null +++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/ThemeUsage.java @@ -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) { +} diff --git a/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/EdgeType.java b/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/EdgeType.java index 61ee2ea..03243b6 100644 --- a/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/EdgeType.java +++ b/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/EdgeType.java @@ -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 } diff --git a/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/NodeType.java b/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/NodeType.java index 37c2d7e..b92bf1f 100644 --- a/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/NodeType.java +++ b/ac-parser-core/src/main/java/com/agenticcode/parsercore/ast/model/NodeType.java @@ -49,5 +49,13 @@ public enum NodeType { *

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 } diff --git a/ac-parser-typescript/pom.xml b/ac-parser-typescript/pom.xml new file mode 100644 index 0000000..109daa8 --- /dev/null +++ b/ac-parser-typescript/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + com.agenticcode + agenticcode + 1.0.0-SNAPSHOT + + + ac-parser-typescript + jar + + ac-parser-typescript + TypeScript/React source parser: Tier-1 regex coarse scan in Java, Tier-2 facts from the Node sidecar + (roadmap item 192) + + + + + com.agenticcode + ac-parser-core + + + org.jspecify + jspecify + + + + com.fasterxml.jackson.core + jackson-databind + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + com.agenticcode + ac-mvn-plugins + + + + diff --git a/ac-parser-typescript/sidecar/.gitignore b/ac-parser-typescript/sidecar/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/ac-parser-typescript/sidecar/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/ac-parser-typescript/sidecar/extract.mjs b/ac-parser-typescript/sidecar/extract.mjs new file mode 100644 index 0000000..0a4d425 --- /dev/null +++ b/ac-parser-typescript/sidecar/extract.mjs @@ -0,0 +1,1536 @@ +// AgenticCode TypeScript sidecar (roadmap item 192). +// +// Usage: node extract.mjs --root --workspace

[--files ] +// +// Loads one workspace as a whole TypeScript program (its tsconfig.json, or every .ts/.tsx under it +// when there is none), resolves every identifier through the type checker, and prints one JSON +// document to stdout — the FACTS CONTRACT read by TypeScriptFactsReader.java. Nothing is written to +// disk (noEmit, no incremental): the project root is mounted read-only in the container. +// +// { +// "version": 4, "workspace": "pur-r-vstamm", "files": { +// "": { +// "imports": [{ "specifier", "resolved": "|null", "package": "|null", "line", "names" }], +// "declarations": [{ "name", "kind", "exported", "startLine", "endLine", +// "members": [{ "name", "type", "optional", "line" }] (interface / type-literal alias only) }], +// "endpoints": [{ "name", "owner", "member", "httpMethod", "generator": "typescript-generator|hey-api", +// "backend", "url", "requestType", "responseType", "paramsType", "line" }] (item 193), +// "calls": [{ "fromDecl", "expression", "symbol", "file": "|null", "decl", "package", +// "receiver": "|null", "member": "|null", +// "actionType": "/ | | null" (item 194), "line", "kind" }], +// "slices": [{ "name": "", "sliceName", "stateType", "exported", "startLine", "endLine", (item 194) +// "fields": [{ "name", "type", "optional", "line" }] (the top-level keys of the state), +// "reducers": [{ "name": "", "kind": "reducer|case|matcher|default", "startLine", "endLine", +// "trigger": { "expression", "file", "decl" } | null (case/matcher: what fires it), +// "accesses": [{ "mode": "read|write", "path": ["key", "sub", ...], "line" }] }] }], +// "store": { "line", "keys": [{ "key", "sliceFile": "|null", "sliceName": "|null" }] } | null, +// "stateAccesses": [{ "fromDecl", "path": ["key", "field", ...], "line", "via": "useAppSelector||getState" }], +// "bindings": [{ "fromDecl", "kind": "field|prefix", "rootDto", "ownerDto", "ownerFile": "", "field", "path", +// "partial", "component": "|null", "attribute": "", "line" }], (item 195) +// "themeTokens": [{ "token": "palette.primary.dark" | "PRIMARY", "kind": "path|constant", "value", "constant", "line" }] (item 196, theme file only), +// "styles": [{ "fromDecl", "styleKind": "sx|style|styled", "element", "line", "col", "properties": [..], "literals": [..], +// "dynamic", "spread", "tokens": [{ "token", "property", "line" }] }] (item 196), +// "tokenRefs": [{ "fromDecl", "token", "context": "|code", "line" }] (item 196: theme tokens used outside style blocks) +// } +// } +// } +// "decl" is the top-level declaration in "file" that owns the callee (a function, a class or interface +// whose member is called, ...) or null when the callee is a local binding; "fromDecl" is the top-level +// declaration containing the call site ("" at module level). Calls into npm packages and the TypeScript +// lib carry "package" ("lib" for the language library) and no file. +// Item 194 (Redux store): "slices" describes every createSlice in the file — its state keys (from the +// checker's type of initialState), its case reducers (`reducers: {}`) and its extra reducers +// (`builder.addCase/addMatcher/addDefaultCase`), each with the state paths it reads and writes, rooted +// at the reducer's state parameter. "store" is the configureStore of the file: reducer key -> the slice +// it mounts (resolved through `.reducer` back to the createSlice). "stateAccesses" are the store reads +// of the file: selector arrows of useSelector/useAppSelector, of per-slice wrapper hooks that apply a +// selector argument to a fixed sub-path, and `X.getState()` chains — rooted at the store, so the first +// element is the reducer key. +// Item 195 (DTO field bindings): "bindings" are the uses of the generated `Fields` path objects +// (`AgstammUseCaseField.broker.ebene` on a ``, a `Fields`-typed prop, a call +// argument). The checker types every hop as `XFields`: rootDto = TRoot, ownerDto = the +// TSelf of the hop before the leaf, field = the leaf's name, path = the dotted hops from the root +// (`list[]` for an indexed list hop). kind=field when the leaf is a scalar (its TSelf is no project +// DTO), kind=prefix when a whole sub-object is handed on. partial=true when the root is not a +// generated `*Field` constant (a prop, a local), so only the tail of the path is known. +// Item 196 (styling): "themeTokens" are the leaves of the createTheme({..}) literal (dotted path, value +// folded through constants) plus the file's exported string constants; "styles" is every sx={..}, +// style={..} and styled(X)(..) block with its CSS keys (nested selectors flattened as `&:hover.color`), +// hard-coded colour/length literals, and the theme tokens it reads (a chain on a `theme` value or a +// theme constant) with the key they feed; "tokenRefs" are theme tokens read outside style blocks. +// A "module name" is the root-relative path without extension — the graph identity the Java side +// uses (TypeScriptModuleNames). Diagnostics go to stderr; a non-zero exit means "no facts". +import ts from 'typescript'; +import path from 'node:path'; +import fs from 'node:fs'; + +const HTTP_METHODS = /^(get|post|put|delete|patch|head|options)$/; +const MUTATORS = new Set(['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin', 'set', 'delete', 'clear', 'add']); +const SELECTOR_HOOKS = /^use(App)?Selector$/; +const wrapperBases = new Map(); +const bindingSeen = new WeakSet(); +const LITERAL_PARTS = /#[0-9a-fA-F]{3,8}|-?\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|pt)|rgba?\([^)]*\)|hsla?\([^)]*\)|calc\([^)]*\)/g; +const themeFiles = new Set(); + +const args = parseArgs(process.argv.slice(2)); +const root = path.resolve(args.root); +const workspaceDir = path.resolve(root, args.workspace); +const only = args.files ? new Set(args.files.split(',').map((f) => path.resolve(root, f))) : null; + +const {fileNames, options} = loadConfig(workspaceDir); +const program = ts.createProgram({ + rootNames: fileNames, + options: { + ...options, + noEmit: true, + incremental: false, + composite: false, + tsBuildInfoFile: undefined, + skipLibCheck: true + }, +}); +const checker = program.getTypeChecker(); + +for (const sf of program.getSourceFiles()) { + if (!sf.isDeclarationFile && createThemeCalls(sf).length) themeFiles.add(path.resolve(sf.fileName)); +} +const files = {}; +for (const sf of program.getSourceFiles()) { + if (sf.isDeclarationFile) continue; + const abs = path.resolve(sf.fileName); + if (!abs.startsWith(workspaceDir + path.sep)) continue; + if (abs.includes(`${path.sep}node_modules${path.sep}`)) continue; + if (only && !only.has(abs)) continue; + files[relative(abs)] = extractFile(sf); +} +process.stdout.write(JSON.stringify({version: 4, workspace: args.workspace, files})); + +// --------------------------------------------------------------------------------------------- + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) out[argv[i].slice(2)] = argv[i + 1], i++; + } + if (!out.root || !out.workspace) { + process.stderr.write('usage: extract.mjs --root --workspace [--files a,b]\n'); + process.exit(2); + } + return out; +} + +function loadConfig(dir) { + const cfgPath = ts.findConfigFile(dir, ts.sys.fileExists, 'tsconfig.json'); + if (cfgPath && path.resolve(cfgPath).startsWith(dir)) { + const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile); + if (cfg.error) fail(ts.flattenDiagnosticMessageText(cfg.error.messageText, '\n')); + const parsed = ts.parseJsonConfigFileContent(cfg.config, ts.sys, path.dirname(cfgPath)); + return {fileNames: parsed.fileNames, options: parsed.options}; + } + // No tsconfig (fixtures, ad-hoc trees): every .ts/.tsx under the workspace, frontend-like options. + const fileNames = []; + walk(dir, fileNames); + const srcDir = fs.existsSync(path.join(dir, 'src')) ? path.join(dir, 'src') : dir; + return { + fileNames, + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.ReactJSX, + baseUrl: srcDir, + strict: false, + allowJs: false, + noResolve: false, + }, + }; +} + +function walk(dir, out) { + for (const e of fs.readdirSync(dir, {withFileTypes: true})) { + if (e.name === 'node_modules' || e.name === 'dist') continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p, out); + else if (/\.(ts|tsx)$/.test(e.name) && !e.name.endsWith('.d.ts')) out.push(p); + } +} + +function fail(msg) { + process.stderr.write(`ac-ts-sidecar: ${msg}\n`); + process.exit(1); +} + +function relative(abs) { + return path.relative(root, abs).split(path.sep).join('/'); +} + +// A workspace consumed through its package.json `exports`/`types` resolves into its build output +// (`pur-ui-common/dist/x.d.ts`); the graph identity is the source twin when one exists +// (`pur-ui-common/src/x.ts`), because `dist` is excluded from the walk and never a module. +function canonical(abs) { + const m = abs.match(/^(.*)[\\/]dist[\\/](.*?)(\.d\.ts|\.d\.mts|\.js|\.mjs|\.ts|\.tsx)$/); + if (!m) return abs; + for (const ext of ['.ts', '.tsx']) { + const src = path.join(m[1], 'src', m[2] + ext); + if (fs.existsSync(src)) return src; + } + return abs; +} + +function moduleName(abs) { + return relative(canonical(abs)).replace(/\.(tsx|ts|jsx|js|mjs|d\.ts|d\.mts)$/, ''); +} + +function packageOf(abs) { + if (/node_modules[\\/]typescript[\\/]lib[\\/]lib\./.test(abs)) return 'lib'; + const m = abs.match(/node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/); + return m ? m[1] : null; +} + +function line(sf, pos) { + return sf.getLineAndCharacterOfPosition(pos).line + 1; +} + +// --- per file --------------------------------------------------------------------------------- + +function extractFile(sf) { + const exportedNames = collectExportedNames(sf); + const declarations = []; + const imports = []; + for (const st of sf.statements) { + if ((ts.isImportDeclaration(st) || ts.isExportDeclaration(st)) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) { + imports.push(importFact(sf, st, st.moduleSpecifier.text)); + } + for (const d of topLevelDeclarations(st)) { + declarations.push({ + ...d, exported: d.exported || exportedNames.has(d.name), + startLine: line(sf, d.node.getStart(sf)), endLine: line(sf, d.node.getEnd()), + members: membersOf(sf, d.node), node: undefined + }); + } + } + const endpoints = endpointFacts(sf); + const slices = sliceFacts(sf); + const store = storeFacts(sf); + const stateAccesses = []; + const bindings = []; + const styles = []; + const tokenRefs = []; + const themeTokens = themeTokenFacts(sf); + const getStateAliases = getStateAliasSymbols(sf); + const calls = []; + const visit = (n) => { + if (ts.isJsxAttribute(n) && n.initializer && ts.isJsxExpression(n.initializer) && n.initializer.expression + && (n.name.getText(sf) === 'sx' || n.name.getText(sf) === 'style')) { + styleFacts(sf, n.name.getText(sf), n.initializer.expression, n.parent.parent, n, styles); + } else if (ts.isCallExpression(n) && ts.isCallExpression(n.expression) && calleeHead(n) === 'styled' && n.arguments.length) { + styleFacts(sf, 'styled', n.arguments[0], null, n, styles); + } else if (ts.isIdentifier(n) && !themeFiles.has(path.resolve(sf.fileName)) && !insideStyleBlock(n)) { + const token = themeTokenOf(sf, n); + if (token) tokenRefs.push({ + fromDecl: enclosingTopLevelName(sf, n), + token: token.token, + context: jsxAttributeContext(sf, n), + line: line(sf, n.getStart(sf)) + }); + } + if (ts.isJsxAttribute(n) && n.initializer && ts.isJsxExpression(n.initializer) && n.initializer.expression) { + bindingFacts(sf, n.initializer.expression, n, bindings); + } else if (ts.isPropertyAssignment(n) && !ts.isJsxAttribute(n.parent)) { + bindingFacts(sf, n.initializer, n, bindings); + } else if (ts.isCallExpression(n)) { + for (const arg of n.arguments) bindingFacts(sf, arg, n, bindings); + } + if (ts.isImportCall(n) && n.arguments.length && ts.isStringLiteral(n.arguments[0])) { + imports.push(importFact(sf, n, n.arguments[0].text, 'import()')); + } else if (ts.isCallExpression(n)) { + calls.push(callFact(sf, n, n.expression, 'call')); + selectorAccesses(sf, n, stateAccesses); + getStateAccesses(sf, n, stateAccesses); + } else if (ts.isIdentifier(n) && getStateAliases.size && getStateAliases.has(checker.getSymbolAtLocation(n)) + && !(ts.isVariableDeclaration(n.parent) && n.parent.name === n)) { + const chain = chainFrom(n); + stateAccesses.push({ + fromDecl: enclosingTopLevelName(sf, n), + path: chain.path, + line: line(sf, n.getStart(sf)), + via: 'getState' + }); + } else if (ts.isNewExpression(n)) { + calls.push(callFact(sf, n, n.expression, 'new')); + } else if (ts.isTaggedTemplateExpression(n)) { + calls.push(callFact(sf, n, n.tag, 'tagged')); + } else if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) { + if (!ts.isIdentifier(n.tagName) || /^[A-Z]/.test(n.tagName.text) || ts.isPropertyAccessExpression(n.tagName)) { + calls.push(callFact(sf, n, n.tagName, 'jsx')); + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return { + imports, + declarations, + calls: calls.filter(Boolean), + endpoints, + slices, + store, + stateAccesses, + bindings, + styles, + tokenRefs, + themeTokens + }; +} + +function importFact(sf, node, specifier, names) { + const r = ts.resolveModuleName(specifier, sf.fileName, program.getCompilerOptions(), ts.sys).resolvedModule; + let resolved = null, pkg = null; + if (r) { + if (r.isExternalLibraryImport || r.resolvedFileName.includes('/node_modules/')) pkg = packageOf(r.resolvedFileName) ?? specifier; + else resolved = moduleName(path.resolve(r.resolvedFileName)); + } else if (specifier.startsWith('@') || !/^[./#]/.test(specifier) && !ts.sys.fileExists(specifier)) { + pkg = null; // unresolved and not obviously a package: leave both null, the Java side keeps its Tier-1 guess + } + const clause = names ?? (ts.isImportDeclaration(node) && node.importClause ? node.importClause.getText(sf) + : ts.isExportDeclaration(node) && node.exportClause ? node.exportClause.getText(sf) : ''); + return {specifier, resolved, package: pkg, line: line(sf, node.getStart(sf)), names: clause.replace(/\s+/g, ' ')}; +} + +function collectExportedNames(sf) { + const names = new Set(); + for (const st of sf.statements) { + if (ts.isExportAssignment(st) && ts.isIdentifier(st.expression)) names.add(st.expression.text); + if (ts.isExportDeclaration(st) && !st.moduleSpecifier && st.exportClause && ts.isNamedExports(st.exportClause)) { + for (const e of st.exportClause.elements) names.add((e.propertyName ?? e.name).text); + } + } + return names; +} + +function isExported(node) { + return !!(ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export); +} + +function topLevelDeclarations(st) { + const out = []; + if (ts.isFunctionDeclaration(st)) { + const name = st.name ? st.name.text : 'default'; + out.push({node: st, name, kind: functionKind(name, st), exported: isExported(st)}); + } else if (ts.isClassDeclaration(st) && st.name) { + out.push({node: st, name: st.name.text, kind: 'class', exported: isExported(st)}); + } else if (ts.isInterfaceDeclaration(st)) { + out.push({node: st, name: st.name.text, kind: 'interface', exported: isExported(st)}); + } else if (ts.isTypeAliasDeclaration(st)) { + out.push({node: st, name: st.name.text, kind: 'type', exported: isExported(st)}); + } else if (ts.isEnumDeclaration(st)) { + out.push({node: st, name: st.name.text, kind: 'enum', exported: isExported(st)}); + } else if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (!ts.isIdentifier(d.name)) continue; + const kind = initializerKind(d.name.text, d.initializer); + out.push({node: d, name: d.name.text, kind, exported: isExported(st)}); + } + } + return out; +} + +function functionKind(name, fn) { + if (/^use[A-Z]/.test(name)) return 'hook'; + if (/^[A-Z]/.test(name) && containsJsx(fn)) return 'component'; + return 'function'; +} + +function initializerKind(name, init) { + if (!init) return 'const'; + if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) return functionKind(name, init); + if (ts.isCallExpression(init)) { + const head = calleeHead(init); + if (/^create(App)?AsyncThunk$/.test(head)) return 'thunk'; + if (/^create(App)?Slice$/.test(head)) return 'slice'; + if (head === 'styled') return 'styled'; + if (/^(React\.)?(memo|forwardRef|lazy)$/.test(head)) return 'component'; + } + if (ts.isCallExpression(init) || ts.isNewExpression(init) || ts.isObjectLiteralExpression(init)) return 'const'; + return 'const'; +} + +// `styled(Box)(...)` -> 'styled'; `React.memo(X)` -> 'React.memo'; `createAppAsyncThunk<..>(..)` -> its name. +function calleeHead(call) { + let e = call.expression; + while (ts.isCallExpression(e)) e = e.expression; + return e.getText(); +} + +function containsJsx(node) { + let found = false; + const visit = (n) => { + if (found) return; + if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { + found = true; + return; + } + ts.forEachChild(n, visit); + }; + visit(node); + return found; +} + +function enclosingTopLevelName(sf, node) { + let n = node; + while (n.parent && !ts.isSourceFile(n.parent)) n = n.parent; + if (ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) return n.name ? n.name.text : 'default'; + if (ts.isVariableStatement(n)) { + for (const d of n.declarationList.declarations) { + if (ts.isIdentifier(d.name) && d.pos <= node.pos && node.end <= d.end) return d.name.text; + } + const first = n.declarationList.declarations[0]; + return first && ts.isIdentifier(first.name) ? first.name.text : ''; + } + if (ts.isExportAssignment(n)) return 'default'; + return ''; +} + +// The top-level declaration that owns `decl`: the declaration itself when it is top level, the class +// or interface when `decl` is one of its members, else null (a local binding, a parameter, ...). +function owningTopLevelName(decl) { + const p = decl.parent; + if (!p) return null; + if (ts.isSourceFile(p)) return decl.name && ts.isIdentifier(decl.name) ? decl.name.text : 'default'; + if (ts.isVariableDeclarationList(p) && p.parent && ts.isVariableStatement(p.parent) && ts.isSourceFile(p.parent.parent)) { + return ts.isIdentifier(decl.name) ? decl.name.text : null; + } + if ((ts.isClassDeclaration(p) || ts.isInterfaceDeclaration(p) || ts.isEnumDeclaration(p)) && p.parent && ts.isSourceFile(p.parent)) { + return p.name ? p.name.text : 'default'; + } + if (ts.isTypeLiteralNode(p) && p.parent && ts.isTypeAliasDeclaration(p.parent) && ts.isSourceFile(p.parent.parent)) { + return p.parent.name.text; + } + return null; +} + +function callFact(sf, node, callee, kind) { + let symbol = checker.getSymbolAtLocation(callee); + if (!symbol && ts.isPropertyAccessExpression(callee)) symbol = checker.getSymbolAtLocation(callee.name); + if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol); + const decl = symbol && symbol.declarations && symbol.declarations[0]; + if (!decl) symbol = null; // the checker's `unknown` symbol for an unresolved name + let file = null, pkg = null, declName = null, actionType = null; + if (decl) { + const dsf = decl.getSourceFile(); + const abs = path.resolve(dsf.fileName); + if (abs.includes(`${path.sep}node_modules${path.sep}`) || dsf.isDeclarationFile && !abs.startsWith(root + path.sep)) { + pkg = packageOf(abs) ?? 'lib'; + } else { + file = moduleName(abs); + declName = owningTopLevelName(decl); + // Item 194: `dispatch(updateX(..))` — the callee is a binding of `.actions`, owned by the + // slice const; `dispatch(loadX(..))` — the callee is a thunk with its type prefix. + const action = actionOf(decl); + if (action) { + actionType = action.actionType; + if (action.slice) { + declName = action.slice.name.text; + file = moduleName(path.resolve(action.slice.getSourceFile().fileName)); + } + } + } + } + let receiver = null, member = null; + if (ts.isPropertyAccessExpression(callee)) { + const chain = []; + let e = callee; + while (ts.isPropertyAccessExpression(e)) { + chain.unshift(e.name.text); + e = e.expression; + } + const t = checker.getTypeAtLocation(e); + const ts_ = t && (t.symbol ?? t.aliasSymbol); + receiver = ts_ ? ts_.getName() : null; + if (receiver === '__type' || receiver === '__object') receiver = null; + member = chain.join('.'); + } + return { + fromDecl: enclosingTopLevelName(sf, node), + expression: callee.getText(sf).replace(/\s+/g, ' '), + symbol: symbol ? symbol.getName() : null, + file, decl: declName, package: pkg, receiver, member, actionType, + line: line(sf, node.getStart(sf)), + kind, + }; +} + +// --- item 193: interface members and generated web-service endpoints ---------------------------- + +// Property members of an interface or of a type-literal alias: the DTO shape the frontend binds to. +function membersOf(sf, node) { + let members = null; + if (ts.isInterfaceDeclaration(node)) members = node.members; + else if (ts.isTypeAliasDeclaration(node) && ts.isTypeLiteralNode(node.type)) members = node.type.members; + if (!members) return []; + const out = []; + for (const m of members) { + if (!ts.isPropertySignature(m) || !m.name) continue; + out.push({ + name: m.name.getText(sf), type: m.type ? m.type.getText(sf).replace(/\s+/g, ' ') : 'any', + optional: !!m.questionToken, line: line(sf, m.getStart(sf)) + }); + } + return out; +} + +function endpointFacts(sf) { + const out = []; + for (const st of sf.statements) { + if (ts.isClassDeclaration(st) && st.name) legacyEndpoints(sf, st, out); + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (ts.isIdentifier(d.name) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) { + heyApiEndpoint(sf, d.name.text, d.initializer, out); + } + } + } + } + return out; +} + +// Legacy generator (EndpointGenerator in Pur-Devtools): `class XEndpoint { baseUrl = '/x/'; member: T = { get: (params) => COMMON.buildPurURL(`${this.baseUrl}...`) } }`. +function legacyEndpoints(sf, cls, out) { + const base = cls.members.find((m) => ts.isPropertyDeclaration(m) && m.name.getText(sf) === 'baseUrl' + && m.initializer && (ts.isStringLiteral(m.initializer) || ts.isNoSubstitutionTemplateLiteral(m.initializer))); + if (!base) return; + const baseUrl = base.initializer.text; + for (const m of cls.members) { + if (!ts.isPropertyDeclaration(m) || !m.initializer || !ts.isObjectLiteralExpression(m.initializer)) continue; + for (const p of m.initializer.properties) { + const key = p.name ? p.name.getText(sf) : ''; + if (!HTTP_METHODS.test(key)) continue; + const body = ts.isPropertyAssignment(p) ? p.initializer : p; + const built = findUrlBuild(sf, body, baseUrl); + const types = memberTypes(sf, m.type, key); + out.push({ + name: `${cls.name.text}.${m.name.getText(sf)}`, + owner: cls.name.text, + member: m.name.getText(sf), + httpMethod: key.toUpperCase(), + generator: 'typescript-generator', + backend: built.backend, + url: built.url, + requestType: types.request, + responseType: types.response, + paramsType: types.params, + line: line(sf, m.getStart(sf)) + }); + } + } +} + +// `COMMON.buildPurRVstammURL(`${this.baseUrl}search?vermnr=${encodePathParams(params.vermnr)}`)` +// -> backend 'pur-r-vstamm', url '/agstamm/ui/search?vermnr={vermnr}'. `buildURL(params.backendType, ...)` -> 'dynamic'. +function findUrlBuild(sf, node, baseUrl) { + let found = null; + const visit = (n) => { + if (found) return; + if (ts.isCallExpression(n)) { + const callee = n.expression.getText(sf); + const m = callee.match(/(?:^|\.)build(\w*)URL$/); + if (m) { + const middle = m[1]; + const dynamic = middle === ''; + const arg = dynamic ? n.arguments[1] : n.arguments[0]; + found = { + backend: dynamic ? 'dynamic' : middle.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase(), + url: arg ? templateText(sf, arg, baseUrl) : baseUrl + }; + return; + } + } + ts.forEachChild(n, visit); + }; + visit(node); + return found ?? {backend: null, url: baseUrl}; +} + +function templateText(sf, expr, baseUrl) { + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; + if (ts.isTemplateExpression(expr)) { + return expr.head.text + expr.templateSpans.map((sp) => substitution(sf, sp.expression, baseUrl) + sp.literal.text).join(''); + } + return substitution(sf, expr, baseUrl); +} + +function substitution(sf, expr, baseUrl) { + const text = expr.getText(sf).replace(/\s+/g, ''); + if (text === 'this.baseUrl') return baseUrl; + let e = expr; + while (ts.isCallExpression(e) && e.arguments.length) e = e.arguments[0]; // encodeURIComponent(x), COMMON.encodePathParams(x) + if (ts.isPropertyAccessExpression(e)) return `{${e.name.text}}`; + if (ts.isIdentifier(e)) return `{${e.text}}`; + return '{' + e.getText(sf).replace(/\s+/g, '') + '}'; +} + +// `GetMethodWithParameters` / `PostMethod` / `PostMethodWithParameters` / `{ get: () => Promise }`. +function memberTypes(sf, typeNode, key) { + const none = {response: null, request: null, params: null}; + if (!typeNode) return none; + const text = (t) => t.getText(sf).replace(/\s+/g, ' '); + if (ts.isTypeReferenceNode(typeNode) && typeNode.typeArguments && typeNode.typeArguments.length) { + const args = typeNode.typeArguments.map(text); + const withParams = /WithParameters$/.test(typeNode.typeName.getText(sf)); + const isBody = key !== 'get' && key !== 'head' && key !== 'options'; + return { + response: args[0] ?? null, params: withParams ? args[1] ?? null : null, + request: isBody ? args[args.length - 1] ?? null : null + }; + } + if (ts.isTypeLiteralNode(typeNode)) { + for (const m of typeNode.members) { + if (ts.isPropertySignature(m) && m.type && ts.isFunctionTypeNode(m.type)) { + const ret = m.type.type; + const response = ts.isTypeReferenceNode(ret) && ret.typeArguments && ret.typeArguments.length ? text(ret.typeArguments[0]) : text(ret); + const request = m.type.parameters.length ? (m.type.parameters[m.type.parameters.length - 1].type ? text(m.type.parameters[m.type.parameters.length - 1].type) : null) : null; + return {response, request: key === 'get' ? null : request, params: null}; + } + } + } + return none; +} + +// hey-api (@hey-api/openapi-ts sdk.gen.ts): `export const getX = (options) => (options.client ?? client).get<...>({ url: '/x/{id}', ...options })`. +function heyApiEndpoint(sf, name, fn, out) { + let found = null; + const visit = (n) => { + if (found) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && HTTP_METHODS.test(n.expression.name.text) + && n.arguments.length && ts.isObjectLiteralExpression(n.arguments[0])) { + const urlProp = n.arguments[0].properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText(sf) === 'url' + && (ts.isStringLiteral(p.initializer) || ts.isNoSubstitutionTemplateLiteral(p.initializer))); + if (urlProp) { + found = {method: n.expression.name.text.toUpperCase(), url: urlProp.initializer.text}; + return; + } + } + ts.forEachChild(n, visit); + }; + visit(fn.body); + if (!found) return; + const firstTypeArg = (t) => t && ts.isTypeReferenceNode(t) && t.typeArguments && t.typeArguments.length ? t.typeArguments[0].getText(sf) : null; + const request = fn.parameters.length ? firstTypeArg(fn.parameters[0].type) : null; + const response = firstTypeArg(fn.type); + out.push({ + name, owner: null, member: null, httpMethod: found.method, generator: 'hey-api', backend: null, url: found.url, + requestType: request, responseType: response, paramsType: null, line: line(sf, fn.getStart(sf)) + }); +} + +// --- item 194: the Redux store — slices, the store, and state reads ------------------------------ + + +function unwrap(e) { + while (e && (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) + || ts.isTypeAssertionExpression(e) || ts.isSatisfiesExpression?.(e))) e = e.expression; + return e; +} + +// Follows import/export aliases one step at a time: `export default xSlice.reducer` is an alias whose +// target is an expression, so getAliasedSymbol() would jump past it to `unknown` — here it stops at +// the ExportAssignment, which sliceOfReducerExpression() then reads. +function resolveSymbol(node) { + let s = node && checker.getSymbolAtLocation(node); + for (let i = 0; s && s.flags & ts.SymbolFlags.Alias && i < 10; i++) { + const next = checker.getImmediateAliasedSymbol(s); + if (!next || !next.declarations || !next.declarations.length) break; + s = next; + } + return s && s.declarations && s.declarations.length ? s : null; +} + +function declarationOf(node) { + const s = resolveSymbol(node); + return s ? s.declarations[0] : null; +} + +// A string literal, a template whose substitutions fold to literals, or an identifier declared with one. +function literalString(expr, depth = 0) { + expr = unwrap(expr); + if (!expr || depth > 5) return null; + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; + if (ts.isTemplateExpression(expr)) { + let out = expr.head.text; + for (const sp of expr.templateSpans) { + const v = literalString(sp.expression, depth + 1); + if (v === null) return null; + out += v + sp.literal.text; + } + return out; + } + if (ts.isIdentifier(expr) || ts.isPropertyAccessExpression(expr)) { + const d = declarationOf(ts.isIdentifier(expr) ? expr : expr.name); + if (d && ts.isVariableDeclaration(d) && d.initializer) return literalString(d.initializer, depth + 1); + if (d && ts.isEnumMember(d) && d.initializer) return literalString(d.initializer, depth + 1); + if (d && ts.isPropertyAssignment(d)) return literalString(d.initializer, depth + 1); + } + return null; +} + +function isCreateSlice(init) { + return init && ts.isCallExpression(init) && /^create(App)?Slice$/.test(calleeHead(init)); +} + +function isCreateThunk(init) { + return init && ts.isCallExpression(init) && /^create(App)?AsyncThunk$/.test(calleeHead(init)); +} + +function property(obj, name) { + if (!obj || !ts.isObjectLiteralExpression(obj)) return null; + for (const p of obj.properties) { + if (p.name && p.name.getText() === name) return p; + } + return null; +} + +function propertyValue(obj, name) { + const p = property(obj, name); + if (!p) return null; + if (ts.isPropertyAssignment(p)) return p.initializer; + if (ts.isShorthandPropertyAssignment(p)) return p.name; + if (ts.isMethodDeclaration(p)) return p; + return null; +} + +function sliceNameOf(createCall) { + const arg = createCall.arguments[0]; + return literalString(propertyValue(arg, 'name')); +} + +// The variable declaration `const xSlice = createSlice(..)` an expression refers to, or null. +function sliceDeclarationOf(expr) { + expr = unwrap(expr); + if (!expr || !ts.isIdentifier(expr)) return null; + const d = declarationOf(expr); + return d && ts.isVariableDeclaration(d) && ts.isIdentifier(d.name) && isCreateSlice(d.initializer) ? d : null; +} + +// `dispatch(updateX(..))`: updateX is a binding element of `const { updateX } = xSlice.actions` -> the +// slice declaration and the action type `/updateX`. A thunk const -> its type prefix. +function actionOf(decl) { + if (ts.isBindingElement(decl)) { + let p = decl.parent; + while (p && !ts.isVariableDeclaration(p)) p = p.parent; + const init = p && unwrap(p.initializer); + if (init && ts.isPropertyAccessExpression(init) && init.name.text === 'actions') { + const slice = sliceDeclarationOf(init.expression); + const name = slice && sliceNameOf(slice.initializer); + if (name) return {actionType: `${name}/${(decl.propertyName ?? decl.name).getText()}`, slice}; + } + return null; + } + if (ts.isVariableDeclaration(decl) && isCreateThunk(decl.initializer)) { + const prefix = literalString(decl.initializer.arguments[0]); + return prefix ? {actionType: prefix, slice: null} : null; + } + return null; +} + +function sliceFacts(sf) { + const out = []; + for (const st of sf.statements) { + if (!ts.isVariableStatement(st)) continue; + for (const d of st.declarationList.declarations) { + if (!ts.isIdentifier(d.name) || !isCreateSlice(d.initializer)) continue; + const arg = d.initializer.arguments[0]; + const sliceName = sliceNameOf(d.initializer) ?? d.name.text; + const init = propertyValue(arg, 'initialState'); + const type = init ? checker.getTypeAtLocation(init) : null; + const fields = []; + if (type) { + for (const p of type.getProperties()) { + const pd = p.declarations && p.declarations[0]; + fields.push({ + name: p.getName(), + type: checker.typeToString(checker.getTypeOfSymbolAtLocation(p, init), undefined, ts.TypeFormatFlags.NoTruncation).replace(/\s+/g, ' '), + optional: !!(p.flags & ts.SymbolFlags.Optional), + line: pd && pd.getSourceFile() === sf ? line(sf, pd.getStart(sf)) : line(sf, init.getStart(sf)) + }); + } + } + const reducers = []; + const reducersObj = propertyValue(arg, 'reducers'); + if (reducersObj && ts.isObjectLiteralExpression(reducersObj)) { + for (const p of reducersObj.properties) { + const fn = ts.isMethodDeclaration(p) ? p : ts.isPropertyAssignment(p) ? unwrap(p.initializer) : null; + if (!fn || !p.name || !(ts.isMethodDeclaration(fn) || ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) continue; + reducers.push(reducerFact(sf, `${sliceName}/${p.name.getText(sf)}`, 'reducer', null, fn, p)); + } + } + const extra = unwrap(propertyValue(arg, 'extraReducers')); + if (extra && (ts.isArrowFunction(extra) || ts.isFunctionExpression(extra))) { + const visit = (n) => { + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) + && /^add(Case|Matcher|DefaultCase)$/.test(n.expression.name.text)) { + const which = n.expression.name.text; + const kind = which === 'addCase' ? 'case' : which === 'addMatcher' ? 'matcher' : 'default'; + const fn = unwrap(n.arguments[kind === 'default' ? 0 : 1]); + const triggerExpr = kind === 'default' ? null : n.arguments[0]; + if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) { + const trigger = triggerExpr ? triggerFact(sf, triggerExpr) : null; + const name = kind === 'case' && trigger && trigger.actionType ? trigger.actionType + : `${sliceName}/${kind}:${trigger ? trigger.expression : 'default'}`; + reducers.push(reducerFact(sf, name, kind, trigger, fn, n)); + } + } + ts.forEachChild(n, visit); + }; + visit(extra.body); + } + out.push({ + name: d.name.text, + sliceName, + stateType: type ? checker.typeToString(type).replace(/\s+/g, ' ') : null, + exported: isExported(st), + startLine: line(sf, d.getStart(sf)), + endLine: line(sf, d.getEnd()), + fields, + reducers + }); + } + } + return out; +} + +// `loadX.fulfilled` -> the thunk's `/fulfilled`; `isSliceFulfilled(sliceName)` and other matchers keep their text. +function triggerFact(sf, expr) { + const e = unwrap(expr); + let actionType = null, file = null, decl = null; + if (ts.isPropertyAccessExpression(e)) { + const d = declarationOf(unwrap(e.expression)); + if (d && ts.isVariableDeclaration(d) && isCreateThunk(d.initializer)) { + const prefix = literalString(d.initializer.arguments[0]); + if (prefix) actionType = `${prefix}/${e.name.text}`; + file = moduleName(path.resolve(d.getSourceFile().fileName)); + decl = owningTopLevelName(d); + } else { + const bound = d && actionOf(d); + if (bound && ts.isBindingElement(d)) { + actionType = bound.actionType; + file = moduleName(path.resolve(bound.slice.getSourceFile().fileName)); + decl = bound.slice.name.text; + } + } + } else if (ts.isIdentifier(e)) { + const d = declarationOf(e); + const bound = d && actionOf(d); + if (bound) { + actionType = bound.actionType; + file = moduleName(path.resolve((bound.slice ?? d).getSourceFile().fileName)); + decl = bound.slice ? bound.slice.name.text : owningTopLevelName(d); + } + } + return {expression: expr.getText(sf).replace(/\s+/g, ' '), actionType, file, decl}; +} + +function reducerFact(sf, name, kind, trigger, fn, site) { + const param = fn.parameters[0]; + const stateSym = param && ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : null; + const accesses = stateSym ? stateAccessesIn(sf, fn.body, stateSym) : []; + return {name, kind, trigger, startLine: line(sf, site.getStart(sf)), endLine: line(sf, site.getEnd()), accesses}; +} + +// Every maximal `state.a.b[..]` chain in a reducer body, classified as read or write by its syntactic role. +function stateAccessesIn(sf, body, stateSym) { + const out = []; + const seen = new Set(); + const push = (mode, p, node) => { + const k = `${mode}|${p.join('.')}|${node.pos}`; + if (!seen.has(k)) { + seen.add(k); + out.push({mode, path: p, line: line(sf, node.getStart(sf))}); + } + }; + const visit = (n) => { + if (ts.isIdentifier(n) && checker.getSymbolAtLocation(n) === stateSym) { + const {path: p, top} = chainFrom(n); + const parent = top.parent; + let write = false, read = true; + if (ts.isBinaryExpression(parent) && parent.left === top && isAssignment(parent.operatorToken.kind)) { + write = true; + read = parent.operatorToken.kind !== ts.SyntaxKind.EqualsToken; + } else if ((ts.isPrefixUnaryExpression(parent) || ts.isPostfixUnaryExpression(parent)) + && (parent.operator === ts.SyntaxKind.PlusPlusToken || parent.operator === ts.SyntaxKind.MinusMinusToken)) { + write = true; + } else if (ts.isDeleteExpression(parent)) { + write = true; + read = false; + } else if (ts.isPropertyAccessExpression(parent) && parent.expression === top && MUTATORS.has(parent.name.text) + && ts.isCallExpression(parent.parent) && parent.parent.expression === parent) { + write = true; + } else if (ts.isCallExpression(parent) && parent.expression.getText(sf).replace(/\s+/g, '') === 'Object.assign' + && parent.arguments[0] === top) { + write = true; + } + if (read) push('read', p, n); + if (write) push('write', p, n); + return; + } + if (ts.isReturnStatement(n) && n.expression && isReducerTopLevelReturn(n, body)) { + const e = unwrap(n.expression); + if (ts.isObjectLiteralExpression(e)) { + for (const p of e.properties) { + if (p.name && !ts.isSpreadAssignment(p)) push('write', [p.name.getText(sf)], p); + } + } else { + push('write', [], n); + } + } + ts.forEachChild(n, visit); + }; + if (body) { + if (ts.isBlock(body)) visit(body); + else { // expression-bodied arrow: `(state) => ({ ...state, x: 1 })` replaces the state + const e = unwrap(body); + if (ts.isObjectLiteralExpression(e)) { + for (const p of e.properties) if (p.name && !ts.isSpreadAssignment(p)) push('write', [p.name.getText(sf)], p); + } else if (!ts.isCallExpression(e) || !containsIdentifier(e, stateSym)) { + // `() => initialState` replaces the state; `(state) => doSomething(state)` is only a read (visited below) + push('write', [], body); + } + visit(body); + } + } + return out; +} + +function isReducerTopLevelReturn(ret, body) { + let n = ret.parent; + while (n && n !== body) { + if (ts.isFunctionLike(n)) return false; // a return inside a nested callback + n = n.parent; + } + return true; +} + +function containsIdentifier(node, sym) { + let found = false; + const visit = (n) => { + if (found) return; + if (ts.isIdentifier(n) && checker.getSymbolAtLocation(n) === sym) { + found = true; + return; + } + ts.forEachChild(n, visit); + }; + visit(node); + return found; +} + +function isAssignment(kind) { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +// From a root identifier outwards: `state.a?.b['c'][i]` -> path [a, b, c] (a computed index ends the +// path), `top` = the outermost node of the chain (what the parent acts on). +function chainFrom(id) { + const p = []; + let top = id; + for (; ;) { + let parent = top.parent; + if (ts.isNonNullExpression(parent) || ts.isParenthesizedExpression(parent)) { + top = parent; + continue; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === top) { + // `state.messages.find(..)`: the method is not part of the state path — the chain ends at `messages` + if (ts.isCallExpression(parent.parent) && parent.parent.expression === parent) break; + p.push(parent.name.text); + top = parent; + continue; + } + if (ts.isElementAccessExpression(parent) && parent.expression === top) { + const arg = unwrap(parent.argumentExpression); + if (arg && (ts.isStringLiteral(arg) || ts.isNumericLiteral(arg))) { + p.push(arg.text); + top = parent; + continue; + } + top = parent; // computed index: the path stops here, the element access is still part of the chain + break; + } + break; + } + return {path: p, top}; +} + +// `export const store = configureStore({ reducer: { key: xReducer, ... } })`. +function storeFacts(sf) { + for (const st of sf.statements) { + if (!ts.isVariableStatement(st)) continue; + for (const d of st.declarationList.declarations) { + if (!d.initializer || !ts.isCallExpression(d.initializer) || calleeHead(d.initializer) !== 'configureStore') continue; + const reducer = unwrap(propertyValue(d.initializer.arguments[0], 'reducer')); + const keys = []; + if (reducer && ts.isObjectLiteralExpression(reducer)) { + for (const p of reducer.properties) { + if (!p.name) continue; + const value = ts.isPropertyAssignment(p) ? p.initializer : ts.isShorthandPropertyAssignment(p) ? p.name : null; + const slice = value ? sliceOfReducerExpression(value, 0) : null; + keys.push({ + key: p.name.getText(sf), + sliceFile: slice ? moduleName(path.resolve(slice.getSourceFile().fileName)) : null, + sliceName: slice ? sliceNameOf(slice.initializer) ?? slice.name.text : null + }); + } + } + return {line: line(sf, d.getStart(sf)), keys}; + } + } + return null; +} + +// `xReducer` -> `export const xReducer = xSlice.reducer` / `export default xSlice.reducer` -> the createSlice declaration. +function sliceOfReducerExpression(expr, depth) { + expr = unwrap(expr); + if (!expr || depth > 6) return null; + if (ts.isPropertyAccessExpression(expr) && expr.name.text === 'reducer') return sliceDeclarationOf(expr.expression); + if (ts.isIdentifier(expr)) { + const d = declarationOf(expr); + if (!d) return null; + if (ts.isVariableDeclaration(d)) return isCreateSlice(d.initializer) ? d : d.initializer ? sliceOfReducerExpression(d.initializer, depth + 1) : null; + if (ts.isExportAssignment(d)) return sliceOfReducerExpression(d.expression, depth + 1); + if (ts.isBindingElement(d)) return null; + } + return null; +} + +// Selector reads: `useAppSelector((state) => state.a.b)`, `const { x, y } = useAppSelector((s) => s.a)` and +// `useXSelector((s) => s?.result)` where useXSelector applies its argument to a fixed base path. +function selectorAccesses(sf, call, out) { + const callee = unwrap(call.expression); + const calleeName = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null; + if (!calleeName) return; + let base = null, selectorArg = 0, via = calleeName; + if (SELECTOR_HOOKS.test(calleeName)) { + base = []; + } else if (/^use[A-Z]/.test(calleeName)) { + const w = wrapperBase(declarationOf(ts.isIdentifier(callee) ? callee : callee.name)); + if (!w) return; + base = w.base; + selectorArg = w.argIndex; + } else { + return; + } + const arg = unwrap(call.arguments[selectorArg]); + if (!arg || !(ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) || !arg.parameters.length) return; + const param = arg.parameters[0]; + if (!ts.isIdentifier(param.name)) return; + const sym = checker.getSymbolAtLocation(param.name); + const paths = []; + const visit = (n) => { + if (ts.isIdentifier(n) && n !== param.name && checker.getSymbolAtLocation(n) === sym) { + paths.push(base.concat(chainFrom(n).path)); + return; + } + ts.forEachChild(n, visit); + }; + visit(arg.body); + const fromDecl = enclosingTopLevelName(sf, call); + const at = line(sf, call.getStart(sf)); + // `const { a, b } = useAppSelector((s) => s.x)` reads x.a and x.b + let parent = call.parent; + while (ts.isAsExpression(parent) || ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent)) parent = parent.parent; + if (paths.length === 1 && ts.isVariableDeclaration(parent) && ts.isObjectBindingPattern(parent.name)) { + for (const el of parent.name.elements) { + if (el.dotDotDotToken) { + out.push({fromDecl, path: paths[0], line: at, via}); + continue; + } + out.push({fromDecl, path: paths[0].concat((el.propertyName ?? el.name).getText(sf)), line: at, via}); + } + return; + } + for (const p of paths) out.push({fromDecl, path: p, line: at, via}); +} + +// `const useXSelector = (selector: (s) => R, eq?) => useAppSelector((state) => selector(state.x.y), eq)` +// -> { base: [x, y], argIndex: 0 }; null when the declaration is not such a wrapper. +function wrapperBase(decl) { + if (!decl) return null; + if (wrapperBases.has(decl)) return wrapperBases.get(decl); + let result = null; + const fn = ts.isFunctionDeclaration(decl) ? decl : ts.isVariableDeclaration(decl) ? unwrap(decl.initializer) : null; + if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isFunctionDeclaration(fn)) && fn.body) { + const params = fn.parameters.map((p) => (ts.isIdentifier(p.name) ? checker.getSymbolAtLocation(p.name) : null)); + const visit = (n) => { + if (result) return; + if (ts.isCallExpression(n)) { + const c = unwrap(n.expression); + const cn = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) ? c.name.text : ''; + const inner = SELECTOR_HOOKS.test(cn) ? unwrap(n.arguments[0]) : null; + if (inner && (ts.isArrowFunction(inner) || ts.isFunctionExpression(inner)) && inner.parameters.length && ts.isIdentifier(inner.parameters[0].name)) { + const stateSym = checker.getSymbolAtLocation(inner.parameters[0].name); + let body = inner.body; + if (ts.isBlock(body)) { + const ret = body.statements.find(ts.isReturnStatement); + body = ret ? ret.expression : null; + } + body = unwrap(body); + if (body && ts.isCallExpression(body) && ts.isIdentifier(body.expression) && body.arguments.length) { + const argIndex = params.indexOf(checker.getSymbolAtLocation(body.expression)); + const root = rootIdentifier(body.arguments[0]); + if (argIndex >= 0 && root && checker.getSymbolAtLocation(root) === stateSym) { + result = {base: chainFrom(root).path, argIndex}; + return; + } + } + } + } + ts.forEachChild(n, visit); + }; + visit(fn.body); + } + wrapperBases.set(decl, result); + return result; +} + +function rootIdentifier(expr) { + let e = unwrap(expr); + while (e && (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e) || ts.isNonNullExpression(e) || ts.isParenthesizedExpression(e))) e = unwrap(e.expression); + return e && ts.isIdentifier(e) ? e : null; +} + +// `store.getState().a.b` / `thunkAPI.getState().a` — a direct read chain on the store. +function getStateAccesses(sf, call, out) { + const callee = unwrap(call.expression); + const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : null; + if (name !== 'getState') return; + const chain = chainFrom(call); + if (!chain.path.length) return; // aliased (`const s = getState()`) chains are collected per identifier + out.push({ + fromDecl: enclosingTopLevelName(sf, call), + path: chain.path, + line: line(sf, call.getStart(sf)), + via: 'getState' + }); +} + +// `const state = thunkAPI.getState() as RootState` — every later `state.a.b` in the file is a store read. +function getStateAliasSymbols(sf) { + const syms = new Set(); + const visit = (n) => { + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) { + const init = unwrap(n.initializer); + if (init && ts.isCallExpression(init)) { + const c = unwrap(init.expression); + const nm = ts.isPropertyAccessExpression(c) ? c.name.text : ts.isIdentifier(c) ? c.text : null; + if (nm === 'getState') { + const s = checker.getSymbolAtLocation(n.name); + if (s) syms.add(s); + } + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return syms; +} + +// --- item 195: DTO field bindings through the generated `Fields` path objects -------------------- + +function isFieldsType(t) { + const seen = new Set(); + const stack = [t]; + while (stack.length) { + const x = stack.pop(); + if (!x || seen.has(x)) continue; + seen.add(x); + if (x.isUnion && x.isUnion()) { + stack.push(...x.types); + continue; + } + const sym = x.getSymbol(); + if (sym && sym.getName() === 'Fields' && sym.declarations && sym.declarations.some((d) => ts.isClassDeclaration(d))) return true; + if (!(x.flags & ts.TypeFlags.Object)) continue; + const target = x.target ?? x; + try { + for (const b of checker.getBaseTypes(target)) stack.push(b); + } catch (e) { /* not a class/interface type */ + } + } + return false; +} + +function firstMember(t) { + return t && t.isUnion && t.isUnion() ? t.types[0] : t; +} + +// [TRoot, TSelf] of a `XFields` type reference, as types; [] when not a reference. +function fieldsTypeArgs(t) { + t = firstMember(t); + if (!t || !(t.flags & ts.TypeFlags.Object) || !(t.objectFlags & ts.ObjectFlags.Reference)) return []; + return checker.getTypeArguments(t); +} + +// A project DTO: a class/interface/type declared in a project file (not `never`, a primitive, or a lib type). +function dtoOf(t) { + if (!t) return null; + const sym = t.getSymbol() ?? t.aliasSymbol; + const decl = sym && sym.declarations && sym.declarations[0]; + if (!decl || !(ts.isInterfaceDeclaration(decl) || ts.isClassDeclaration(decl) || ts.isTypeAliasDeclaration(decl))) return null; + const abs = path.resolve(decl.getSourceFile().fileName); + if (abs.includes(`${path.sep}node_modules${path.sep}`) || !abs.startsWith(root + path.sep)) return null; + return {name: sym.getName(), file: moduleName(abs)}; +} + +function bindingFacts(sf, expr, site, out) { + if (!expr) return; + const e = unwrap(expr); + if (!e || bindingSeen.has(e)) return; + if (ts.isConditionalExpression(e)) { + bindingFacts(sf, e.whenTrue, site, out); + bindingFacts(sf, e.whenFalse, site, out); + return; + } + if (ts.isArrowFunction(e) || ts.isFunctionExpression(e) || ts.isObjectLiteralExpression(e) || ts.isArrayLiteralExpression(e)) return; + let t; + try { + t = checker.getTypeAtLocation(e); + } catch (err) { + return; + } + if (!t || !isFieldsType(t)) return; + bindingSeen.add(e); + // the chain: hops from the leaf back to the root + const hops = []; + let x = e; + for (; ;) { + x = unwrap(x); + if (ts.isCallExpression(x) && ts.isPropertyAccessExpression(unwrap(x.expression))) { + const callee = unwrap(x.expression); + hops.unshift({name: callee.name.text, list: true, type: checker.getTypeAtLocation(x)}); + x = callee.expression; + } else if (ts.isPropertyAccessExpression(x)) { + hops.unshift({name: x.name.text, list: false, type: checker.getTypeAtLocation(x)}); + x = x.expression; + } else if (ts.isElementAccessExpression(x)) { + x = x.expression; + } else { + break; + } + } + const rootNode = unwrap(x); + const rootType = checker.getTypeAtLocation(rootNode); + const rootDecl = ts.isIdentifier(rootNode) ? declarationOf(rootNode) : null; + const fullRoot = !!(rootDecl && ts.isVariableDeclaration(rootDecl) && rootDecl.initializer && ts.isNewExpression(unwrap(rootDecl.initializer))); + const partial = !fullRoot || (t.isUnion && t.isUnion()); + // The base of the path: the root when it is a Fields object (`AgstammUseCaseField`), else the + // first Fields-typed hop, which is a carrier such as `props.useCaseFieldPrefix` — its own name is + // not a DTO field, so it is not part of the path. + let baseType = rootType; + let typed = hops; + if (!isFieldsType(rootType)) { + let carrier = 0; + while (carrier < hops.length && !isFieldsType(hops[carrier].type)) carrier++; + if (carrier >= hops.length) return; + baseType = hops[carrier].type; + typed = hops.slice(carrier + 1); + } + const baseArgs = fieldsTypeArgs(baseType); + const rootDto = baseArgs.length ? dtoOf(baseArgs[0]) : null; + if (!typed.length || !rootDto) return; // a bare hand-over of a prefix object binds nothing new + const leaf = typed[typed.length - 1]; + const leafArgs = fieldsTypeArgs(leaf.type); + const leafDto = leafArgs.length > 1 ? dtoOf(leafArgs[1]) : null; + const ownerType = typed.length > 1 ? fieldsTypeArgs(typed[typed.length - 2].type)[1] : baseArgs[1]; + // the interface that DECLARES the leaf — `datStart` on GeneralAgreementDO is inherited from + // AbstractHistorizedDO, and that is where the FIELD node lives + let owner = ownerType ? declaringDto(ownerType, leaf.name) ?? dtoOf(ownerType) : null; + // a single hop off a root constant (`GeneralAgreementDOField.datStart`): the root's TSelf is + // `never`, the owner is the root DTO itself — or the base interface declaring the member + if (!owner) owner = declaringDto(baseArgs[0], leaf.name) ?? rootDto; + let component = null, attribute = null; + if (ts.isJsxAttribute(site)) { + attribute = site.name.getText(sf); + const el = site.parent.parent; + component = el && (ts.isJsxOpeningElement(el) || ts.isJsxSelfClosingElement(el)) ? el.tagName.getText(sf) : null; + } else if (ts.isPropertyAssignment(site)) { + attribute = site.name.getText(sf); + let p = site.parent; + while (p && !ts.isJsxOpeningElement(p) && !ts.isJsxSelfClosingElement(p) && !ts.isSourceFile(p)) p = p.parent; + component = p && !ts.isSourceFile(p) ? p.tagName.getText(sf) : null; + } else if (ts.isCallExpression(site)) { + attribute = site.expression.getText(sf).replace(/\s+/g, ''); + } + out.push({ + fromDecl: enclosingTopLevelName(sf, e), + kind: leafDto ? 'prefix' : 'field', + rootDto: rootDto.name, ownerDto: owner.name, ownerFile: owner.file, field: leaf.name, + path: typed.map((h) => h.list ? `${h.name}[]` : h.name).join('.'), + partial, component, attribute, line: line(sf, e.getStart(sf)), + }); +} + +function declaringDto(type, member) { + const t = firstMember(type); + const prop = t && checker.getPropertyOfType(t, member); + const decl = prop && prop.declarations && prop.declarations[0]; + const parent = decl && decl.parent; + if (!parent || !(ts.isInterfaceDeclaration(parent) || ts.isClassDeclaration(parent)) || !parent.name) return null; + const abs = path.resolve(parent.getSourceFile().fileName); + if (abs.includes(`${path.sep}node_modules${path.sep}`) || !abs.startsWith(root + path.sep)) return null; + return {name: parent.name.text, file: moduleName(abs)}; +} + + +// --- item 196: theme tokens, style blocks, token references ------------------------------------- + +// Every `createTheme({...})` in the file (item 199: a light and a dark theme often share one file). +function createThemeCalls(sf) { + const found = []; + const visit = (n) => { + if (ts.isCallExpression(n) && /(^|\.)createTheme$/.test(n.expression.getText(sf)) && n.arguments.length + && ts.isObjectLiteralExpression(unwrap(n.arguments[0]))) { + found.push(n); + return; + } + ts.forEachChild(n, visit); + }; + visit(sf); + return found; +} + +function themeTokenFacts(sf) { + const calls = createThemeCalls(sf); + if (!calls.length) return []; + const out = []; + // One fact per token name and file: the first theme's value wins, `variants` counts the themes + // in this file that declare it (item 199). + const byToken = new Map(); + const walk = (obj, prefix) => { + for (const p of obj.properties) { + if (!ts.isPropertyAssignment(p) || !p.name) continue; + const key = p.name.getText(sf).replace(/^['"]|['"]$/g, ''); + const init = unwrap(p.initializer); + const token = prefix ? `${prefix}.${key}` : key; + if (ts.isObjectLiteralExpression(init)) { + walk(init, token); + continue; + } + const seen = byToken.get(token); + if (seen) { + seen.variants++; + continue; + } + const fact = { + token, + kind: 'path', + value: tokenValue(sf, init), + constant: ts.isIdentifier(init) ? init.text : null, + line: line(sf, p.getStart(sf)), + variants: 1 + }; + byToken.set(token, fact); + out.push(fact); + } + }; + for (const call of calls) walk(unwrap(call.arguments[0]), ''); + for (const st of sf.statements) { + if (!ts.isVariableStatement(st) || !isExported(st)) continue; + for (const d of st.declarationList.declarations) { + const init = d.initializer && unwrap(d.initializer); + if (ts.isIdentifier(d.name) && init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init) || ts.isNumericLiteral(init))) { + out.push({ + token: d.name.text, + kind: 'constant', + value: init.text, + constant: null, + line: line(sf, d.getStart(sf)) + }); + } + } + } + return out; +} + +function tokenValue(sf, init) { + if (ts.isNumericLiteral(init)) return init.text; + if (ts.isPrefixUnaryExpression(init)) return init.getText(sf); + const lit = literalString(init); + if (lit !== null) return lit; + if (ts.isIdentifier(init)) { + const d = declarationOf(init); + if (d && ts.isVariableDeclaration(d) && d.initializer && ts.isNumericLiteral(unwrap(d.initializer))) return unwrap(d.initializer).text; + } + return init.getText(sf).replace(/\s+/g, ' '); +} + +// A theme token read: `theme.palette.primary.dark` (root typed Theme or named theme; `spacing(2)` ends +// at `spacing`, `grey['200']` continues) or an identifier bound to a theme-file string constant. +function themeTokenOf(sf, id) { + if (!ts.isIdentifier(id)) return null; + if (ts.isPropertyAccessExpression(id.parent) && id.parent.name === id) return null; + if (ts.isJsxAttribute(id.parent) || ts.isPropertyAssignment(id.parent) && id.parent.name === id) return null; + if (ts.isBindingElement(id.parent) || ts.isParameter(id.parent) || ts.isVariableDeclaration(id.parent) && id.parent.name === id) return null; + if (ts.isImportSpecifier(id.parent) || ts.isImportClause(id.parent) || ts.isExportSpecifier(id.parent)) return null; + let isTheme = false; + if (id.text === 'theme') isTheme = true; + else if (isThemeObject(id)) isTheme = true; // `import { theme as appTheme }` — the createTheme const itself + else { + let t = null; + try { + t = checker.getTypeAtLocation(id); + } catch (e) { /* ignore */ + } + const sym = t && (t.getSymbol() ?? t.aliasSymbol); + if (sym && sym.getName() === 'Theme') isTheme = true; + } + if (isTheme) { + const p = []; + let top = id; + for (; ;) { + const parent = top.parent; + if (ts.isNonNullExpression(parent) || ts.isParenthesizedExpression(parent)) { + top = parent; + continue; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === top) { + p.push(parent.name.text); + top = parent; + if (ts.isCallExpression(top.parent) && top.parent.expression === top) break; // theme.spacing(2) + continue; + } + if (ts.isElementAccessExpression(parent) && parent.expression === top) { + const arg = unwrap(parent.argumentExpression); + if (arg && (ts.isStringLiteral(arg) || ts.isNumericLiteral(arg))) { + p.push(arg.text); + top = parent; + continue; + } + } + break; + } + return p.length ? {token: p.join('.')} : null; + } + const d = declarationOf(id); + if (d && ts.isVariableDeclaration(d) && ts.isIdentifier(d.name) && themeFiles.has(path.resolve(d.getSourceFile().fileName))) { + const init = d.initializer && unwrap(d.initializer); + if (init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init) || ts.isNumericLiteral(init))) return {token: d.name.text}; + } + return null; +} + +function insideStyleBlock(n) { + for (let p = n.parent; p; p = p.parent) { + if (ts.isJsxAttribute(p)) return p.name.getText() === 'sx' || p.name.getText() === 'style'; + if (ts.isCallExpression(p) && ts.isCallExpression(p.expression) && calleeHead(p) === 'styled') return true; + if (ts.isSourceFile(p)) return false; + } + return false; +} + +function jsxAttributeContext(sf, n) { + for (let p = n.parent; p && !ts.isSourceFile(p); p = p.parent) { + if (ts.isJsxAttribute(p)) return p.name.getText(sf); + } + return 'code'; +} + +// One sx={..} / style={..} / styled(X)(..) block: its keys, hard-coded values and theme tokens. +function styleFacts(sf, kind, expr, jsxElement, site, out) { + const e = unwrap(expr); + let element = null; + if (jsxElement && (ts.isJsxOpeningElement(jsxElement) || ts.isJsxSelfClosingElement(jsxElement))) element = jsxElement.tagName.getText(sf); + if (kind === 'styled') { + const inner = site.expression; + element = inner.arguments.length ? inner.arguments[0].getText(sf).replace(/\s+/g, '') : null; + } + const fact = { + fromDecl: enclosingTopLevelName(sf, site), + styleKind: kind, + element, + line: line(sf, site.getStart(sf)), + col: sf.getLineAndCharacterOfPosition(site.getStart(sf)).character + 1, + properties: [], + literals: [], + dynamic: false, + spread: false, + tokens: [] + }; + let obj = e; + if (ts.isArrowFunction(obj) || ts.isFunctionExpression(obj)) { + let body = obj.body; + if (ts.isBlock(body)) { + const ret = body.statements.find(ts.isReturnStatement); + body = ret ? ret.expression : null; + } + obj = body ? unwrap(body) : null; + } + if (obj && ts.isObjectLiteralExpression(obj)) { + collectStyleObject(sf, obj, '', fact); + } else if (obj && ts.isArrayLiteralExpression(obj)) { + for (const el of obj.elements) { + const u = unwrap(el); + if (ts.isObjectLiteralExpression(u)) collectStyleObject(sf, u, '', fact); else fact.dynamic = true; + } + } else { + fact.dynamic = true; + collectTokens(sf, e, null, fact); + } + out.push(fact); +} + +function collectStyleObject(sf, obj, prefix, fact) { + for (const p of obj.properties) { + if (ts.isSpreadAssignment(p)) { + fact.spread = true; + collectTokens(sf, p.expression, null, fact); + continue; + } + if (!p.name) continue; + const key = prefix ? `${prefix}.${p.name.getText(sf).replace(/^['"]|['"]$/g, '')}` : p.name.getText(sf).replace(/^['"]|['"]$/g, ''); + if (ts.isShorthandPropertyAssignment(p)) { + fact.properties.push(key); + fact.dynamic = true; + collectTokens(sf, p.name, key, fact); + continue; + } + if (!ts.isPropertyAssignment(p)) continue; + const v = unwrap(p.initializer); + if (ts.isObjectLiteralExpression(v)) { + collectStyleObject(sf, v, key, fact); + continue; + } + fact.properties.push(key); + if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) { + const m = v.text.match(LITERAL_PARTS); // `1px solid #D2D2D2` -> 1px, #D2D2D2 + if (m) fact.literals.push(...m); + } else if (ts.isNumericLiteral(v) || (ts.isPrefixUnaryExpression(v) && ts.isNumericLiteral(v.operand))) { + // spacing multipliers (mt: 2) are theme-relative, not literals + } else if (ts.isTemplateExpression(v)) { + const m = v.getText(sf).match(LITERAL_PARTS); + if (m) fact.literals.push(...m); + collectTokens(sf, v, key, fact); + fact.dynamic = true; + } else { + const before = fact.tokens.length; + collectTokens(sf, v, key, fact); + if (fact.tokens.length === before) fact.dynamic = true; + } + } +} + +function collectTokens(sf, node, property, fact) { + const visit = (n) => { + if (ts.isIdentifier(n)) { + const t = themeTokenOf(sf, n); + if (t) fact.tokens.push({token: t.token, property, line: line(sf, n.getStart(sf))}); + } + ts.forEachChild(n, visit); + }; + visit(node); +} + +// An identifier bound (through imports) to a `const x = createTheme(..)` of a theme file. +function isThemeObject(id) { + const d = declarationOf(id); + return !!(d && ts.isVariableDeclaration(d) && d.initializer && themeFiles.has(path.resolve(d.getSourceFile().fileName)) + && ts.isCallExpression(unwrap(d.initializer)) && /(^|\.)createTheme$/.test(unwrap(d.initializer).expression.getText())); +} diff --git a/ac-parser-typescript/sidecar/package-lock.json b/ac-parser-typescript/sidecar/package-lock.json new file mode 100644 index 0000000..e6024dc --- /dev/null +++ b/ac-parser-typescript/sidecar/package-lock.json @@ -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" + } + } + } +} diff --git a/ac-parser-typescript/sidecar/package.json b/ac-parser-typescript/sidecar/package.json new file mode 100644 index 0000000..96d74b9 --- /dev/null +++ b/ac-parser-typescript/sidecar/package.json @@ -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" + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/CssLineCounter.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/CssLineCounter.java new file mode 100644 index 0000000..28462da --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/CssLineCounter.java @@ -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); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptCoarseScanner.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptCoarseScanner.java new file mode 100644 index 0000000..6f895d2 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptCoarseScanner.java @@ -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. + * + *

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 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 workspaces; + private final Set 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 workspaces, Set 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 nodes, List 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 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 props = new java.util.LinkedHashSet<>(); + Set 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 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 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 external, List 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 nodes, + List edges, String name, int offset, boolean exported, String kind) { + int line = lineOf(content, offset); + Map 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 = } 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 nodes = new ArrayList<>(); + List 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 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 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 nodes, + List 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 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 props) { + void setExternalImports(Set 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 byName = new LinkedHashMap<>(); + private final List nodes; + + Placeholders(List 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 }) or a store {@code FIELD} + * ({@code .}) 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 .} 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 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.} ({@code theme=true}), resolved by exact name. + */ + AstNode theme(String token) { + return byName.computeIfAbsent("THEME|" + token, n -> { + Map 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 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; + }); + } + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFacts.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFacts.java new file mode 100644 index 0000000..87e9dbb --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFacts.java @@ -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 byFile, Map byModule, + Map 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 | -> 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 byFile) { + Map byModule = new HashMap<>(); + Map 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 parts) { + Map 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 imports, List declarations, List calls, + List endpoints, List slices, @Nullable StoreFact store, + List stateAccesses, List bindings, + List themeTokens, List styles, List tokenRefs) { + + /** + * The item-195 shape, without styling facts. + */ + public FileFacts(List imports, List declarations, List calls, + List endpoints, List slices, @Nullable StoreFact store, + List stateAccesses, List 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 imports, List declarations, List calls, + List endpoints) { + this(imports, declarations, calls, endpoints, List.of(), null, List.of(), List.of()); + } + + /** + * The item-194 shape, without bindings. + */ + public FileFacts(List imports, List declarations, List calls, + List endpoints, List slices, @Nullable StoreFact store, + List 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 properties, + List literals, boolean dynamic, boolean spread, List 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 }. + * + * @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 fields, List 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 /matcher:} + * @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 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 path, int line) { + } + + /** + * A {@code configureStore} (item 194): which slice each reducer key mounts. + */ + public record StoreFact(int line, List 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 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 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 .} 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 /} 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; + } + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFactsReader.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFactsReader.java new file mode 100644 index 0000000..99880fa --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptFactsReader.java @@ -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 byFile = new HashMap<>(); + JsonNode files = rootNode.path("files"); + for (Iterator> it = files.fields(); it.hasNext(); ) { + Map.Entry e = it.next(); + byFile.put(e.getKey(), fileFacts(e.getValue())); + } + return TypeScriptFacts.of(byFile); + } + + private static FileFacts fileFacts(JsonNode f) { + List 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 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 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 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 slices = new ArrayList<>(); + for (JsonNode sl : f.path("slices")) { + List reducers = new ArrayList<>(); + for (JsonNode r : sl.path("reducers")) { + List 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 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 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 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 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 styles = new ArrayList<>(); + for (JsonNode sb : f.path("styles")) { + List 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 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 members(JsonNode array) { + List 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 strings(JsonNode array) { + List 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(); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptLineCounter.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptLineCounter.java new file mode 100644 index 0000000..7dab761 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptLineCounter.java @@ -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); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptModuleNames.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptModuleNames.java new file mode 100644 index 0000000..69eb082 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptModuleNames.java @@ -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 without the script extension, 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. + * + *

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}): + *

    + *
  • {@code ./x}, {@code ../x} — relative to the importing file's directory
  • + *
  • {@code } or {@code /...} — the workspace barrel ({@code /src/index}) + * or that path
  • + *
  • {@code #/x} — the importing workspace's {@code src/x} (package.json {@code imports})
  • + *
  • {@code @scope/pkg}, a name in {@code externalPackages}, {@code node:*} — an npm package, + * recorded on the module, never a placeholder
  • + *
  • any other bare specifier — {@code baseUrl}-relative: {@code /src/}
  • + *
+ * 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 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 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 workspaces, + Set 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 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); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptParser.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptParser.java new file mode 100644 index 0000000..e1bd689 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptParser.java @@ -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: + *
    + *
  • 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)
  • + *
  • imports: resolved against the file system by the sidecar; an npm package lands in + * {@code externalImports}; an unresolved specifier keeps the Tier-1 guess
  • + *
  • 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 .} under its {@code DATA_STRUCTURE}; a member call on an Endpoint instance is + * retargeted to the endpoint {@code FUNCTION} ({@code calleeMethod = X.member})
  • + *
  • calls: a callee owned by a top-level declaration of this 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.
  • + *
  • 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 .}, {@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.
  • + *
  • item 195 (DTO field bindings): a use of a generated {@code Fields} path object + * ({@code }) 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}.
  • + *
  • item 196 (styling): the file with {@code createTheme} gets a {@code DATA_STRUCTURE theme} with a + * {@code FIELD theme.} 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.
  • + *
+ */ +public final class TypeScriptParser implements LanguageParser { + + private static final Set FUNCTION_KINDS = Set.of("function", "component", "hook", "thunk", "styled", "class"); + private static final Set 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 workspaces, Set 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 declared, + List nodes, List edges) { + String sourceFile = module.sourceFile(); + String key = project.facts().storeKey(module.name(), sl.sliceName()); + Map 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 fields = new HashMap<>(); + for (MemberFact m : sl.fields()) { + Map 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 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 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 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 path, int line, String via) { + Map 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 endpointProps(EndpointFact e) { + TypeScriptRestPaths paths = TypeScriptRestPaths.of(e.url()); + Map 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 nodes = new ArrayList<>(); + List 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 declared = new HashMap<>(); + Map dtoFields = new HashMap<>(); // "." -> 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 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 .: 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 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 tokenNodes = new HashMap<>(); + if (!file.themeTokens().isEmpty()) { + Map 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 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 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 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> perSite = new LinkedHashMap<>(); + for (StyleTokenFact t : st.tokens()) { + perSite.computeIfAbsent(t.token() + "@" + t.line(), k -> new ArrayList<>()).add(t); + } + for (List site : perSite.values()) { + StyleTokenFact t = site.get(0); + AstNode target = tokenNodes.containsKey(t.token()) ? tokenNodes.get(t.token()) : placeholders.theme(t.token()); + Map 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 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 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 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 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); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptProject.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptProject.java new file mode 100644 index 0000000..55e8033 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptProject.java @@ -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 workspaces, Set 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 workspaces = new LinkedHashSet<>(); + Set 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 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 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 `/src/redux` per such import (verified on purfe). + Path nodeModules = root.resolve("node_modules"); + if (Files.isDirectory(nodeModules)) { + try (DirectoryStream entries = Files.newDirectoryStream(nodeModules, Files::isDirectory)) { + for (Path entry : entries) { + String name = entry.getFileName().toString(); + if (name.startsWith(".")) { + continue; + } + if (name.startsWith("@")) { + try (DirectoryStream 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 into) { + for (String section : new String[]{"dependencies", "devDependencies", "peerDependencies"}) { + for (Iterator it = packageJson.path(section).fieldNames(); it.hasNext(); ) { + into.add(it.next()); + } + } + } + + public TypeScriptProject withFacts(TypeScriptFacts newFacts) { + return new TypeScriptProject(workspaces, externalPackages, newFacts); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptRestPaths.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptRestPaths.java new file mode 100644 index 0000000..d80477a --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptRestPaths.java @@ -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. + * + *

The backend persists a handler's path without 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 //v} 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 queryParams) { + + private static final Pattern APP_BASE = Pattern.compile("^/([^/]+)/v\\d+(?=/|$)"); + + public static TypeScriptRestPaths of(String url) { + String path = url; + List 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} → {@code SvcResult}). + */ + public static String unqualified(String type) { + return type.replaceAll("\\b[A-Z][A-Z0-9_]*\\.(?=[A-Za-z_])", ""); + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptSidecar.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptSidecar.java new file mode 100644 index 0000000..549e904 --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/TypeScriptSidecar.java @@ -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= + * extract.mjs --root --workspace [--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 files) { + List 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); + } + } +} diff --git a/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/package-info.java b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/package-info.java new file mode 100644 index 0000000..799774f --- /dev/null +++ b/ac-parser-typescript/src/main/java/com/agenticcode/parsertypescript/package-info.java @@ -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; diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/Fixtures.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/Fixtures.java new file mode 100644 index 0000000..69a7ced --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/Fixtures.java @@ -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); + } + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptCoarseScannerTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptCoarseScannerTest.java new file mode 100644 index 0000000..27f310b --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptCoarseScannerTest.java @@ -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 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 refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES).toList(); + Set 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 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 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 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 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); + } + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptFactsReaderTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptFactsReaderTest.java new file mode 100644 index 0000000..839bc4e --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptFactsReaderTest.java @@ -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 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: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", 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: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"); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptLineCounterTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptLineCounterTest.java new file mode 100644 index 0000000..8ebcbe5 --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptLineCounterTest.java @@ -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()); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptModuleNamesTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptModuleNamesTest.java new file mode 100644 index 0000000..065848c --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptModuleNamesTest.java @@ -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 WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch"); + private static final Set 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)); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java new file mode 100644 index 0000000..336664b --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptParserTest.java @@ -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 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 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 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 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 fromPage = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(page.id())).toList(); + Set 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 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 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", 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 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 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 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 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 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 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")); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptProjectTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptProjectTest.java new file mode 100644 index 0000000..80c809a --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptProjectTest.java @@ -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")); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptRestPathsTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptRestPathsTest.java new file mode 100644 index 0000000..38b55b3 --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptRestPathsTest.java @@ -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", TypeScriptRestPaths.unqualified("COMMON.SvcResult")); + assertEquals("{ vermnr: number }", TypeScriptRestPaths.unqualified("{ vermnr: number }")); + } +} diff --git a/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptSidecarTest.java b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptSidecarTest.java new file mode 100644 index 0000000..bf76611 --- /dev/null +++ b/ac-parser-typescript/src/test/java/com/agenticcode/parsertypescript/TypeScriptSidecarTest.java @@ -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 s = Stream.empty()) { + assertNotNull(s); + } + } +} diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json b/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json new file mode 100644 index 0000000..8ded847 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/facts-pur-r-vstamm.json @@ -0,0 +1,1986 @@ +{ + "version": 4, + "workspace": "pur-r-vstamm", + "files": { + "pur-r-vstamm/src/generated/agstamm-interfaces.ts": { + "imports": [], + "declarations": [ + { + "name": "Broker", + "kind": "interface", + "exported": true, + "startLine": 7, + "endLine": 10, + "members": [ + { + "name": "vermnr", + "type": "number", + "optional": false, + "line": 8 + }, + { + "name": "ebene", + "type": "string", + "optional": false, + "line": 9 + } + ] + }, + { + "name": "AgstammUseCase", + "kind": "interface", + "exported": true, + "startLine": 12, + "endLine": 15, + "members": [ + { + "name": "broker", + "type": "Broker", + "optional": false, + "line": 13 + }, + { + "name": "brokerName", + "type": "string", + "optional": false, + "line": 14 + } + ] + }, + { + "name": "Fields", + "kind": "class", + "exported": true, + "startLine": 17, + "endLine": 24, + "members": [] + }, + { + "name": "BrokerFields", + "kind": "class", + "exported": true, + "startLine": 26, + "endLine": 29, + "members": [] + }, + { + "name": "AgstammUseCaseFields", + "kind": "class", + "exported": true, + "startLine": 31, + "endLine": 34, + "members": [] + }, + { + "name": "AgstammUseCaseField", + "kind": "const", + "exported": true, + "startLine": 36, + "endLine": 36, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "Fields", + "expression": "this.$$parent?.get", + "symbol": "get", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "Fields", + "package": null, + "receiver": "Fields", + "member": "$$parent.get", + "actionType": null, + "line": 22, + "kind": "call" + }, + { + "fromDecl": "Fields", + "expression": "this.$$parent.get", + "symbol": "get", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "Fields", + "package": null, + "receiver": "Fields", + "member": "$$parent.get", + "actionType": null, + "line": 22, + "kind": "call" + }, + { + "fromDecl": "BrokerFields", + "expression": "Fields", + "symbol": "Fields", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "Fields", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 27, + "kind": "new" + }, + { + "fromDecl": "BrokerFields", + "expression": "Fields", + "symbol": "Fields", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "Fields", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 28, + "kind": "new" + }, + { + "fromDecl": "AgstammUseCaseFields", + "expression": "BrokerFields", + "symbol": "BrokerFields", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "BrokerFields", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 32, + "kind": "new" + }, + { + "fromDecl": "AgstammUseCaseFields", + "expression": "Fields", + "symbol": "Fields", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "Fields", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 33, + "kind": "new" + }, + { + "fromDecl": "AgstammUseCaseField", + "expression": "AgstammUseCaseFields", + "symbol": "AgstammUseCaseFields", + "file": "pur-r-vstamm/src/generated/agstamm-interfaces", + "decl": "AgstammUseCaseFields", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 36, + "kind": "new" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/generated/agstamm-endpoints.ts": { + "imports": [ + { + "specifier": "pur-ui-common", + "resolved": null, + "package": null, + "line": 6, + "names": "* as COMMON" + }, + { + "specifier": "./agstamm-interfaces", + "resolved": "pur-r-vstamm/src/generated/agstamm-interfaces", + "package": null, + "line": 7, + "names": "* as API" + } + ], + "declarations": [ + { + "name": "Endpoint", + "kind": "interface", + "exported": false, + "startLine": 9, + "endLine": 11, + "members": [ + { + "name": "baseUrl", + "type": "string", + "optional": false, + "line": 10 + } + ] + }, + { + "name": "GetMethodWithParameters", + "kind": "interface", + "exported": false, + "startLine": 13, + "endLine": 15, + "members": [ + { + "name": "get", + "type": "(params: P) => Promise", + "optional": false, + "line": 14 + } + ] + }, + { + "name": "PostMethod", + "kind": "interface", + "exported": false, + "startLine": 17, + "endLine": 19, + "members": [ + { + "name": "post", + "type": "(body: B) => Promise", + "optional": false, + "line": 18 + } + ] + }, + { + "name": "AgstammControllerEndpoint", + "kind": "class", + "exported": true, + "startLine": 21, + "endLine": 33, + "members": [] + }, + { + "name": "AgstammAction", + "kind": "type", + "exported": true, + "startLine": 35, + "endLine": 35, + "members": [] + }, + { + "name": "AgstammKind", + "kind": "enum", + "exported": true, + "startLine": 37, + "endLine": 37, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "AgstammControllerEndpoint", + "expression": "COMMON.executeGetRequest", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "executeGetRequest", + "actionType": null, + "line": 25, + "kind": "call" + }, + { + "fromDecl": "AgstammControllerEndpoint", + "expression": "COMMON.buildPurURL", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "buildPurURL", + "actionType": null, + "line": 25, + "kind": "call" + }, + { + "fromDecl": "AgstammControllerEndpoint", + "expression": "COMMON.executePostRequest", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "executePostRequest", + "actionType": null, + "line": 30, + "kind": "call" + }, + { + "fromDecl": "AgstammControllerEndpoint", + "expression": "COMMON.buildPurURL", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "buildPurURL", + "actionType": null, + "line": 30, + "kind": "call" + } + ], + "endpoints": [ + { + "name": "AgstammControllerEndpoint.searchBroker", + "owner": "AgstammControllerEndpoint", + "member": "searchBroker", + "httpMethod": "GET", + "generator": "typescript-generator", + "backend": "pur", + "url": "/agstamm/ui/search?vermnr={vermnr}", + "requestType": null, + "responseType": "COMMON.SvcResult", + "paramsType": "{ vermnr: number }", + "line": 23 + }, + { + "name": "AgstammControllerEndpoint.saveBroker", + "owner": "AgstammControllerEndpoint", + "member": "saveBroker", + "httpMethod": "POST", + "generator": "typescript-generator", + "backend": "pur", + "url": "/agstamm/ui/", + "requestType": "API.AgstammUseCase", + "responseType": "COMMON.SvcResult", + "paramsType": null, + "line": 28 + } + ], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/store/store.ts": { + "imports": [ + { + "specifier": "@reduxjs/toolkit", + "resolved": null, + "package": null, + "line": 1, + "names": "{configureStore}" + }, + { + "specifier": "store/slices/agstammSlice", + "resolved": "pur-r-vstamm/src/store/slices/agstammSlice", + "package": null, + "line": 2, + "names": "agstammReducer" + } + ], + "declarations": [ + { + "name": "store", + "kind": "const", + "exported": true, + "startLine": 4, + "endLine": 8, + "members": [] + }, + { + "name": "RootState", + "kind": "type", + "exported": true, + "startLine": 10, + "endLine": 10, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "store", + "expression": "configureStore", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 4, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": { + "line": 4, + "keys": [ + { + "key": "broker", + "sliceFile": "pur-r-vstamm/src/store/slices/agstammSlice", + "sliceName": "agstamm" + } + ] + }, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/store/rv-redux-types.ts": { + "imports": [ + { + "specifier": "react-redux", + "resolved": null, + "package": null, + "line": 1, + "names": "{useSelector}" + }, + { + "specifier": "store/store", + "resolved": "pur-r-vstamm/src/store/store", + "package": null, + "line": 2, + "names": "type {RootState}" + } + ], + "declarations": [ + { + "name": "RvRootState", + "kind": "interface", + "exported": true, + "startLine": 4, + "endLine": 6, + "members": [ + { + "name": "broker", + "type": "{ agstammUseCaseSvcResult?: unknown; requestStatus: string }", + "optional": false, + "line": 5 + } + ] + }, + { + "name": "useAppSelector", + "kind": "const", + "exported": true, + "startLine": 8, + "endLine": 8, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "useAppSelector", + "expression": "useSelector.withTypes", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "withTypes", + "actionType": null, + "line": 8, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/generated/client/client.ts": { + "imports": [], + "declarations": [ + { + "name": "TDataShape", + "kind": "type", + "exported": true, + "startLine": 1, + "endLine": 1, + "members": [ + { + "name": "path", + "type": "Record", + "optional": true, + "line": 1 + } + ] + }, + { + "name": "Options", + "kind": "type", + "exported": true, + "startLine": 2, + "endLine": 4, + "members": [] + }, + { + "name": "RequestResult", + "kind": "type", + "exported": true, + "startLine": 5, + "endLine": 9, + "members": [] + } + ], + "calls": [], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/generated/client/client.gen.ts": { + "imports": [], + "declarations": [ + { + "name": "client", + "kind": "const", + "exported": true, + "startLine": 1, + "endLine": 1, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "client", + "expression": "Promise.reject", + "symbol": "reject", + "file": null, + "decl": null, + "package": "lib", + "receiver": "PromiseConstructor", + "member": "reject", + "actionType": null, + "line": 1, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/generated/client/types.gen.ts": { + "imports": [], + "declarations": [ + { + "name": "GetPurRVstammV1AgstammUiHistorieByVermnrData", + "kind": "type", + "exported": true, + "startLine": 1, + "endLine": 1, + "members": [ + { + "name": "path", + "type": "{ vermnr: number }", + "optional": false, + "line": 1 + } + ] + }, + { + "name": "GetPurRVstammV1AgstammUiHistorieByVermnrResponses", + "kind": "type", + "exported": true, + "startLine": 2, + "endLine": 2, + "members": [ + { + "name": "200", + "type": "{ result?: { eintraege: string[] } }", + "optional": false, + "line": 2 + } + ] + } + ], + "calls": [], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/generated/client/sdk.gen.ts": { + "imports": [ + { + "specifier": "./client", + "resolved": "pur-r-vstamm/src/generated/client/client", + "package": null, + "line": 3, + "names": "type {Options as Options2, RequestResult, TDataShape}" + }, + { + "specifier": "./client.gen", + "resolved": "pur-r-vstamm/src/generated/client/client.gen", + "package": null, + "line": 4, + "names": "{client}" + }, + { + "specifier": "./types.gen", + "resolved": "pur-r-vstamm/src/generated/client/types.gen", + "package": null, + "line": 5, + "names": "type { GetPurRVstammV1AgstammUiHistorieByVermnrData, GetPurRVstammV1AgstammUiHistorieByVermnrResponses }" + } + ], + "declarations": [ + { + "name": "Options", + "kind": "type", + "exported": true, + "startLine": 10, + "endLine": 12, + "members": [] + }, + { + "name": "getPurRVstammV1AgstammUiHistorieByVermnr", + "kind": "function", + "exported": true, + "startLine": 17, + "endLine": 22, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "getPurRVstammV1AgstammUiHistorieByVermnr", + "expression": "(options.client ?? client).get", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "get", + "actionType": null, + "line": 18, + "kind": "call" + } + ], + "endpoints": [ + { + "name": "getPurRVstammV1AgstammUiHistorieByVermnr", + "owner": null, + "member": null, + "httpMethod": "GET", + "generator": "hey-api", + "backend": null, + "url": "/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}", + "requestType": "GetPurRVstammV1AgstammUiHistorieByVermnrData", + "responseType": "GetPurRVstammV1AgstammUiHistorieByVermnrResponses", + "paramsType": null, + "line": 17 + } + ], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/store/slices/agstammSlice.ts": { + "imports": [ + { + "specifier": "@reduxjs/toolkit", + "resolved": null, + "package": null, + "line": 1, + "names": "{createSlice, PayloadAction}" + }, + { + "specifier": "generated/agstamm-interfaces", + "resolved": "pur-r-vstamm/src/generated/agstamm-interfaces", + "package": null, + "line": 2, + "names": "{AgstammUseCase}" + }, + { + "specifier": "generated/agstamm-endpoints", + "resolved": "pur-r-vstamm/src/generated/agstamm-endpoints", + "package": null, + "line": 3, + "names": "{AgstammControllerEndpoint}" + }, + { + "specifier": "pur-ui-common", + "resolved": null, + "package": null, + "line": 4, + "names": "{createAppAsyncThunk, RequestStatus, SvcResult}" + }, + { + "specifier": "../rv-redux-types", + "resolved": "pur-r-vstamm/src/store/rv-redux-types", + "package": null, + "line": 5, + "names": "type {RvRootState}" + }, + { + "specifier": "generated/client/sdk.gen", + "resolved": "pur-r-vstamm/src/generated/client/sdk.gen", + "package": null, + "line": 6, + "names": "{getPurRVstammV1AgstammUiHistorieByVermnr}" + } + ], + "declarations": [ + { + "name": "AgstammState", + "kind": "interface", + "exported": true, + "startLine": 8, + "endLine": 11, + "members": [ + { + "name": "requestStatus", + "type": "RequestStatus", + "optional": false, + "line": 9 + }, + { + "name": "agstammUseCaseSvcResult", + "type": "SvcResult", + "optional": true, + "line": 10 + } + ] + }, + { + "name": "initialState", + "kind": "const", + "exported": false, + "startLine": 13, + "endLine": 13, + "members": [] + }, + { + "name": "agstammSlice", + "kind": "slice", + "exported": false, + "startLine": 15, + "endLine": 37, + "members": [] + }, + { + "name": "agstammUiApi", + "kind": "const", + "exported": false, + "startLine": 39, + "endLine": 39, + "members": [] + }, + { + "name": "loadBrokerFromServer", + "kind": "thunk", + "exported": true, + "startLine": 41, + "endLine": 44, + "members": [] + }, + { + "name": "saveBrokerToServer", + "kind": "thunk", + "exported": true, + "startLine": 46, + "endLine": 49, + "members": [] + }, + { + "name": "selectAgstamm", + "kind": "function", + "exported": true, + "startLine": 51, + "endLine": 51, + "members": [] + }, + { + "name": "loadHistorie", + "kind": "thunk", + "exported": true, + "startLine": 53, + "endLine": 56, + "members": [] + }, + { + "name": "statusOf", + "kind": "function", + "exported": false, + "startLine": 58, + "endLine": 60, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "agstammSlice", + "expression": "createSlice", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 15, + "kind": "call" + }, + { + "fromDecl": "agstammSlice", + "expression": "builder.addCase", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "addCase", + "actionType": null, + "line": 29, + "kind": "call" + }, + { + "fromDecl": "agstammSlice", + "expression": "builder.addMatcher", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "addMatcher", + "actionType": null, + "line": 33, + "kind": "call" + }, + { + "fromDecl": "agstammSlice", + "expression": "action.type.endsWith", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "type.endsWith", + "actionType": null, + "line": 33, + "kind": "call" + }, + { + "fromDecl": "agstammUiApi", + "expression": "AgstammControllerEndpoint", + "symbol": "AgstammControllerEndpoint", + "file": "pur-r-vstamm/src/generated/agstamm-endpoints", + "decl": "AgstammControllerEndpoint", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 39, + "kind": "new" + }, + { + "fromDecl": "loadBrokerFromServer", + "expression": "createAppAsyncThunk", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 41, + "kind": "call" + }, + { + "fromDecl": "loadBrokerFromServer", + "expression": "agstammUiApi.searchBroker.get", + "symbol": "get", + "file": "pur-r-vstamm/src/generated/agstamm-endpoints", + "decl": "GetMethodWithParameters", + "package": null, + "receiver": "AgstammControllerEndpoint", + "member": "searchBroker.get", + "actionType": null, + "line": 43, + "kind": "call" + }, + { + "fromDecl": "loadBrokerFromServer", + "expression": "String", + "symbol": "String", + "file": null, + "decl": null, + "package": "lib", + "receiver": null, + "member": null, + "actionType": null, + "line": 43, + "kind": "call" + }, + { + "fromDecl": "saveBrokerToServer", + "expression": "createAppAsyncThunk", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 46, + "kind": "call" + }, + { + "fromDecl": "saveBrokerToServer", + "expression": "agstammUiApi.saveBroker.post", + "symbol": "post", + "file": "pur-r-vstamm/src/generated/agstamm-endpoints", + "decl": "PostMethod", + "package": null, + "receiver": "AgstammControllerEndpoint", + "member": "saveBroker.post", + "actionType": null, + "line": 48, + "kind": "call" + }, + { + "fromDecl": "loadHistorie", + "expression": "createAppAsyncThunk", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 53, + "kind": "call" + }, + { + "fromDecl": "loadHistorie", + "expression": "getPurRVstammV1AgstammUiHistorieByVermnr", + "symbol": "getPurRVstammV1AgstammUiHistorieByVermnr", + "file": "pur-r-vstamm/src/generated/client/sdk.gen", + "decl": "getPurRVstammV1AgstammUiHistorieByVermnr", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 55, + "kind": "call" + } + ], + "endpoints": [], + "slices": [ + { + "name": "agstammSlice", + "sliceName": "agstamm", + "stateType": "AgstammState", + "exported": false, + "startLine": 15, + "endLine": 37, + "fields": [ + { + "name": "requestStatus", + "type": "RequestStatus", + "optional": false, + "line": 9 + }, + { + "name": "agstammUseCaseSvcResult", + "type": "SvcResult", + "optional": true, + "line": 10 + } + ], + "reducers": [ + { + "name": "agstamm/updateAgstammUseCaseSvcResult", + "kind": "reducer", + "trigger": null, + "startLine": 19, + "endLine": 21, + "accesses": [ + { + "mode": "write", + "path": [ + "agstammUseCaseSvcResult" + ], + "line": 20 + } + ] + }, + { + "name": "agstamm/resetIfIdle", + "kind": "reducer", + "trigger": null, + "startLine": 22, + "endLine": 26, + "accesses": [ + { + "mode": "read", + "path": [ + "requestStatus" + ], + "line": 23 + }, + { + "mode": "write", + "path": [ + "agstammUseCaseSvcResult" + ], + "line": 24 + } + ] + }, + { + "name": "agstamm/loadBroker/fulfilled", + "kind": "case", + "trigger": { + "expression": "loadBrokerFromServer.fulfilled", + "actionType": "agstamm/loadBroker/fulfilled", + "file": "pur-r-vstamm/src/store/slices/agstammSlice", + "decl": "loadBrokerFromServer" + }, + "startLine": 29, + "endLine": 32, + "accesses": [ + { + "mode": "write", + "path": [ + "requestStatus" + ], + "line": 30 + }, + { + "mode": "write", + "path": [ + "agstammUseCaseSvcResult" + ], + "line": 31 + } + ] + }, + { + "name": "agstamm/matcher:(action) => action.type.endsWith('/rejected')", + "kind": "matcher", + "trigger": { + "expression": "(action) => action.type.endsWith('/rejected')", + "actionType": null, + "file": null, + "decl": null + }, + "startLine": 33, + "endLine": 35, + "accesses": [ + { + "mode": "write", + "path": [ + "requestStatus" + ], + "line": 34 + } + ] + } + ] + } + ], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/store/hooks/useAgstamm.ts": { + "imports": [ + { + "specifier": "store/slices/agstammSlice", + "resolved": "pur-r-vstamm/src/store/slices/agstammSlice", + "package": null, + "line": 1, + "names": "{loadBrokerFromServer, selectAgstamm}" + }, + { + "specifier": "store/rv-redux-types", + "resolved": "pur-r-vstamm/src/store/rv-redux-types", + "package": null, + "line": 2, + "names": "{useAppSelector}" + }, + { + "specifier": "pur-ui-common", + "resolved": null, + "package": null, + "line": 3, + "names": "type {SvcResult}" + }, + { + "specifier": "generated/agstamm-interfaces", + "resolved": "pur-r-vstamm/src/generated/agstamm-interfaces", + "package": null, + "line": 4, + "names": "type {AgstammUseCase}" + } + ], + "declarations": [ + { + "name": "useAgstamm", + "kind": "hook", + "exported": true, + "startLine": 6, + "endLine": 15, + "members": [] + }, + { + "name": "useAgstammSelector", + "kind": "hook", + "exported": true, + "startLine": 17, + "endLine": 19, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "useAgstamm", + "expression": "selectAgstamm", + "symbol": "selectAgstamm", + "file": "pur-r-vstamm/src/store/slices/agstammSlice", + "decl": "selectAgstamm", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 7, + "kind": "call" + }, + { + "fromDecl": "useAgstamm", + "expression": "loadBrokerFromServer", + "symbol": "loadBrokerFromServer", + "file": "pur-r-vstamm/src/store/slices/agstammSlice", + "decl": "loadBrokerFromServer", + "package": null, + "receiver": null, + "member": null, + "actionType": "agstamm/loadBroker", + "line": 13, + "kind": "call" + }, + { + "fromDecl": "useAgstammSelector", + "expression": "useAppSelector", + "symbol": "useAppSelector", + "file": "pur-r-vstamm/src/store/rv-redux-types", + "decl": "useAppSelector", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 18, + "kind": "call" + }, + { + "fromDecl": "useAgstammSelector", + "expression": "selector", + "symbol": "selector", + "file": "pur-r-vstamm/src/store/hooks/useAgstamm", + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 18, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [ + { + "fromDecl": "useAgstammSelector", + "path": [ + "broker", + "agstammUseCaseSvcResult" + ], + "line": 18, + "via": "useAppSelector" + } + ], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [] + }, + "pur-r-vstamm/src/rvTheme.ts": { + "imports": [ + { + "specifier": "@mui/material", + "resolved": null, + "package": null, + "line": 1, + "names": "{ createTheme }" + } + ], + "declarations": [ + { + "name": "PRIMARY", + "kind": "const", + "exported": true, + "startLine": 3, + "endLine": 3, + "members": [] + }, + { + "name": "PRIMARY_DARK", + "kind": "const", + "exported": false, + "startLine": 4, + "endLine": 4, + "members": [] + }, + { + "name": "WHITE", + "kind": "const", + "exported": true, + "startLine": 5, + "endLine": 5, + "members": [] + }, + { + "name": "rvTheme", + "kind": "const", + "exported": true, + "startLine": 7, + "endLine": 14, + "members": [] + }, + { + "name": "rvDarkTheme", + "kind": "const", + "exported": true, + "startLine": 16, + "endLine": 21, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "rvTheme", + "expression": "createTheme", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 7, + "kind": "call" + }, + { + "fromDecl": "rvDarkTheme", + "expression": "createTheme", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 16, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [], + "bindings": [], + "styles": [], + "tokenRefs": [], + "themeTokens": [ + { + "token": "palette.primary.main", + "kind": "path", + "value": "#005CA9", + "constant": "PRIMARY", + "line": 9, + "variants": 2 + }, + { + "token": "palette.primary.dark", + "kind": "path", + "value": "#0054A2", + "constant": "PRIMARY_DARK", + "line": 9, + "variants": 2 + }, + { + "token": "palette.primary.contrastText", + "kind": "path", + "value": "#FFFFFF", + "constant": null, + "line": 9, + "variants": 1 + }, + { + "token": "palette.background.paper", + "kind": "path", + "value": "#FFFFFF", + "constant": "WHITE", + "line": 10, + "variants": 1 + }, + { + "token": "shape.borderRadius", + "kind": "path", + "value": "4", + "constant": null, + "line": 12, + "variants": 1 + }, + { + "token": "typography.fontFamily", + "kind": "path", + "value": "OpenSans", + "constant": null, + "line": 13, + "variants": 1 + }, + { + "token": "typography.h1.fontWeight", + "kind": "path", + "value": "300", + "constant": null, + "line": 13, + "variants": 1 + }, + { + "token": "palette.mode", + "kind": "path", + "value": "dark", + "constant": null, + "line": 18, + "variants": 1 + }, + { + "token": "PRIMARY", + "kind": "constant", + "value": "#005CA9", + "constant": null, + "line": 3 + }, + { + "token": "WHITE", + "kind": "constant", + "value": "#FFFFFF", + "constant": null, + "line": 5 + } + ] + }, + "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx": { + "imports": [ + { + "specifier": "store/hooks/useAgstamm", + "resolved": "pur-r-vstamm/src/store/hooks/useAgstamm", + "package": null, + "line": 1, + "names": "{useAgstammSelector}" + }, + { + "specifier": "rvTheme", + "resolved": "pur-r-vstamm/src/rvTheme", + "package": null, + "line": 2, + "names": "{PRIMARY}" + }, + { + "specifier": "@mui/material", + "resolved": null, + "package": null, + "line": 3, + "names": "{Box, useTheme}" + } + ], + "declarations": [ + { + "name": "HistorieDrawer", + "kind": "component", + "exported": true, + "startLine": 5, + "endLine": 13, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "HistorieDrawer", + "expression": "useAgstammSelector", + "symbol": "useAgstammSelector", + "file": "pur-r-vstamm/src/store/hooks/useAgstamm", + "decl": "useAgstammSelector", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 6, + "kind": "call" + }, + { + "fromDecl": "HistorieDrawer", + "expression": "useTheme", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 7, + "kind": "call" + }, + { + "fromDecl": "HistorieDrawer", + "expression": "Box", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 9, + "kind": "jsx" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [ + { + "fromDecl": "HistorieDrawer", + "path": [ + "broker", + "agstammUseCaseSvcResult", + "result", + "purMode" + ], + "line": 6, + "via": "useAgstammSelector" + } + ], + "bindings": [], + "styles": [ + { + "fromDecl": "HistorieDrawer", + "styleKind": "sx", + "element": "Box", + "line": 9, + "col": 14, + "properties": [ + "color", + "borderColor", + "height", + "&:hover.background", + "mt" + ], + "literals": [ + "17px" + ], + "dynamic": false, + "spread": false, + "tokens": [ + { + "token": "PRIMARY", + "property": "color", + "line": 9 + }, + { + "token": "PRIMARY", + "property": "borderColor", + "line": 9 + }, + { + "token": "palette.background.paper", + "property": "&:hover.background", + "line": 9 + } + ] + } + ], + "tokenRefs": [ + { + "fromDecl": "HistorieDrawer", + "token": "palette.primary.main", + "context": "borderColor", + "line": 9 + } + ], + "themeTokens": [] + }, + "pur-r-vstamm/src/components/Agstamm/AgstammPage.tsx": { + "imports": [ + { + "specifier": "react", + "resolved": null, + "package": null, + "line": 1, + "names": "React, {useEffect}" + }, + { + "specifier": "@mui/material", + "resolved": null, + "package": null, + "line": 2, + "names": "{Box, Typography}" + }, + { + "specifier": "@mui/material/styles", + "resolved": null, + "package": null, + "line": 3, + "names": "{styled}" + }, + { + "specifier": "pur-ui-common", + "resolved": null, + "package": null, + "line": 4, + "names": "{SmartInput, theme}" + }, + { + "specifier": "store/hooks/useAgstamm", + "resolved": "pur-r-vstamm/src/store/hooks/useAgstamm", + "package": null, + "line": 5, + "names": "{useAgstamm}" + }, + { + "specifier": "store/rv-redux-types", + "resolved": "pur-r-vstamm/src/store/rv-redux-types", + "package": null, + "line": 6, + "names": "{useAppSelector}" + }, + { + "specifier": "store/store", + "resolved": "pur-r-vstamm/src/store/store", + "package": null, + "line": 7, + "names": "{store}" + }, + { + "specifier": "store/slices/agstammSlice", + "resolved": "pur-r-vstamm/src/store/slices/agstammSlice", + "package": null, + "line": 8, + "names": "{resetIfIdle}" + }, + { + "specifier": "generated/agstamm-interfaces", + "resolved": "pur-r-vstamm/src/generated/agstamm-interfaces", + "package": null, + "line": 9, + "names": "{AgstammUseCaseField}" + }, + { + "specifier": "./HistorieDrawer", + "resolved": "pur-r-vstamm/src/components/Agstamm/HistorieDrawer", + "package": null, + "line": 10, + "names": "{HistorieDrawer}" + } + ], + "declarations": [ + { + "name": "Panel", + "kind": "styled", + "exported": false, + "startLine": 14, + "endLine": 16, + "members": [] + }, + { + "name": "useVermnr", + "kind": "hook", + "exported": true, + "startLine": 18, + "endLine": 21, + "members": [] + }, + { + "name": "AgstammPage", + "kind": "component", + "exported": true, + "startLine": 23, + "endLine": 42, + "members": [] + } + ], + "calls": [ + { + "fromDecl": "Panel", + "expression": "styled(Box)", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 14, + "kind": "call" + }, + { + "fromDecl": "Panel", + "expression": "styled", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 14, + "kind": "call" + }, + { + "fromDecl": "Panel", + "expression": "theme.spacing", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "spacing", + "actionType": null, + "line": 15, + "kind": "call" + }, + { + "fromDecl": "useVermnr", + "expression": "Number", + "symbol": "Number", + "file": null, + "decl": null, + "package": "lib", + "receiver": null, + "member": null, + "actionType": null, + "line": 20, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "useAgstamm", + "symbol": "useAgstamm", + "file": "pur-r-vstamm/src/store/hooks/useAgstamm", + "decl": "useAgstamm", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 24, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "useVermnr", + "symbol": "useVermnr", + "file": "pur-r-vstamm/src/components/Agstamm/AgstammPage", + "decl": "useVermnr", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 25, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "useAppSelector", + "symbol": "useAppSelector", + "file": "pur-r-vstamm/src/store/rv-redux-types", + "decl": "useAppSelector", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 26, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "useAppSelector", + "symbol": "useAppSelector", + "file": "pur-r-vstamm/src/store/rv-redux-types", + "decl": "useAppSelector", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 27, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "useEffect", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 28, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "dispatchLoadBrokerFromServer", + "symbol": "dispatchLoadBrokerFromServer", + "file": "pur-r-vstamm/src/components/Agstamm/AgstammPage", + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 29, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "store.getState", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "getState", + "actionType": null, + "line": 30, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "store.dispatch", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "dispatch", + "actionType": null, + "line": 31, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "resetIfIdle", + "symbol": "resetIfIdle", + "file": "pur-r-vstamm/src/store/slices/agstammSlice", + "decl": "agstammSlice", + "package": null, + "receiver": null, + "member": null, + "actionType": "agstamm/resetIfIdle", + "line": 31, + "kind": "call" + }, + { + "fromDecl": "AgstammPage", + "expression": "Panel", + "symbol": "Panel", + "file": "pur-r-vstamm/src/components/Agstamm/AgstammPage", + "decl": "Panel", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 35, + "kind": "jsx" + }, + { + "fromDecl": "AgstammPage", + "expression": "Typography", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 37, + "kind": "jsx" + }, + { + "fromDecl": "AgstammPage", + "expression": "SmartInput", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 38, + "kind": "jsx" + }, + { + "fromDecl": "AgstammPage", + "expression": "HistorieDrawer", + "symbol": "HistorieDrawer", + "file": "pur-r-vstamm/src/components/Agstamm/HistorieDrawer", + "decl": "HistorieDrawer", + "package": null, + "receiver": null, + "member": null, + "actionType": null, + "line": 39, + "kind": "jsx" + }, + { + "fromDecl": "default", + "expression": "React.memo", + "symbol": null, + "file": null, + "decl": null, + "package": null, + "receiver": null, + "member": "memo", + "actionType": null, + "line": 44, + "kind": "call" + } + ], + "endpoints": [], + "slices": [], + "store": null, + "stateAccesses": [ + { + "fromDecl": "AgstammPage", + "path": [ + "broker", + "requestStatus" + ], + "line": 26, + "via": "useAppSelector" + }, + { + "fromDecl": "AgstammPage", + "path": [ + "broker", + "agstammUseCaseSvcResult" + ], + "line": 27, + "via": "useAppSelector" + }, + { + "fromDecl": "AgstammPage", + "path": [ + "broker", + "requestStatus" + ], + "line": 30, + "via": "getState" + } + ], + "bindings": [ + { + "fromDecl": "AgstammPage", + "kind": "field", + "rootDto": "AgstammUseCase", + "ownerDto": "Broker", + "ownerFile": "pur-r-vstamm/src/generated/agstamm-interfaces", + "field": "ebene", + "path": "broker.ebene", + "partial": false, + "component": "SmartInput", + "attribute": "field", + "line": 38 + } + ], + "styles": [ + { + "fromDecl": "Panel", + "styleKind": "styled", + "element": "Box", + "line": 14, + "col": 15, + "properties": [ + "padding" + ], + "literals": [], + "dynamic": false, + "spread": false, + "tokens": [ + { + "token": "spacing", + "property": "padding", + "line": 15 + } + ] + }, + { + "fromDecl": "AgstammPage", + "styleKind": "sx", + "element": "Panel", + "line": 35, + "col": 16, + "properties": [ + "mt" + ], + "literals": [], + "dynamic": false, + "spread": false, + "tokens": [] + }, + { + "fromDecl": "AgstammPage", + "styleKind": "style", + "element": "Typography", + "line": 37, + "col": 25, + "properties": [ + "color" + ], + "literals": [], + "dynamic": false, + "spread": false, + "tokens": [ + { + "token": "palette.primary.dark", + "property": "color", + "line": 37 + } + ] + } + ], + "tokenRefs": [], + "themeTokens": [] + } + } +} \ No newline at end of file diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/AgstammPage.tsx b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/AgstammPage.tsx new file mode 100644 index 0000000..b4bd51f --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/AgstammPage.tsx @@ -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 ( + + {/* JSX comment */} + Agstamm + + + + ) +} + +export default React.memo(AgstammPage) diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx new file mode 100644 index 0000000..3b6f66f --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx @@ -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 ( + + Historie {purMode} + + ) +} diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-endpoints.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-endpoints.ts new file mode 100644 index 0000000..b126e2f --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-endpoints.ts @@ -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 { + get: (params: P) => Promise; +} + +interface PostMethod { + post: (body: B) => Promise; +} + +export class AgstammControllerEndpoint implements Endpoint { + baseUrl: string = '/agstamm/ui/'; + public searchBroker: GetMethodWithParameters, { vermnr: number }> = { + get: (params) => { + return COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${params.vermnr}`)); + }, + }; + public saveBroker: PostMethod, API.AgstammUseCase> = { + post: (body) => { + return COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body); + }, + }; +} + +export type AgstammAction = 'ADD' | 'UPDATE'; + +export enum AgstammKind { A, B } diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-interfaces.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-interfaces.ts new file mode 100644 index 0000000..1e88264 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/agstamm-interfaces.ts @@ -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 { + $$parent?: Fields; + $$name: string = ''; + + get(): string { + return this.$$parent?.get() ? this.$$parent.get() + '.' + this.$$name : this.$$name; + } +} + +export class BrokerFields extends Fields { + vermnr: Fields = new Fields(); + ebene: Fields = new Fields(); +} + +export class AgstammUseCaseFields extends Fields { + broker: BrokerFields = new BrokerFields(); + brokerName: Fields = new Fields(); +} + +export const AgstammUseCaseField: AgstammUseCaseFields = new AgstammUseCaseFields(); diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.gen.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.gen.ts new file mode 100644 index 0000000..394e912 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.gen.ts @@ -0,0 +1 @@ +export const client = {get: (opts: { url: string }): Promise => Promise.reject(opts.url) as Promise}; diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.ts new file mode 100644 index 0000000..069d60c --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/client.ts @@ -0,0 +1,9 @@ +export type TDataShape = { path?: Record }; +export type Options = TData & { + throwOnError?: ThrowOnError +}; +export type RequestResult = Promise<{ + data: TData; + error: TError; + throwOnError: ThrowOnError +}>; diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/sdk.gen.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/sdk.gen.ts new file mode 100644 index 0000000..7ab5284 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/sdk.gen.ts @@ -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 = + Options2 + & { client?: unknown }; + +/** + * Historie eines Vermittlers + */ +export const getPurRVstammV1AgstammUiHistorieByVermnr = (options: Options): RequestResult => { + return (options.client ?? client).get({ + url: '/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}', + ...options + }); +}; diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/types.gen.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/types.gen.ts new file mode 100644 index 0000000..8bcaa5d --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/generated/client/types.gen.ts @@ -0,0 +1,2 @@ +export type GetPurRVstammV1AgstammUiHistorieByVermnrData = { path: { vermnr: number } }; +export type GetPurRVstammV1AgstammUiHistorieByVermnrResponses = { 200: { result?: { eintraege: string[] } } }; diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/rvTheme.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/rvTheme.ts new file mode 100644 index 0000000..f92bd43 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/rvTheme.ts @@ -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'}, + }, +}) diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/hooks/useAgstamm.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/hooks/useAgstamm.ts new file mode 100644 index 0000000..2159cbc --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/hooks/useAgstamm.ts @@ -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 = (selector: (useCase: SvcResult | undefined) => R): R => { + return useAppSelector((state) => selector(state.broker.agstammUseCaseSvcResult)) +} diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/rv-redux-types.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/rv-redux-types.ts new file mode 100644 index 0000000..d271314 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/rv-redux-types.ts @@ -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() diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/slices/agstammSlice.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/slices/agstammSlice.ts new file mode 100644 index 0000000..c79a341 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/slices/agstammSlice.ts @@ -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 +} + +const initialState: AgstammState = {requestStatus: 'idle'} + +const agstammSlice = createSlice({ + name: 'agstamm', + initialState, + reducers: { + updateAgstammUseCaseSvcResult(state, action: PayloadAction | 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, number>( + `agstamm/loadBroker`, + (vermnr) => agstammUiApi.searchBroker.get({vermnr: String(vermnr)}), +) + +export const saveBrokerToServer = createAppAsyncThunk, AgstammUseCase>( + 'agstamm/saveBroker', + (useCase: AgstammUseCase) => agstammUiApi.saveBroker.post(useCase), +) + +export const selectAgstamm = (state: RvRootState) => state.broker.agstammUseCaseSvcResult + +export const loadHistorie = createAppAsyncThunk( + '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 diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/store.ts b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/store.ts new file mode 100644 index 0000000..786438a --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-r-vstamm/src/store/store.ts @@ -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 diff --git a/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-ui/index.css b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-ui/index.css new file mode 100644 index 0000000..b8d74b8 --- /dev/null +++ b/ac-parser-typescript/src/test/resources/fixtures/typescript/pur-ui/index.css @@ -0,0 +1,9 @@ +/* fonts */ +@font-face { + font-family: 'OpenSans'; + src: url('/fonts/OpenSans.woff2') format('woff2'); /* not a comment: */ +} + +body { + margin: 0; +} diff --git a/docker-compose.yml b/docker-compose.yml index 3d9a221..02f3c11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/pom.xml b/pom.xml index 3b3a2ca..e4dd476 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,9 @@ pom AgenticCode - Parsing, storing, and agentically querying source code (Natural/Software AG and Java) + Parsing, storing, and agentically querying source code (Natural/Software AG, Java and + TypeScript/React) + ac-mvn-plugins @@ -18,6 +20,7 @@ ac-parser-core ac-parser-natural ac-parser-java + ac-parser-typescript ac-neo4j-store ac-code-server ac-cli @@ -74,6 +77,11 @@ ac-parser-java ${project.version} + + com.agenticcode + ac-parser-typescript + ${project.version} + com.agenticcode ac-neo4j-store diff --git a/x-docs/agent-api-system-prompt.md b/x-docs/agent-api-system-prompt.md index 264ed59..9bb9981 100644 --- a/x-docs/agent-api-system-prompt.md +++ b/x-docs/agent-api-system-prompt.md @@ -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: []`: 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.`), its +state keys are `FIELD`s named `.` — so `variables/./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): `` +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.` with values. "Where is token X used" = +`theme//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` diff --git a/x-docs/agent-api-usage-ac-implementation.md b/x-docs/agent-api-usage-ac-implementation.md index 02fce57..0544ec6 100644 --- a/x-docs/agent-api-usage-ac-implementation.md +++ b/x-docs/agent-api-usage-ac-implementation.md @@ -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 ` | -| `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 --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 ` 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 ''`, 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 ` | -| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING ` 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 ` 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 ` | +| `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 --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 ` 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/./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 (``), 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 ''`, 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 ` | +| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING ` 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 ` 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=` and `module=` (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=` 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=` (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=` (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 `` 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:`. +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 = `. 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 `.`** (`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.`; `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 `.` (`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** `.` 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 = /updateX` and is a cross-module `CALLS` edge to the +slice module with `calleeMethod = /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 [--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/./reads|writes` and +`search/identifier?name=.` 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`, 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()`, members +`broker = new BrokerFields(this, "broker")`, list members as `keyTableList = (index?) => +new KeyTableDOFields(...)`). A path expression on it — `AgstammUseCaseField.broker.ebene` on a +``, ``, 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`): 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 `.` (`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/./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.` 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.` 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 +`.@:

`, 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.` 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.` (`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 diff --git a/x-docs/ast-graph-schema.md b/x-docs/ast-graph-schema.md index 3f06832..793cadb 100644 --- a/x-docs/ast-graph-schema.md +++ b/x-docs/ast-graph-schema.md @@ -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
.ts / .tsx file — name = root-relative path without extension] +C[MODULE
.css file — name keeps .css] +F[FUNCTION
function / component / hook / thunk / styled / class] +D[DATA_STRUCTURE
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,
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 = .`. +- **`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
slice file] + S[STORE_SLICE
name = reducer key, sliceName, stateType, store=true] + SF[FIELD
name = key.field, dataType, optional, store=true, slice, field] + R[FUNCTION
kind=reducer, reducerKind=reducer|case|matcher|default,
name = action type, slice, trigger, actionType] +T[FUNCTION
kind=thunk] +C[FUNCTION
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 `.` (`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 `.` 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.` (`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.` (`via=theme`, `property`) and + `FUNCTION -REFERENCES-> FIELD theme.` (`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 diff --git a/x-docs/features.md b/x-docs/features.md index 649c76d..74c4a43 100644 --- a/x-docs/features.md +++ b/x-docs/features.md @@ -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`. Bindings are typed + path expressions (``, 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`), 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 + `.` 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 + `/`, 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` 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`); `context` is not extended (the accesses endpoint and + `variables/./reads|writes` answer). Sidecar facts contract bumped to **version 2** + (`slices`, `store`, `stateAccesses`, `calls[].actionType`). Cross-file reads are placeholders + `.` (`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 `buildURL(`…`)`, `{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 (`//v`) 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=`), 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 `@`. + - `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:` 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) diff --git a/x-docs/roadmap.md b/x-docs/roadmap.md index 5cb8965..8d6a009 100644 --- a/x-docs/roadmap.md +++ b/x-docs/roadmap.md @@ -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=` 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** diff --git a/x-scripts/verify-api.sh b/x-scripts/verify-api.sh index 17dd5e1..e6770a8 100755 --- a/x-scripts/verify-api.sh +++ b/x-scripts/verify-api.sh @@ -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"