Natjav
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
# Build context for ac-code-server's Dockerfile.jvm is the repository root (item 192: the image also
|
||||
# carries ac-parser-typescript/sidecar). Send only what the Dockerfile COPYs — never .git, target/
|
||||
# trees, ac-ui/node_modules or the sidecar's own node_modules (npm ci installs those in the image).
|
||||
*
|
||||
!ac-code-server/target/quarkus-app/
|
||||
!ac-parser-typescript/sidecar/package.json
|
||||
!ac-parser-typescript/sidecar/package-lock.json
|
||||
!ac-parser-typescript/sidecar/extract.mjs
|
||||
61
README.md
61
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
|
||||
|
||||
@@ -354,6 +358,7 @@ agenticcode> exit
|
||||
ac project create upms /path/to/sources -l natural -d "UPMS legacy"
|
||||
ac project list
|
||||
ac project update upms -d "New description"
|
||||
ac project rename upms upms_alt # every node, override and counterpart reference follows
|
||||
ac project delete upms
|
||||
|
||||
# Ingest / refresh
|
||||
@@ -390,15 +395,15 @@ ac dynamic-calls reset --file X.nat --line 403 -p upms
|
||||
|
||||
`ac --help` lists everything; `ac <command> --help` shows a command's options. The main commands:
|
||||
|
||||
| Group | Commands |
|
||||
|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Project & ingest** | `project create\|update\|list\|delete`, `refresh [MODULE] [--deep] [--max-depth N] [--max-nodes N] [--neighborhood]`, `recreate` |
|
||||
| **Call graph** | `callers`, `callees`, `call-tree`, `context`, `digest`, `functions`, `function-callers`, `function-overrides`, `ego-graph`, `modules`, `loc` |
|
||||
| **Data & DB** | `db-accesses`, `sql-statements`, `workfile-accesses`, `payload`, `dispatch-table`, `module-data-structures`, `data-structure-fields`, `db-table-columns`, `entity-columns` |
|
||||
| **Search & dataflow** | `search-identifier`, `search-value`, `search-source`, `search-annotation`, `variable-reads`, `variable-writes`, `flow-forward`, `flow-backward`, `field-flow` |
|
||||
| **Source & nodes** | `module-source`, `file-source`, `node-source`, `inspect-node` |
|
||||
| **Dynamic dispatch** | `dynamic-calls unresolved\|overrides\|set\|reset` |
|
||||
| **Session / misc** | `connect`, `use`, `version` |
|
||||
| Group | Commands |
|
||||
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Project & ingest** | `project create\|update\|list\|delete`, `refresh [MODULE] [--deep] [--max-depth N] [--max-nodes N] [--neighborhood]`, `recreate` |
|
||||
| **Call graph** | `callers`, `callees`, `call-tree`, `context`, `digest`, `functions`, `function-callers`, `function-overrides`, `ego-graph`, `modules`, `loc` |
|
||||
| **Data & DB** | `db-accesses`, `sql-statements`, `workfile-accesses`, `payload`, `dispatch-table`, `module-data-structures`, `data-structure-fields`, `db-table-columns`, `entity-columns`, `store`, `store-accesses`, `bindings`, `theme`, `theme-usages`, `styles` |
|
||||
| **Search & dataflow** | `search-identifier`, `search-value`, `search-source`, `search-annotation`, `variable-reads`, `variable-writes`, `flow-forward`, `flow-backward`, `field-flow` |
|
||||
| **Source & nodes** | `module-source`, `file-source`, `node-source`, `inspect-node` |
|
||||
| **Dynamic dispatch** | `dynamic-calls unresolved\|overrides\|set\|reset` |
|
||||
| **Session / misc** | `connect`, `use`, `version` |
|
||||
|
||||
**Useful options.** Most list commands accept `--limit` / `--offset` for pagination. `callers` and `callees` take
|
||||
`--scope external` (module-to-module `CALLNAT`/inheritance — the default for `callers`) or `--scope internal` (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,18 @@ import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* Groups project management subcommands: create, update, delete, recreate, show, list.
|
||||
* Groups project management subcommands: create, update, delete, recreate, rename, show, list.
|
||||
*/
|
||||
@Command(
|
||||
name = "project",
|
||||
mixinStandardHelpOptions = true,
|
||||
description = "Create, update, delete, recreate, show or list projects",
|
||||
description = "Create, update, delete, recreate, rename, show or list projects",
|
||||
subcommands = {
|
||||
ProjectCommand.CreateCommand.class,
|
||||
ProjectCommand.UpdateCommand.class,
|
||||
ProjectCommand.DeleteCommand.class,
|
||||
ProjectCommand.RecreateCommand.class,
|
||||
ProjectCommand.RenameCommand.class,
|
||||
ProjectCommand.ShowCommand.class,
|
||||
ProjectCommand.ListCommand.class
|
||||
}
|
||||
@@ -59,13 +60,17 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Option(names = {"-l", "--language"}, required = true,
|
||||
description = "Project source language (natural/java/natjav)")
|
||||
description = "Project source language (natural/java/natjav/typescript)")
|
||||
String language;
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
|
||||
String userExitDir = "";
|
||||
@@ -75,12 +80,13 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
|
||||
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
|
||||
language, generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir)));
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "update", mixinStandardHelpOptions = true,
|
||||
description = "Update a project's description, root, exclude-dirs, language and/or generated/user-exit dirs (unset fields are left unchanged)")
|
||||
description = "Update a project's description, root, exclude-dirs, language, counterparts and/or generated/user-exit dirs (unset fields are left unchanged)")
|
||||
static final class UpdateCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@@ -97,13 +103,17 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
|
||||
List<String> excludeDirs = List.of();
|
||||
|
||||
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/natjav)")
|
||||
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/natjav/typescript)")
|
||||
String language = "";
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
|
||||
String userExitDir = "";
|
||||
@@ -116,7 +126,8 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
excludeDirs.isEmpty() ? null : excludeDirs,
|
||||
language.isBlank() ? null : language,
|
||||
generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir)));
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +181,28 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 202: rename a project in place (nodes, overrides and other projects' counterparts follow).
|
||||
*/
|
||||
@Command(name = "rename", mixinStandardHelpOptions = true,
|
||||
description = "Rename a project; every node, override and counterpart reference follows")
|
||||
static final class RenameCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "0", description = "Current project name")
|
||||
String name;
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Parameters(index = "1", description = "New project name")
|
||||
String newName;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
return printResponse(apiClient().postJson("/api/projects/" + encode(name) + "/rename",
|
||||
java.util.Map.of("newName", newName)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 78: re-initialise a project from its own stored config — no need to look up and re-type
|
||||
* {@code root}/{@code language}/{@code generatedDir}/{@code userExitDir}, and no window in which they
|
||||
|
||||
@@ -14,5 +14,6 @@ import java.util.List;
|
||||
* leave the stored values unchanged.
|
||||
*/
|
||||
record ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable List<String> counterparts) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java
Normal file
26
ac-cli/src/main/java/com/agenticcode/cli/StoreCommand.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Item 194: the frontend Redux store — its slices, their state keys and access counts.
|
||||
*/
|
||||
@Command(name = "store", mixinStandardHelpOptions = true,
|
||||
description = "List the project's Redux store slices (reducer key, state keys with types and read/write counts, reducers)")
|
||||
final class StoreCommand extends AbstractProjectCommand {
|
||||
|
||||
@Option(names = "--slice", description = "Only this slice (reducer key or RTK slice name)")
|
||||
@Nullable String slice;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
return printResponse(apiClient().get(appendQuery(projectPath() + "/store", "slice", slice)));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java
Normal file
50
ac-cli/src/main/java/com/agenticcode/cli/StylesCommand.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Item 196: the style inventory — sx/style/styled blocks and CSS rules with keys, literals and theme tokens.
|
||||
*/
|
||||
@Command(name = "styles", mixinStandardHelpOptions = true,
|
||||
description = "List style blocks (sx, style, styled, css rules) with their CSS keys, hard-coded literals and theme tokens")
|
||||
final class StylesCommand extends AbstractProjectCommand {
|
||||
|
||||
@Option(names = "--module", description = "Only blocks in this module (identity or short name)")
|
||||
@Nullable String module;
|
||||
|
||||
@Option(names = "--kind", description = "sx | style | styled | css (default: all)")
|
||||
@Nullable String kind;
|
||||
|
||||
@Option(names = "--with-literals", description = "Only blocks with hard-coded colour/length literals (what bypasses the theme)")
|
||||
boolean withLiterals;
|
||||
|
||||
@Option(names = "--count-only", description = "Print only how many rows match, instead of the rows themselves")
|
||||
boolean countOnly;
|
||||
|
||||
@Option(names = "--limit", description = "Max items to return (default: all)")
|
||||
int limit = -1;
|
||||
|
||||
@Option(names = "--offset", description = "Items to skip")
|
||||
int offset = -1;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = appendQuery(projectPath() + "/styles", "module", module);
|
||||
path = appendQuery(path, "kind", kind);
|
||||
if (withLiterals) {
|
||||
path = appendQuery(path, "withLiterals", "true");
|
||||
}
|
||||
if (countOnly) {
|
||||
path = appendQuery(path, "countOnly", "true");
|
||||
}
|
||||
path = appendQuery(appendQuery(path, "limit", limit), "offset", offset);
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java
Normal file
29
ac-cli/src/main/java/com/agenticcode/cli/ThemeCommand.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.agenticcode.cli;
|
||||
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
/**
|
||||
* Item 196: the MUI theme's tokens with their project-side use counts.
|
||||
*/
|
||||
@Command(name = "theme", mixinStandardHelpOptions = true,
|
||||
description = "List the theme tokens (createTheme leaves + theme constants) with values and project use counts; undeclared tokens the code reads are listed with declared=false")
|
||||
final class ThemeCommand extends AbstractProjectCommand {
|
||||
|
||||
@Option(names = "--unused", description = "Only tokens with no project reference (MUI's own use of a token is not visible)")
|
||||
boolean unused;
|
||||
|
||||
@Override
|
||||
public Integer call() throws Exception {
|
||||
try {
|
||||
String path = projectPath() + "/theme";
|
||||
if (unused) {
|
||||
path = appendQuery(path, "unused", "true");
|
||||
}
|
||||
return printResponse(apiClient().get(path));
|
||||
} catch (IllegalStateException e) {
|
||||
System.err.println(e.getMessage());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@
|
||||
server.url=http://localhost:8787
|
||||
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
|
||||
version=346
|
||||
version=350
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-natjav</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-neo4j-store</artifactId>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -732,7 +732,7 @@ public class AnalysisResource {
|
||||
@PathParam("function") String function,
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.functionCallers(project, resolvedName, function, anySource(sourceFile))
|
||||
.map(resp -> Response.ok(resp).build()));
|
||||
.map(resp -> callRefResponse(resp, null)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@@ -759,9 +759,18 @@ public class AnalysisResource {
|
||||
@QueryParam("sourceFile") @Nullable String sourceFile) {
|
||||
return withIngestedModule(project, name, sourceFile, resolvedName -> graphRepository.callees(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset),
|
||||
resolveInterfaces != null && resolveInterfaces)
|
||||
.map(resp -> namesOnly(fields)
|
||||
? ok(callRefNames(resp))
|
||||
: Response.ok(resp).build()));
|
||||
.map(resp -> callRefResponse(resp, fields)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 181: callers/callees page at {@code limit=50} by default; the response says so in the
|
||||
* item-131 headers (and the body's {@code total}/{@code truncated}), for the names-only shape too.
|
||||
*/
|
||||
private Response callRefResponse(CallRefResponse resp, @Nullable String fields) {
|
||||
Response.ResponseBuilder builder = namesOnly(fields)
|
||||
? Response.fromResponse(ok(callRefNames(resp)))
|
||||
: Response.ok(resp);
|
||||
return builder.header(TOTAL_COUNT, resp.total()).header(TRUNCATED, resp.truncated()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -827,6 +836,191 @@ public class AnalysisResource {
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
private static @Nullable String blankToNull(@Nullable String s) {
|
||||
return s == null || s.isBlank() ? null : s.strip();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/counterparts")
|
||||
@Operation(summary = "Counterparts in another project (item 193)",
|
||||
description = "This project's outbound web-service calls, generated data structures and their fields, each "
|
||||
+ "with its COUNTERPART_OF twin in a counterpart project (project setting `counterparts`); "
|
||||
+ "`unmatched=true` lists only those without a twin - what is not served or mirrored yet.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Counterpart.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> counterparts(@PathParam("project") String project,
|
||||
@Parameter(description = "Narrow to one declaring module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "rest | dto | field; all when absent.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@Parameter(description = "Only rows without a counterpart.")
|
||||
@QueryParam("unmatched") @Nullable Boolean unmatched,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effKind = kind == null || kind.isBlank() ? null : kind.strip().toLowerCase(java.util.Locale.ROOT);
|
||||
if (effKind != null && !List.of("rest", "dto", "field").contains(effKind)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
|
||||
"Unknown kind '" + kind + "'; expected rest, dto or field"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.counterpartsPage(project, module, effKind,
|
||||
Boolean.TRUE.equals(unmatched), effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/theme")
|
||||
@Operation(summary = "The MUI theme's tokens with their project-side use counts (item 196)",
|
||||
description = "Every leaf of createTheme({..}) (`palette.primary.dark`, value folded through constants) and every "
|
||||
+ "exported string constant of the theme file, plus the tokens the code reads that no theme declares "
|
||||
+ "(`declared=false`: MUI defaults such as palette.grey.200, or typos). `uses` counts project references only; "
|
||||
+ "MUI's own use of a token is invisible, so `unused=true` means 'not referenced by project code', not 'dead'.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeToken.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> theme(@PathParam("project") String project,
|
||||
@Parameter(description = "Only tokens with no project reference.")
|
||||
@QueryParam("unused") @Nullable Boolean unused) {
|
||||
return withProject(project, () -> graphRepository.themeTokens(project, Boolean.TRUE.equals(unused)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/theme/{token}/usages")
|
||||
@Operation(summary = "Where one theme token is read (item 196)",
|
||||
description = "Style blocks reading the token (with `styleKind`, `element` and the CSS `property` it feeds) and plain "
|
||||
+ "reads (`context` = the JSX attribute or `code`). `token` is the dotted path (`palette.primary.dark`) or a constant name (`PRIMARY`).")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeUsage.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or token not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> themeUsages(@PathParam("project") String project, @PathParam("token") String token) {
|
||||
return withProject(project, () -> graphRepository.themeUsages(project, token).chain(rows -> {
|
||||
if (rows.isEmpty()) {
|
||||
return graphRepository.themeTokens(project, false).map(all -> all.stream().anyMatch(t -> t.token().equals(token))
|
||||
? ok(rows)
|
||||
: ProjectResource.error(Response.Status.NOT_FOUND, "TOKEN_NOT_FOUND",
|
||||
"No theme token '" + token + "' in project '" + project + "'"));
|
||||
}
|
||||
return Uni.createFrom().item(ok(rows));
|
||||
}));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/styles")
|
||||
@Operation(summary = "The style inventory: sx/style/styled blocks and CSS rules (item 196)",
|
||||
description = "One row per style block with its CSS keys (`properties`, nested selectors flattened), hard-coded "
|
||||
+ "colours/lengths (`literals`) and the theme tokens it reads. `withLiterals=true` = only blocks that bypass "
|
||||
+ "the theme; `kind` = sx | style | styled | css; `module` = one component module.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StyleBlock.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> styles(@PathParam("project") String project,
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "sx | style | styled | css; all when absent.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@Parameter(description = "Only blocks with hard-coded colour/length literals.")
|
||||
@QueryParam("withLiterals") @Nullable Boolean withLiterals,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String trimmedKind = blankToNull(kind);
|
||||
String effKind = trimmedKind == null ? null : trimmedKind.toLowerCase(java.util.Locale.ROOT);
|
||||
if (effKind != null && !List.of("sx", "style", "styled", "css").contains(effKind)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
|
||||
"Unknown kind '" + kind + "'; expected sx, style, styled or css"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.stylesPage(project, blankToNull(module), effKind,
|
||||
Boolean.TRUE.equals(withLiterals), effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/bindings")
|
||||
@Operation(summary = "DTO field bindings: which component reads/writes which backend field (item 195)",
|
||||
description = "Every use of a generated `Fields` path object (`<SmartInput field={AgstammUseCaseField.broker.ebene}>`) "
|
||||
+ "as one row: the binding function, the DTO field it names (`dto.field`, full `path` from `rootDto`), "
|
||||
+ "READS or WRITES (input components write), and the field's COUNTERPART_OF twin in the backend project. "
|
||||
+ "`dto`/`field`/`module`/`mode`/`partial` narrow the list.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Binding.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> bindings(@PathParam("project") String project,
|
||||
@Parameter(description = "Only fields of this DTO (the declaring interface, e.g. Broker).")
|
||||
@QueryParam("dto") @Nullable String dto,
|
||||
@Parameter(description = "Only this field name.")
|
||||
@QueryParam("field") @Nullable String field,
|
||||
@Parameter(description = "reads | writes; both when absent.")
|
||||
@QueryParam("mode") @Nullable String mode,
|
||||
@Parameter(description = "Only bindings in this module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "true: only partial paths (rooted at a prop/local); false: only full paths.")
|
||||
@QueryParam("partial") @Nullable Boolean partial,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
|
||||
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
|
||||
"Unknown mode '" + mode + "'; expected reads or writes"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.bindingsPage(project, blankToNull(dto), blankToNull(field), effMode,
|
||||
blankToNull(module), partial, effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/store")
|
||||
@Operation(summary = "The frontend Redux store: its slices, state keys and access counts (item 194)",
|
||||
description = "One row per createSlice mounted in the project's store, named by its reducer key (`state.<slice>`), "
|
||||
+ "with the top-level state keys (type, optional, read/write site counts), the number of reducer functions "
|
||||
+ "and the total access sites. `slice` narrows to one slice by reducer key or RTK slice name.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreSlice.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> store(@PathParam("project") String project,
|
||||
@Parameter(description = "Reducer key or slice name; all slices when absent.")
|
||||
@QueryParam("slice") @Nullable String slice) {
|
||||
String effSlice = slice == null || slice.isBlank() ? null : slice.strip();
|
||||
return withProject(project, () -> graphRepository.storeSlices(project, effSlice).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/store/{slice}/accesses")
|
||||
@Operation(summary = "Who reads and writes a store slice (item 194)",
|
||||
description = "Every access site of the slice's state: reducers (`functionKind=reducer`, the writers, `via=reducer`) "
|
||||
+ "and the components/hooks/thunks reading it through useAppSelector, a wrapper hook or getState() "
|
||||
+ "(`via` names the hook). `field` narrows to one state key, `mode` to READS or WRITES, `module` to one "
|
||||
+ "accessing module. `path` is the full sub-path as written; `field` is null for a whole-slice access.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreAccess.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or slice not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> storeAccesses(@PathParam("project") String project, @PathParam("slice") String slice,
|
||||
@Parameter(description = "One top-level state key; all when absent.")
|
||||
@QueryParam("field") @Nullable String field,
|
||||
@Parameter(description = "reads | writes; both when absent.")
|
||||
@QueryParam("mode") @Nullable String mode,
|
||||
@Parameter(description = "Only accesses from this module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
|
||||
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
|
||||
"Unknown mode '" + mode + "'; expected reads or writes"));
|
||||
}
|
||||
String effField = field == null || field.isBlank() ? null : field.strip();
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.storeSlices(project, slice).chain(slices -> {
|
||||
if (slices.isEmpty()) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.NOT_FOUND, "SLICE_NOT_FOUND",
|
||||
"No store slice '" + slice + "' in project '" + project + "'"));
|
||||
}
|
||||
return graphRepository.storeAccessesPage(project, slice, effField, effMode, module, effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page));
|
||||
}));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search/references")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ReferenceSite.class)))
|
||||
@@ -1152,7 +1346,7 @@ public class AnalysisResource {
|
||||
return withModule(project, name, sourceFile, resolvedName -> fanoutWarm(project,
|
||||
() -> graphRepository.callers(project, resolvedName, scope, effectiveLimit(limit), effectiveOffset(offset)),
|
||||
CallRefResponse::sourceFiles,
|
||||
resp -> namesOnly(fields) ? ok(callRefNames(resp)) : Response.ok(resp).build()));
|
||||
resp -> callRefResponse(resp, fields)));
|
||||
}
|
||||
|
||||
@GET
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import com.agenticcode.codeserver.service.ProjectIngestService;
|
||||
import com.agenticcode.codeserver.service.ProjectMetadataCache;
|
||||
import com.agenticcode.codeserver.service.ProjectRootResolver;
|
||||
import com.agenticcode.neo4jstore.graph.GraphRepository;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
@@ -38,14 +39,16 @@ public class ProjectResource {
|
||||
private final GraphRepository graphRepository;
|
||||
private final ProjectIngestService ingestService;
|
||||
private final ProjectRootResolver rootResolver;
|
||||
private final ProjectMetadataCache projectMetadataCache;
|
||||
private final boolean scanOnCreate;
|
||||
|
||||
public ProjectResource(GraphRepository graphRepository, ProjectIngestService ingestService,
|
||||
ProjectRootResolver rootResolver,
|
||||
ProjectRootResolver rootResolver, ProjectMetadataCache projectMetadataCache,
|
||||
@ConfigProperty(name = "agenticcode.tier1.scan-on-create", defaultValue = "true") boolean scanOnCreate) {
|
||||
this.graphRepository = graphRepository;
|
||||
this.ingestService = ingestService;
|
||||
this.rootResolver = rootResolver;
|
||||
this.projectMetadataCache = projectMetadataCache;
|
||||
this.scanOnCreate = scanOnCreate;
|
||||
}
|
||||
|
||||
@@ -61,7 +64,7 @@ public class ProjectResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "natjav");
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "natjav", "typescript");
|
||||
|
||||
private static @Nullable String normalize(@Nullable String value) {
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
@@ -136,8 +139,14 @@ public class ProjectResource {
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts == null ? List.of() : counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> {
|
||||
scanTier1(project);
|
||||
@@ -185,8 +194,14 @@ public class ProjectResource {
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> Response.ok().build();
|
||||
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
@@ -202,6 +217,48 @@ public class ProjectResource {
|
||||
return graphRepository.clearAll().replaceWith(Response.noContent().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 202: rename a project in place — every node, override and counterpart reference follows.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{project}/rename")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Rename a project (item 202)",
|
||||
description = "Rewrites the project key on every node and override and in other projects' counterparts lists. "
|
||||
+ "Batched; an interrupted rename is finished by re-running it.")
|
||||
@APIResponse(responseCode = "200", description = "The renamed project.")
|
||||
@APIResponse(responseCode = "400", description = "newName missing, blank or equal to the current name.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "409", description = "A project with the new name already exists.",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> rename(@PathParam("project") String project, RenameRequest request) {
|
||||
String newName = request == null || request.newName() == null ? "" : request.newName().trim();
|
||||
if (newName.isEmpty() || newName.equals(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "INVALID_REQUEST",
|
||||
"newName is required and must differ from the current name"));
|
||||
}
|
||||
return graphRepository.renameProject(project, newName)
|
||||
.flatMap(result -> switch (result) {
|
||||
case SUCCESS -> {
|
||||
projectMetadataCache.invalidate(project);
|
||||
projectMetadataCache.invalidate(newName);
|
||||
yield graphRepository.getProject(newName).map(info -> Response.ok(info).build());
|
||||
}
|
||||
case NOT_FOUND -> Uni.createFrom().item(error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
"Project '" + project + "' does not exist"));
|
||||
case CONFLICT -> Uni.createFrom().item(error(Response.Status.CONFLICT, "PROJECT_EXISTS",
|
||||
"Project '" + newName + "' already exists"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Body of {@code POST /api/projects/{project}/rename}.
|
||||
*/
|
||||
public record RenameRequest(@Nullable String newName) {
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{project}")
|
||||
@Operation(summary = "Delete a project")
|
||||
@@ -229,14 +286,33 @@ public class ProjectResource {
|
||||
*/
|
||||
public record ProjectRequest(@Nullable String description, @Nullable String root,
|
||||
@Nullable List<String> excludeDirs, @Nullable String language,
|
||||
@Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
@Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable List<String> counterparts) {
|
||||
|
||||
/**
|
||||
* Pre-item-193 shape (no counterparts).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor for callers that predate the item-47 fields (language /
|
||||
* generated / user-exit dir all absent).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
|
||||
this(description, root, excludeDirs, null, null, null);
|
||||
this(description, root, excludeDirs, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
|
||||
*/
|
||||
@Nullable List<String> normalizedCounterparts() {
|
||||
if (counterparts == null) {
|
||||
return null;
|
||||
}
|
||||
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
|
||||
}
|
||||
|
||||
List<String> excludeDirsOrEmpty() {
|
||||
|
||||
@@ -19,6 +19,11 @@ import com.agenticcode.parsernatural.CopycodeResolver;
|
||||
import com.agenticcode.parsernatural.NaturalCoarseScanner;
|
||||
import com.agenticcode.parsernatural.NaturalLineCounter;
|
||||
import com.agenticcode.parsernatural.NaturalParser;
|
||||
import com.agenticcode.parsertypescript.CssLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner;
|
||||
import com.agenticcode.parsertypescript.TypeScriptLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptParser;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
@@ -46,6 +51,12 @@ public class AstIngestService {
|
||||
private final NatJavParser natJavParser = new NatJavParser();
|
||||
private final NatJavCoarseScanner natJavScanner = new NatJavCoarseScanner();
|
||||
private final LineCounter natJavLineCounter = new NatJavLineCounter();
|
||||
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
|
||||
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
|
||||
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
|
||||
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
|
||||
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
|
||||
private final LineCounter cssLineCounter = new CssLineCounter();
|
||||
|
||||
/**
|
||||
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
|
||||
@@ -117,23 +128,24 @@ public class AstIngestService {
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
return parse(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE);
|
||||
return parse(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #parse(SourceFiles.Language, String, String, CopycodeResolver)}, with the project's
|
||||
* NatJav DDM index (item D) so a view resolves to its table and columns. Ignored by the other
|
||||
* languages, which have no DDM concept.
|
||||
* Parses {@code content} with the parser for {@code language}. Each language-specific context is
|
||||
* ignored by the others: {@code copycodes} serves Natural (item 46a), {@code ddms} the NatJav DDM
|
||||
* index (item D, so a view resolves to its table and columns), {@code typescript} TypeScript/CSS
|
||||
* (item 192). The switch is exhaustive on purpose: a language added without a parser must fail to
|
||||
* compile, not silently hand its files to another parser and store a plausible-looking but wrong
|
||||
* graph.
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, NatJavDdmResolver ddms) {
|
||||
// Exhaustive switch, not an if/else with a Natural fallthrough: a language added without a
|
||||
// parser must fail to compile, not silently hand its files to the Natural parser and store
|
||||
// a plausible-looking but wrong graph.
|
||||
CopycodeResolver copycodes, NatJavDdmResolver ddms, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaParser.parse(sourceFile, content);
|
||||
case NATJAV -> natJavParser.parse(sourceFile, content, ddms);
|
||||
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,19 +164,20 @@ public class AstIngestService {
|
||||
*/
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
return coarseScan(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE);
|
||||
return coarseScan(language, sourceFile, content, copycodes, NatJavDdmResolver.NONE, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* As {@link #coarseScan(SourceFiles.Language, String, String, CopycodeResolver)}, with the
|
||||
* project's NatJav DDM index.
|
||||
* As {@link #parse(SourceFiles.Language, String, String, CopycodeResolver, NatJavDdmResolver, TypeScriptProject)},
|
||||
* for the Tier-1 outline scan.
|
||||
*/
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, NatJavDdmResolver ddms) {
|
||||
CopycodeResolver copycodes, NatJavDdmResolver ddms, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaScanner.scan(sourceFile, content);
|
||||
case NATJAV -> natJavScanner.scan(sourceFile, content, ddms);
|
||||
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +190,8 @@ public class AstIngestService {
|
||||
case JAVA -> javaLineCounter;
|
||||
case NATJAV -> natJavLineCounter;
|
||||
case NATURAL -> naturalLineCounter;
|
||||
case TYPESCRIPT -> typeScriptLineCounter;
|
||||
case CSS -> cssLineCounter;
|
||||
};
|
||||
return counter.count(content);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import com.agenticcode.parsernatjav.NatJavDdmResolver;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
@@ -53,10 +54,13 @@ public class ProjectIngestService {
|
||||
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
|
||||
// report "clean" about a graph whose refresh has just started.
|
||||
private final ProjectMetadataCache projectMetadata;
|
||||
// Item 192: builds the per-ingest TypeScript context (workspaces, packages, sidecar facts).
|
||||
private final TypeScriptSidecarService typescript;
|
||||
|
||||
public ProjectIngestService(AstIngestService astIngestService,
|
||||
VersionInfo versionInfo,
|
||||
ProjectMetadataCache projectMetadata,
|
||||
TypeScriptSidecarService typescript,
|
||||
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
|
||||
@@ -65,6 +69,7 @@ public class ProjectIngestService {
|
||||
this.astIngestService = astIngestService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.projectMetadata = projectMetadata;
|
||||
this.typescript = typescript;
|
||||
this.batchSize = Math.max(1, batchSize);
|
||||
this.maxDeepDepth = Math.max(1, maxDeepDepth);
|
||||
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
|
||||
@@ -237,16 +242,16 @@ public class ProjectIngestService {
|
||||
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
|
||||
*/
|
||||
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
|
||||
CopycodeLibrary copycodes, Map<String, LocMetrics> userExit,
|
||||
CopycodeLibrary copycodes, TypeScriptProject ts, Map<String, LocMetrics> userExit,
|
||||
ProjectInfo project,
|
||||
Map<String, NatJavIdentities.Rename> natJavRenames,
|
||||
NatJavDdmResolver ddms) throws IOException {
|
||||
String content = SourceFiles.read(candidate.file());
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
ParseResult result = coarse
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ddms)
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ddms, ts)
|
||||
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content,
|
||||
copycodes, ddms), content, candidate.kind().language());
|
||||
copycodes, ddms, ts), content, candidate.kind().language());
|
||||
result = withUserExitMetrics(result, project.generatedDir(), userExit);
|
||||
result = NatJavIdentities.apply(result, sourceFile, natJavRenames);
|
||||
return new Parsed(candidate, result);
|
||||
@@ -272,10 +277,12 @@ public class ProjectIngestService {
|
||||
* Build-output directories always skipped when scanning a root (J6: their generated sources
|
||||
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
|
||||
*/
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
|
||||
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
|
||||
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
|
||||
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs,
|
||||
@Nullable String projectLanguage) throws IOException {
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
|
||||
throws IOException {
|
||||
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
|
||||
for (String dir : DEFAULT_EXCLUDE_DIRS) {
|
||||
if (!effectiveExcludes.contains(dir)) {
|
||||
@@ -291,8 +298,10 @@ public class ProjectIngestService {
|
||||
if (SourceFiles.isExcluded(root, file, effectiveExcludes)) {
|
||||
continue;
|
||||
}
|
||||
// NatJav: the project's declared language decides what a .java file is (item A);
|
||||
// TypeScript/CSS are picked up only by a typescript project (item 192).
|
||||
SourceFiles.Kind kind = SourceFiles.classify(file, projectLanguage);
|
||||
if (kind != null) {
|
||||
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
|
||||
candidates.add(new Candidate(file, kind));
|
||||
}
|
||||
}
|
||||
@@ -573,6 +582,11 @@ public class ProjectIngestService {
|
||||
: allCandidates;
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), project.language(),
|
||||
astIngestService);
|
||||
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
|
||||
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
|
||||
// Tier-1, so the refresh completes and the response says what is missing.
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
|
||||
|
||||
List<String> examinedFiles = candidates.stream()
|
||||
.map(c -> relativeSourceFile(root, c.file()))
|
||||
@@ -583,11 +597,10 @@ public class ProjectIngestService {
|
||||
// Results are collected in candidate order (futures list is parallel to candidates), so the
|
||||
// downstream duplicate detection and persist order stay deterministic.
|
||||
List<Parsed> parsed = new ArrayList<>();
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
List<Future<Parsed>> futures = candidates.stream()
|
||||
.map(candidate -> parseExecutor.submit(
|
||||
() -> parseCandidate(candidate, root, coarse, copycodes, userExit, project, natJavRenames, ddms)))
|
||||
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project, natJavRenames, ddms)))
|
||||
.toList();
|
||||
for (int i = 0; i < futures.size(); i++) {
|
||||
try {
|
||||
@@ -704,10 +717,9 @@ public class ProjectIngestService {
|
||||
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
|
||||
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
|
||||
// (a legacy project) keeps the conservative behaviour.
|
||||
if (!SourceFiles.Language.JAVA.name().equalsIgnoreCase(project.language() == null ? "" : project.language())
|
||||
&& !SourceFiles.NATJAV_PROJECT_LANGUAGE.equalsIgnoreCase(
|
||||
project.language() == null ? "" : project.language())
|
||||
&& copycodeChanged(root, excludeDirs, stored)) {
|
||||
String declared = project.language() == null ? "" : project.language();
|
||||
boolean inlinesCopycodes = declared.isEmpty() || "natural".equalsIgnoreCase(declared);
|
||||
if (inlinesCopycodes && copycodeChanged(root, excludeDirs, stored)) {
|
||||
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
|
||||
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
|
||||
project.name());
|
||||
@@ -952,6 +964,10 @@ public class ProjectIngestService {
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), project.language(),
|
||||
astIngestService);
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
|
||||
// workspace, run once for this call).
|
||||
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
|
||||
int ingested = 0;
|
||||
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
|
||||
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
|
||||
@@ -959,7 +975,6 @@ public class ProjectIngestService {
|
||||
Set<String> inferredCallTargets = new HashSet<>();
|
||||
Set<String> staticCallTargets = new HashSet<>();
|
||||
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
List<String> examinedFiles = new ArrayList<>();
|
||||
Set<String> ingestedModuleNames = new HashSet<>();
|
||||
|
||||
@@ -1008,7 +1023,7 @@ public class ProjectIngestService {
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
examinedFiles.add(sourceFile);
|
||||
ParseResult result = NatJavIdentities.apply(withUserExitMetrics(withShellMetrics(
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ddms),
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ddms, ts),
|
||||
content, candidate.kind().language()), project.generatedDir(), userExit),
|
||||
sourceFile, natJavRenames);
|
||||
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);
|
||||
|
||||
@@ -23,6 +23,15 @@ import java.util.Locale;
|
||||
*
|
||||
* <p>NatJav (Natural transpiled to Java) breaks the extension-only rule: its files are {@code .java},
|
||||
* so the <em>project's</em> declared language decides — see {@link #classify(Path, String)}.
|
||||
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
|
||||
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
|
||||
* ingestible and classify to {@code null}.
|
||||
*
|
||||
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
|
||||
* project of a given declared language picks a file up. Java and Natural files are ingested by every
|
||||
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
|
||||
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
|
||||
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
|
||||
*/
|
||||
final class SourceFiles {
|
||||
|
||||
@@ -45,8 +54,8 @@ final class SourceFiles {
|
||||
private static final String NATJAV_DATA_AREA_SUFFIX = "_da.java";
|
||||
|
||||
/**
|
||||
* As {@link #classify(Path, String)} for a caller with no project in scope (so {@code .java} is
|
||||
* hand-written Java).
|
||||
* @return {@link #classify(Path, String)} with no project in scope — the extension alone, so a
|
||||
* {@code .java} file is {@code JAVA}.
|
||||
*/
|
||||
static @Nullable Kind classify(Path file) {
|
||||
return classify(file, null);
|
||||
@@ -76,9 +85,27 @@ final class SourceFiles {
|
||||
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
|
||||
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
|
||||
}
|
||||
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
|
||||
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
|
||||
}
|
||||
if (name.endsWith(".css")) {
|
||||
return new Kind(Language.CSS, NodeType.MODULE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
|
||||
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
|
||||
* — see the class comment.
|
||||
*/
|
||||
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
|
||||
return switch (language) {
|
||||
case JAVA, NATURAL, NATJAV -> true;
|
||||
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the uppercased filename stem (name without extension), used as the case-insensitive
|
||||
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
|
||||
@@ -122,9 +149,10 @@ final class SourceFiles {
|
||||
/**
|
||||
* The parser a source file is read by. {@code NATJAV} is Natural transpiled to Java: its files
|
||||
* are syntactically Java but map onto the Natural half of the unified AST, which is why it is a
|
||||
* language of its own rather than a flavour of {@code JAVA}.
|
||||
* language of its own rather than a flavour of {@code JAVA}. {@code CSS} shares the TypeScript
|
||||
* parser but has its own line counter.
|
||||
*/
|
||||
enum Language {JAVA, NATURAL, NATJAV}
|
||||
enum Language {JAVA, NATURAL, NATJAV, TYPESCRIPT, CSS}
|
||||
|
||||
/**
|
||||
* Classification of an ingestible source file.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.agenticcode.codeserver.service;
|
||||
|
||||
import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import com.agenticcode.parsertypescript.TypeScriptFacts;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import com.agenticcode.parsertypescript.TypeScriptSidecar;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Item 192: builds the per-ingest {@link TypeScriptProject} for a {@code typescript} project. Tier-1
|
||||
* (coarse) reads only the {@code package.json} files; a deep pass also runs the Node sidecar once per
|
||||
* npm workspace, one after another (measured ~0.5 GB heap and 3–5 s each on the pur frontend), and
|
||||
* merges the facts. A workspace whose sidecar run fails is reported as a
|
||||
* {@link IngestSummary.Failure} with the pseudo-path {@code sidecar:<workspace>}; its files are then
|
||||
* parsed at Tier-1, so the ingest completes and the response says what is missing.
|
||||
*
|
||||
* <p>Configuration ({@code application.properties}): {@code agenticcode.typescript.node} (binary, a
|
||||
* bare name is looked up on {@code PATH}), {@code agenticcode.typescript.sidecar-script}
|
||||
* ({@code extract.mjs}; {@code %prod} points into the image), {@code agenticcode.typescript.max-heap-mb}
|
||||
* and {@code agenticcode.typescript.timeout-seconds}.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class TypeScriptSidecarService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(TypeScriptSidecarService.class);
|
||||
|
||||
private final TypeScriptSidecar sidecar;
|
||||
|
||||
public TypeScriptSidecarService(
|
||||
@ConfigProperty(name = "agenticcode.typescript.node", defaultValue = "node") String node,
|
||||
@ConfigProperty(name = "agenticcode.typescript.sidecar-script",
|
||||
defaultValue = "../ac-parser-typescript/sidecar/extract.mjs") String script,
|
||||
@ConfigProperty(name = "agenticcode.typescript.max-heap-mb", defaultValue = "1024") int maxHeapMb,
|
||||
@ConfigProperty(name = "agenticcode.typescript.timeout-seconds", defaultValue = "600") int timeoutSeconds) {
|
||||
this.sidecar = new TypeScriptSidecar(resolveOnPath(node), Path.of(script).toAbsolutePath().normalize(),
|
||||
Math.max(128, maxHeapMb), Duration.ofSeconds(Math.max(10, timeoutSeconds)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare program name is searched on {@code PATH}; anything with a separator is taken as given.
|
||||
*/
|
||||
static Path resolveOnPath(String program) {
|
||||
Path given = Path.of(program);
|
||||
if (given.getNameCount() > 1 || given.isAbsolute()) {
|
||||
return given;
|
||||
}
|
||||
String pathEnv = System.getenv("PATH");
|
||||
if (pathEnv != null) {
|
||||
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
|
||||
Path candidate = Path.of(dir, program);
|
||||
if (Files.isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return given;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param project the project being ingested; anything but {@code typescript} yields {@link TypeScriptProject#NONE}
|
||||
* @param root the project root
|
||||
* @param coarse Tier-1: no sidecar
|
||||
* @param failures receives one entry per workspace whose sidecar run failed
|
||||
*/
|
||||
public TypeScriptProject prepare(ProjectInfo project, Path root, boolean coarse, List<IngestSummary.Failure> failures) {
|
||||
return prepare(project, root, project.excludeDirs(), coarse, failures);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param excludeDirs directory names the walk skips; a workspace whose directory is excluded is
|
||||
* not loaded by the sidecar either (the pur frontend registers only
|
||||
* {@code pur-ui} + {@code pur-ui-common} and excludes the other two workspaces)
|
||||
*/
|
||||
public TypeScriptProject prepare(ProjectInfo project, Path root, List<String> excludeDirs, boolean coarse,
|
||||
List<IngestSummary.Failure> failures) {
|
||||
if (!"typescript".equalsIgnoreCase(project.language() == null ? "" : project.language())) {
|
||||
return TypeScriptProject.NONE;
|
||||
}
|
||||
TypeScriptProject context;
|
||||
try {
|
||||
context = TypeScriptProject.scan(root);
|
||||
} catch (IOException e) {
|
||||
LOG.warnf("Could not read package.json under '%s' (%s); using built-in package list", root, e.toString());
|
||||
context = TypeScriptProject.NONE;
|
||||
}
|
||||
if (coarse) {
|
||||
return context;
|
||||
}
|
||||
if (!sidecar.available()) {
|
||||
String why = "TypeScript sidecar unavailable (node binary, extract.mjs or its node_modules/typescript missing); "
|
||||
+ "TypeScript files ingested at Tier-1 only";
|
||||
LOG.warn(why);
|
||||
failures.add(new IngestSummary.Failure("sidecar", why));
|
||||
return context;
|
||||
}
|
||||
List<String> workspaces = context.workspaces().isEmpty() ? List.of(".") : context.workspaces().stream()
|
||||
.filter(ws -> excludeDirs.stream().noneMatch(x -> x.equalsIgnoreCase(ws) || x.equalsIgnoreCase(Path.of(ws).getFileName().toString())))
|
||||
.sorted()
|
||||
.toList();
|
||||
List<TypeScriptFacts> parts = new ArrayList<>();
|
||||
for (String workspace : workspaces) {
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
TypeScriptFacts facts = sidecar.extract(root, workspace, null);
|
||||
parts.add(facts);
|
||||
LOG.infof("TypeScript sidecar: workspace '%s' of '%s' -> %d files in %d ms", workspace, project.name(),
|
||||
facts.byFile().size(), (System.nanoTime() - started) / 1_000_000);
|
||||
} catch (TypeScriptSidecar.SidecarException e) {
|
||||
LOG.warnf("TypeScript sidecar failed for workspace '%s' of '%s': %s", workspace, project.name(), e.getMessage());
|
||||
failures.add(new IngestSummary.Failure("sidecar:" + workspace, String.valueOf(e.getMessage())));
|
||||
}
|
||||
}
|
||||
return context.withFacts(TypeScriptFacts.merge(parts));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ quarkus.http.port=8787
|
||||
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
|
||||
# info version (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=346
|
||||
agenticcode.version=350
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
@@ -44,3 +44,14 @@ agenticcode.ingest.batch-size=200
|
||||
# persist. Comments are 45.8 % of the upms node population; this exists to measure what they cost.
|
||||
# Not a feature — no REST parameter and no CLI flag. Leave true for normal operation.
|
||||
agenticcode.ingest.comments.enabled=true
|
||||
# Item 192: the TypeScript Tier-2 sidecar (ac-parser-typescript/sidecar/extract.mjs). A `typescript`
|
||||
# project's deep pass runs `node --max-old-space-size=<max-heap-mb> extract.mjs` once per npm
|
||||
# workspace, sequentially (measured ~0.5 GB heap, 3-5 s per workspace on the pur frontend). A bare
|
||||
# `node` is looked up on PATH; dev mode runs from ac-code-server/, hence the relative script path.
|
||||
# The image copies node and the sidecar to /usr/local/bin/node and /work/sidecar (Dockerfile.jvm).
|
||||
agenticcode.typescript.node=node
|
||||
agenticcode.typescript.sidecar-script=../ac-parser-typescript/sidecar/extract.mjs
|
||||
agenticcode.typescript.max-heap-mb=1024
|
||||
agenticcode.typescript.timeout-seconds=600
|
||||
%prod.agenticcode.typescript.node=/usr/local/bin/node
|
||||
%prod.agenticcode.typescript.sidecar-script=/work/sidecar/extract.mjs
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Item 195: a frontend binding DTO fields through generated {@code Fields} path objects
|
||||
* ({@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}, a prop-rooted prefix, a table's
|
||||
* list prefix) and the Java backend owning the DTOs. After a deep refresh {@code bindings} lists every
|
||||
* site with the field's counterpart in the backend, the reads/writes resolve across files onto the
|
||||
* generated interface's fields, and {@code data-structures/{dto}/fields} carries the bound counts.
|
||||
* Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
|
||||
*/
|
||||
@QuarkusTest
|
||||
class BindingsIT {
|
||||
|
||||
private static final String BACKEND = "bindings-backend";
|
||||
private static final String FRONTEND = "bindings-frontend";
|
||||
|
||||
@TempDir
|
||||
static Path backendRoot;
|
||||
@TempDir
|
||||
static Path frontendRoot;
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixtures() {
|
||||
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
|
||||
Path pkg = backendRoot.resolve("src/main/java/com/example");
|
||||
write(pkg, "AgstammUseCase.java", """
|
||||
package com.example;
|
||||
public class AgstammUseCase {
|
||||
private String brokerName;
|
||||
private Broker broker;
|
||||
private java.util.List<Broker> brokerList;
|
||||
}
|
||||
""");
|
||||
write(pkg, "Broker.java", """
|
||||
package com.example;
|
||||
public class Broker {
|
||||
private Long vermnr;
|
||||
private String ebene;
|
||||
}
|
||||
""");
|
||||
|
||||
write(frontendRoot, "package.json", """
|
||||
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
|
||||
""");
|
||||
Path src = frontendRoot.resolve("app/src");
|
||||
write(src.resolve("generated"), "api-interfaces.ts", """
|
||||
/**
|
||||
* Generated by EndpointGenerator in Pur-Devtools
|
||||
*/
|
||||
// @ts-nocheck
|
||||
export interface Broker {
|
||||
vermnr: number;
|
||||
ebene: string;
|
||||
}
|
||||
export interface AgstammUseCase {
|
||||
brokerName: string;
|
||||
broker: Broker;
|
||||
brokerList: Broker[];
|
||||
}
|
||||
export class Fields<TRoot, TSelf> {
|
||||
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) {}
|
||||
get(): string { return '' }
|
||||
}
|
||||
export class BrokerFields<TRoot, TSelf extends Broker> extends Fields<TRoot, TSelf> {
|
||||
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
|
||||
vermnr = new Fields<TRoot, never>(this, "vermnr");
|
||||
ebene = new Fields<TRoot, never>(this, "ebene");
|
||||
}
|
||||
export class AgstammUseCaseFields<TRoot, TSelf extends AgstammUseCase> extends Fields<TRoot, TSelf> {
|
||||
constructor(parent?: Fields<TRoot, unknown>, name?: string, index?: number) { super(parent, name, index); }
|
||||
brokerName = new Fields<TRoot, never>(this, "brokerName");
|
||||
broker = new BrokerFields<TRoot, Broker>(this, "broker");
|
||||
brokerList = (index?: number) => new BrokerFields<TRoot, Broker>(this, "brokerList", index);
|
||||
}
|
||||
export const AgstammUseCaseField: AgstammUseCaseFields<AgstammUseCase, never> = new AgstammUseCaseFields<AgstammUseCase, never>();
|
||||
""");
|
||||
write(src.resolve("components"), "Smart.tsx", """
|
||||
import { Fields } from 'generated/api-interfaces'
|
||||
export function SmartInput<T>(props: { field: Fields<T, unknown> }) { return <input id={props.field.get()} /> }
|
||||
export function SmartOutput<T>(props: { field: Fields<T, unknown> }) { return <span id={props.field.get()} /> }
|
||||
export function HealthTable<T>(props: { fieldTermForRowData: Fields<T, unknown>; columns: { field: Fields<T, unknown> }[] }) { return <table /> }
|
||||
""");
|
||||
write(src.resolve("components"), "BrokerDialog.tsx", """
|
||||
import { BrokerFields, AgstammUseCase, Broker } from 'generated/api-interfaces'
|
||||
import { SmartInput, SmartOutput } from './Smart'
|
||||
|
||||
export function BrokerDialog(props: { prefix: BrokerFields<AgstammUseCase, Broker> }) {
|
||||
return (
|
||||
<div>
|
||||
<SmartInput field={props.prefix.ebene} />
|
||||
<SmartOutput field={props.prefix.vermnr} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
""");
|
||||
write(src.resolve("components"), "AgstammPage.tsx", """
|
||||
import { AgstammUseCaseField } from 'generated/api-interfaces'
|
||||
import { SmartInput, SmartOutput, HealthTable } from './Smart'
|
||||
import { BrokerDialog } from './BrokerDialog'
|
||||
|
||||
export function AgstammPage() {
|
||||
return (
|
||||
<div>
|
||||
<SmartInput field={AgstammUseCaseField.broker.ebene} />
|
||||
<SmartOutput field={AgstammUseCaseField.brokerName} />
|
||||
<HealthTable fieldTermForRowData={AgstammUseCaseField.brokerList()} columns={[{ field: AgstammUseCaseField.brokerList().vermnr }]} />
|
||||
<BrokerDialog prefix={AgstammUseCaseField.broker} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
""");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
|
||||
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
|
||||
List.of(BACKEND)))
|
||||
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
|
||||
.body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response bindings(String query) {
|
||||
return given().when().get("/api/projects/" + FRONTEND + "/bindings" + query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyBindingSiteIsListedWithItsBackendCounterpart() {
|
||||
bindings("").then()
|
||||
.statusCode(200)
|
||||
// AgstammPage: ebene (R+W), brokerName (R), brokerList prefix (R), brokerList[].vermnr (R), broker prefix (R)
|
||||
// BrokerDialog: ebene (R+W, partial), vermnr (R, partial)
|
||||
.body("size()", equalTo(9))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.dto", equalTo("Broker"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("broker.ebene"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.rootDto", equalTo("AgstammUseCase"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.component", equalTo("SmartInput"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.kind", equalTo("field"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(false))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' && it.mode == 'WRITES' }.lineNo", equalTo(8))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartProject", equalTo(BACKEND))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartModule", equalTo("com.example.Broker"))
|
||||
.body("find { it.function == 'AgstammPage' && it.field == 'ebene' }.counterpartField", equalTo("ebene"))
|
||||
.body("find { it.field == 'brokerName' }.mode", equalTo("READS"))
|
||||
.body("find { it.field == 'brokerName' }.component", equalTo("SmartOutput"))
|
||||
.body("find { it.field == 'brokerList' }.kind", equalTo("prefix"))
|
||||
.body("find { it.field == 'brokerList' }.path", equalTo("brokerList[]"))
|
||||
.body("find { it.field == 'brokerList' }.attribute", equalTo("fieldTermForRowData"))
|
||||
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.path", equalTo("brokerList[].vermnr"))
|
||||
.body("find { it.field == 'vermnr' && it.function == 'AgstammPage' }.component", equalTo("HealthTable"))
|
||||
.body("find { it.field == 'broker' }.kind", equalTo("prefix"))
|
||||
.body("find { it.field == 'broker' }.dto", equalTo("AgstammUseCase"))
|
||||
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.partial", equalTo(true))
|
||||
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' && it.mode == 'WRITES' }.path", equalTo("ebene"))
|
||||
.body("find { it.function == 'BrokerDialog' && it.field == 'ebene' }.rootDto", equalTo("AgstammUseCase"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersAnswerTheBoundaryQuestion() {
|
||||
// which page edits Java Broker.ebene?
|
||||
bindings("?dto=Broker&field=ebene&mode=writes").then().statusCode(200)
|
||||
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"))
|
||||
.body("counterpartModule", everyItem(equalTo("com.example.Broker")));
|
||||
bindings("?dto=Broker&field=ebene&mode=writes&partial=false").then().statusCode(200)
|
||||
.body("size()", equalTo(1)).body("[0].function", equalTo("AgstammPage"));
|
||||
bindings("?module=BrokerDialog&countOnly=true").then().statusCode(200).body("count", equalTo(3));
|
||||
bindings("?mode=bogus").then().statusCode(400).body("code", equalTo("MODE_UNSUPPORTED"));
|
||||
bindings("?dto=Nope").then().statusCode(200).body("size()", equalTo(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dtoFieldsCarryBoundCountsAndNoPlaceholderSurvives() {
|
||||
given().when().get("/api/projects/" + FRONTEND + "/data-structures/Broker/fields").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'ebene' }.type", equalTo("FIELD"))
|
||||
.body("find { it.name == 'ebene' }.boundReads", equalTo(2))
|
||||
.body("find { it.name == 'ebene' }.boundWrites", equalTo(2))
|
||||
.body("find { it.name == 'vermnr' }.boundReads", equalTo(2))
|
||||
.body("find { it.name == 'vermnr' }.boundWrites", equalTo(0));
|
||||
given().when().get("/api/projects/" + FRONTEND + "/search/identifier?name=Broker.ebene").then().statusCode(200)
|
||||
.body("findAll { it.sourceFile == '' }.size()", equalTo(0));
|
||||
// the generic variable endpoint sees the binding writes too
|
||||
given().when().get("/api/projects/" + FRONTEND + "/variables/Broker.ebene/writes").then().statusCode(200)
|
||||
.body("function", containsInAnyOrder("AgstammPage", "BrokerDialog"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.empty;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
|
||||
/**
|
||||
* Item 181: {@code callees}/{@code callers} page at 50 by default. A module calling 60 others used to
|
||||
* get 50 rows and nothing saying so; now the headers and the body's {@code total}/{@code truncated}
|
||||
* tell a cut fan-out from a complete one.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class CallRefTruncationIT {
|
||||
|
||||
private static final String PROJECT = "item181-truncation";
|
||||
private static final int FANOUT = 60;
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
StringBuilder hub = new StringBuilder("DEFINE DATA\n LOCAL\n 01 #A (A8)\nEND-DEFINE\n");
|
||||
for (int i = 0; i < FANOUT; i++) {
|
||||
String callee = "CALLEE%02d".formatted(i);
|
||||
hub.append("CALLNAT '").append(callee).append("' #A\n");
|
||||
write(callee + ".nat", "DEFINE DATA\n PARAMETER\n 01 #P (A8)\nEND-DEFINE\nEND\n");
|
||||
}
|
||||
hub.append("END\n");
|
||||
write("HUB.nat", hub.toString());
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.writeString(root.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDefaultPageSaysItIsCut() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees").then().statusCode(200)
|
||||
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
|
||||
.header("X-AC-Truncated", equalTo("true"))
|
||||
.body("items.size()", equalTo(50))
|
||||
.body("total", equalTo(FANOUT))
|
||||
.body("truncated", equalTo(true));
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?fields=name").then().statusCode(200)
|
||||
.header("X-AC-Truncated", equalTo("true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCompletePageSaysItIsComplete() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?limit=1000").then().statusCode(200)
|
||||
.header("X-AC-Total-Count", equalTo(String.valueOf(FANOUT)))
|
||||
.header("X-AC-Truncated", equalTo("false"))
|
||||
.body("items.size()", equalTo(FANOUT))
|
||||
.body("truncated", equalTo(false));
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/HUB/callees?offset=50").then().statusCode(200)
|
||||
.header("X-AC-Truncated", equalTo("false"))
|
||||
.body("items.size()", equalTo(10));
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/CALLEE07/callers").then().statusCode(200)
|
||||
.header("X-AC-Total-Count", equalTo("1"))
|
||||
.header("X-AC-Truncated", equalTo("false"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Item 193: a TypeScript frontend (generated web-service client + generated DTO interfaces) and the
|
||||
* Java backend it calls, as two projects. The frontend's calls appear in {@code rest-endpoints} as
|
||||
* outbound rows, and {@code COUNTERPART_OF} links call → handler, DTO → class, field → field;
|
||||
* {@code counterparts?unmatched=true} names the calls nothing serves. Needs the Node sidecar
|
||||
* ({@code node} + {@code ../ac-parser-typescript/sidecar/node_modules/typescript}); skipped otherwise.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class CounterpartsIT {
|
||||
|
||||
private static final String BACKEND = "counterparts-backend";
|
||||
private static final String FRONTEND = "counterparts-frontend";
|
||||
|
||||
@TempDir
|
||||
static Path backendRoot;
|
||||
@TempDir
|
||||
static Path frontendRoot;
|
||||
|
||||
static boolean sidecarAvailable() {
|
||||
boolean node = false;
|
||||
String pathEnv = System.getenv("PATH");
|
||||
if (pathEnv != null) {
|
||||
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
|
||||
if (Files.isExecutable(Path.of(dir, "node"))) {
|
||||
node = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return node && Files.isDirectory(Path.of("../ac-parser-typescript/sidecar/node_modules/typescript"));
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixtures() {
|
||||
assumeTrue(sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
|
||||
Path pkg = backendRoot.resolve("src/main/java/com/example");
|
||||
write(pkg, "AgstammController.java", """
|
||||
package com.example;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
|
||||
@Path("/agstamm/ui")
|
||||
public class AgstammController {
|
||||
|
||||
@GET
|
||||
@Path("/{vermnr}")
|
||||
public AgstammUseCase getBroker(@PathParam("vermnr") Long vermnr) {
|
||||
return new AgstammUseCase();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search")
|
||||
public AgstammUseCase searchBroker(Long vermnr) {
|
||||
return new AgstammUseCase();
|
||||
}
|
||||
|
||||
@POST
|
||||
public AgstammUseCase saveBroker(AgstammUseCase useCase) {
|
||||
return useCase;
|
||||
}
|
||||
}
|
||||
""");
|
||||
write(pkg, "AgstammUseCase.java", """
|
||||
package com.example;
|
||||
|
||||
public class AgstammUseCase {
|
||||
private String brokerName;
|
||||
private Broker broker;
|
||||
}
|
||||
""");
|
||||
write(pkg, "Broker.java", """
|
||||
package com.example;
|
||||
|
||||
public class Broker {
|
||||
private Long vermnr;
|
||||
private String ebene;
|
||||
}
|
||||
""");
|
||||
|
||||
write(frontendRoot, "package.json", """
|
||||
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
|
||||
""");
|
||||
Path src = frontendRoot.resolve("app/src");
|
||||
write(src.resolve("util"), "requestUtil.ts", """
|
||||
export function buildPurURL(path: string): string { return 'http://backend' + path }
|
||||
export function executeGetRequest<R>(url: string): Promise<R> { return Promise.reject(url) }
|
||||
export function executePostRequest<R, B>(url: string, body: B): Promise<R> { return Promise.reject(url) }
|
||||
export function encodePathParams(v: unknown): string { return String(v) }
|
||||
""");
|
||||
write(src.resolve("generated"), "api-interfaces.ts", """
|
||||
/**
|
||||
* Generated by EndpointGenerator in Pur-Devtools
|
||||
*/
|
||||
// @ts-nocheck
|
||||
export interface Broker {
|
||||
vermnr: number;
|
||||
ebene: string;
|
||||
}
|
||||
export interface AgstammUseCase {
|
||||
brokerName: string;
|
||||
broker: Broker;
|
||||
onlyInFrontend?: string;
|
||||
}
|
||||
export interface SvcResult<T> {
|
||||
result?: T;
|
||||
}
|
||||
""");
|
||||
write(src.resolve("generated"), "endpoints.ts", """
|
||||
/**
|
||||
* Generated by EndpointGenerator in Pur-Devtools
|
||||
*/
|
||||
// @ts-nocheck
|
||||
import * as COMMON from 'util/requestUtil';
|
||||
import * as API from './api-interfaces';
|
||||
|
||||
interface Endpoint { baseUrl: string; }
|
||||
interface GetMethod<R> { get: () => Promise<R>; }
|
||||
interface GetMethodWithParameters<R, P> { get: (params: P) => Promise<R>; }
|
||||
interface PostMethod<R, B> { post: (body: B) => Promise<R>; }
|
||||
|
||||
export class AgstammControllerEndpoint implements Endpoint {
|
||||
baseUrl: string = '/agstamm/ui/';
|
||||
public getBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
|
||||
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}${COMMON.encodePathParams(params.vermnr)}`)),
|
||||
};
|
||||
public searchBroker: GetMethodWithParameters<API.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
|
||||
get: (params) => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${COMMON.encodePathParams(params.vermnr)}`)),
|
||||
};
|
||||
public saveBroker: PostMethod<API.SvcResult<API.AgstammUseCase>, API.AgstammUseCase> = {
|
||||
post: (body) => COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body),
|
||||
};
|
||||
public ping: GetMethod<string> = {
|
||||
get: () => COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}nothing-serves-this`)),
|
||||
};
|
||||
}
|
||||
""");
|
||||
write(src.resolve("store"), "agstammSlice.ts", """
|
||||
import { AgstammControllerEndpoint } from 'generated/endpoints'
|
||||
import { AgstammUseCase } from 'generated/api-interfaces'
|
||||
|
||||
const api = new AgstammControllerEndpoint()
|
||||
|
||||
export const saveBrokerToServer = (useCase: AgstammUseCase) => api.saveBroker.post(useCase)
|
||||
export const loadBroker = (vermnr: number) => api.getBroker.get({ vermnr })
|
||||
""");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, backendRoot.toString(), null, "java", null, null))
|
||||
.when().post("/api/projects/" + BACKEND).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, frontendRoot.toString(), null, "typescript", null, null,
|
||||
List.of(BACKEND)))
|
||||
.when().post("/api/projects/" + FRONTEND).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + FRONTEND + "/refresh?deep=true").then().statusCode(200)
|
||||
.body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response counterparts(String query) {
|
||||
return given().when().get("/api/projects/" + FRONTEND + "/counterparts" + query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void theFrontendsCallsAreOutboundRestEndpoints() {
|
||||
given().when().get("/api/projects/" + FRONTEND + "/rest-endpoints").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(4))
|
||||
.body("outbound", everyItem(equalTo(true)))
|
||||
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.httpMethod", equalTo("POST"))
|
||||
.body("find { it.handler == 'AgstammControllerEndpoint.saveBroker' }.path", equalTo("/agstamm/ui"))
|
||||
.body("find { it.handler == 'AgstammControllerEndpoint.getBroker' }.path", equalTo("/agstamm/ui/{vermnr}"))
|
||||
.body("find { it.handler == 'AgstammControllerEndpoint.searchBroker' }.path", equalTo("/agstamm/ui/search"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsAreLinkedToTheHandlersServingThem() {
|
||||
counterparts("?kind=rest").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartName", equalTo("saveBroker"))
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartModule", equalTo("com.example.AgstammController"))
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.saveBroker' }.counterpartProject", equalTo(BACKEND))
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.getBroker' }.counterpartName", equalTo("getBroker"))
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.searchBroker' }.counterpartName", equalTo("searchBroker"))
|
||||
.body("find { it.name == 'AgstammControllerEndpoint.ping' }.counterpartName", nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmatchedNamesExactlyWhatNothingServes() {
|
||||
counterparts("?kind=rest&unmatched=true").then()
|
||||
.statusCode(200)
|
||||
.body("name", contains("AgstammControllerEndpoint.ping"))
|
||||
.body("[0].httpMethod", equalTo("GET"))
|
||||
.body("[0].path", equalTo("/agstamm/ui/nothing-serves-this"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedDtosAndTheirFieldsAreLinkedByName() {
|
||||
counterparts("?kind=dto").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'AgstammUseCase' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
|
||||
.body("find { it.name == 'Broker' }.counterpartModule", equalTo("com.example.Broker"))
|
||||
.body("find { it.name == 'SvcResult' }.counterpartName", nullValue());
|
||||
counterparts("?kind=field&module=api-interfaces").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartName", equalTo("brokerName"))
|
||||
.body("find { it.name == 'AgstammUseCase.brokerName' }.counterpartModule", equalTo("com.example.AgstammUseCase"))
|
||||
.body("find { it.name == 'Broker.ebene' }.counterpartModule", equalTo("com.example.Broker"))
|
||||
.body("find { it.name == 'AgstammUseCase.onlyInFrontend' }.counterpartName", nullValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePairSurvivesABackendRefresh() {
|
||||
given().when().post("/api/projects/" + BACKEND + "/refresh?deep=true").then().statusCode(200);
|
||||
counterparts("?kind=rest&unmatched=true").then()
|
||||
.statusCode(200)
|
||||
.body("name", contains("AgstammControllerEndpoint.ping"));
|
||||
counterparts("?kind=rest&countOnly=true").then()
|
||||
.statusCode(200)
|
||||
.header("X-AC-Total-Count", equalTo("4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSettingIsVisibleAndSelfIsRejected() {
|
||||
given().when().get("/api/projects/" + FRONTEND).then()
|
||||
.statusCode(200)
|
||||
.body("counterparts", contains(BACKEND));
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, null, null, null, null, null, List.of(FRONTEND)))
|
||||
.when().put("/api/projects/" + FRONTEND).then()
|
||||
.statusCode(400)
|
||||
.body("code", equalTo("COUNTERPART_SELF"));
|
||||
counterparts("?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,30 @@ class DynamicCallOverrideIT {
|
||||
.body("items.name", hasItem("#TGT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideRebuildsTheDerivedModuleEdgesWithoutARefresh() {
|
||||
// Item 200: CALLS_MODULE (item 68) drives `reaches`; it was only rebuilt by the finalize, so a
|
||||
// freshly pinned target was unreachable until the next refresh.
|
||||
resetAll();
|
||||
Object[] site = unresolvedSite();
|
||||
given().pathParam("name", "CALLERDYN")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
|
||||
.then().statusCode(200).body("reachable", equalTo(false));
|
||||
setOverride((String) site[0], (Integer) site[1], List.of("TARGETMOD", "TARGET2"));
|
||||
given().pathParam("name", "CALLERDYN")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
|
||||
.then().statusCode(200).body("reachable", equalTo(true));
|
||||
given().pathParam("name", "CALLERDYN")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGET2&depth=3")
|
||||
.then().statusCode(200).body("reachable", equalTo(true));
|
||||
// a reset of the one site drops the derived edges again, inline
|
||||
given().when().delete("/api/projects/" + PROJECT + "/dynamic-calls/overrides?originFile=" + site[0] + "&lineNo=" + site[1])
|
||||
.then().statusCode(200);
|
||||
given().pathParam("name", "CALLERDYN")
|
||||
.when().get("/api/projects/" + PROJECT + "/modules/{name}/reaches?target=TARGETMOD&depth=3")
|
||||
.then().statusCode(200).body("reachable", equalTo(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setOverrideResolvesCalleeAndHidesPlaceholder() {
|
||||
resetAll();
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Item 197: {@code functions/{fn}/callers} sees cross-module calls. A Java method called from its own
|
||||
* class (a {@code FUNCTION -CALLS-> FUNCTION} edge) and from another class (a module-to-module edge
|
||||
* carrying {@code callerFn}/{@code calleeMethod}) lists both callers with their lines; a TypeScript
|
||||
* function called from another module lists the calling component. The TypeScript half needs the
|
||||
* Node sidecar (skipped otherwise); the Java half always runs.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class FunctionCallersCrossModuleIT {
|
||||
|
||||
private static final String JAVA = "fncallers-java";
|
||||
private static final String TS = "fncallers-ts";
|
||||
|
||||
@TempDir
|
||||
static Path javaRoot;
|
||||
@TempDir
|
||||
static Path tsRoot;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ingest(String project, Path root, String language) {
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, language, null, null))
|
||||
.when().post("/api/projects/" + project).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + project + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response functionCallers(String project, String module, String fn) {
|
||||
return given().urlEncodingEnabled(false).when().get("/api/projects/" + project + "/modules/" + module + "/functions/" + fn + "/callers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaMethodListsSameClassAndCrossClassCallers() {
|
||||
Path pkg = javaRoot.resolve("src/main/java/com/example");
|
||||
write(pkg, "Logic.java", """
|
||||
package com.example;
|
||||
public class Logic {
|
||||
public void handle() {}
|
||||
public void twice() { handle(); }
|
||||
}
|
||||
""");
|
||||
write(pkg, "Controller.java", """
|
||||
package com.example;
|
||||
public class Controller {
|
||||
private final Logic logic = new Logic();
|
||||
public void save() { logic.handle(); }
|
||||
public void merge() { logic.handle(); logic.handle(); }
|
||||
public void other() { logic.twice(); }
|
||||
}
|
||||
""");
|
||||
ingest(JAVA, javaRoot, "java");
|
||||
|
||||
functionCallers(JAVA, "com.example.Logic", "handle").then().statusCode(200)
|
||||
.body("items.name", containsInAnyOrder("twice", "save", "merge"))
|
||||
.body("sourceFiles", hasItems(endsWith("Logic.java"), endsWith("Controller.java")))
|
||||
.body("items.find { it.name == 'twice' }.sourceFileIndex", not(equalTo(-1)))
|
||||
.body("items.find { it.name == 'save' }.edgeKind", equalTo("METHOD_CALL"))
|
||||
.body("items.find { it.name == 'save' }.sites.lineNo", contains(4))
|
||||
// two calls on one line: one site per (lineNo) key, so a single site at line 5
|
||||
.body("items.find { it.name == 'merge' }.sites.lineNo", contains(5));
|
||||
functionCallers(JAVA, "com.example.Logic", "twice").then().statusCode(200)
|
||||
.body("items.name", contains("other"));
|
||||
// a method nobody calls
|
||||
functionCallers(JAVA, "com.example.Controller", "other").then().statusCode(200).body("items", hasSize(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeScriptFunctionListsTheCallingComponent() {
|
||||
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
write(tsRoot, "package.json", "{\"name\": \"fe\", \"workspaces\": [\"app\"], \"dependencies\": {\"react\": \"^18\"}}");
|
||||
Path src = tsRoot.resolve("app/src");
|
||||
write(src, "util.ts", "export function format(v: string) { return v.trim() }\nexport function unused() { return format('x') }\n");
|
||||
write(src, "Page.tsx", "import { format } from './util'\nexport function Page() { return <div>{format(' a ')}</div> }\n");
|
||||
ingest(TS, tsRoot, "typescript");
|
||||
|
||||
functionCallers(TS, "app%2Fsrc%2Futil", "format").then().statusCode(200)
|
||||
.body("items.name", containsInAnyOrder("Page", "unused"))
|
||||
.body("sourceFiles", hasItem("app/src/Page.tsx"))
|
||||
.body("items.find { it.name == 'Page' }.sites.lineNo", contains(2));
|
||||
functionCallers(TS, "app%2Fsrc%2Futil", "unused").then().statusCode(200).body("items", hasSize(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 203: a caller that invokes an interface at two lines gets one synthetic (CHA) call edge to the
|
||||
* implementation per line, each carrying the called method — so {@code callees} lists both real lines
|
||||
* (not one picked at random) and {@code functions/{impl-method}/callers} sees the call made through the
|
||||
* interface.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class InheritanceCallSitesIT {
|
||||
|
||||
private static final String PROJECT = "item203-inheritance-sites";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingest() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
Path pkg = root.resolve("src/main/java/com/example");
|
||||
write(pkg, "Repo.java", "package com.example;\npublic interface Repo { void save(); void load(); }\n");
|
||||
write(pkg, "RepoImpl.java", "package com.example;\npublic class RepoImpl implements Repo {\n public void save() {}\n public void load() {}\n}\n");
|
||||
write(pkg, "Service.java", """
|
||||
package com.example;
|
||||
public class Service {
|
||||
private Repo repo;
|
||||
public void store() {
|
||||
repo.save();
|
||||
}
|
||||
public void read() {
|
||||
repo.load();
|
||||
}
|
||||
}
|
||||
""");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "java", null, null))
|
||||
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyInterfaceCallSiteReachesTheImplementationWithItsLine() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
|
||||
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.lineNo", containsInAnyOrder(5, 8))
|
||||
.body("items.find { it.name == 'com.example.Repo' }.sites.lineNo", containsInAnyOrder(5, 8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void implementationMethodCallersIncludeCallsThroughTheInterface() {
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/save/callers").then().statusCode(200)
|
||||
.body("items.name", contains("store"))
|
||||
.body("items[0].sites.lineNo", contains(5));
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.RepoImpl/functions/load/callers").then().statusCode(200)
|
||||
.body("items.name", contains("read"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondRefreshDoesNotMultiplyTheSyntheticEdges() {
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
|
||||
given().when().get("/api/projects/" + PROJECT + "/modules/com.example.Service/callees").then().statusCode(200)
|
||||
.body("items.find { it.name == 'com.example.RepoImpl' }.sites.size()", equalTo(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 202: renaming a project moves every node, its dynamic-call overrides and other projects'
|
||||
* {@code counterparts} references; the old name answers 404 afterwards, the new one answers everything
|
||||
* the old one did. Refuses a taken name and a blank or unchanged one.
|
||||
*/
|
||||
@QuarkusTest
|
||||
class ProjectRenameIT {
|
||||
|
||||
private static final String OLD = "rename-old";
|
||||
private static final String NEW = "rename-new";
|
||||
private static final String PEER = "rename-peer";
|
||||
@TempDir
|
||||
static Path root;
|
||||
@TempDir
|
||||
static Path peerRoot;
|
||||
@jakarta.inject.Inject
|
||||
org.neo4j.driver.Driver driver;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write(root, "CALLER.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #TGT (A8)
|
||||
END-DEFINE
|
||||
CALLNAT 'CALLEE' #TGT
|
||||
CALLNAT #TGT
|
||||
END
|
||||
""");
|
||||
write(root, "CALLEE.nat", """
|
||||
DEFINE DATA
|
||||
PARAMETER
|
||||
01 #P (A8)
|
||||
END-DEFINE
|
||||
END
|
||||
""");
|
||||
write(peerRoot, "Peer.java", "package p; public class Peer {}\n");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + OLD).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + OLD + "/refresh?deep=true").then().statusCode(200).body("failed", empty());
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, peerRoot.toString(), null, "java", null, null, List.of(OLD)))
|
||||
.when().post("/api/projects/" + PEER).then().statusCode(201);
|
||||
// a manual override that must follow the rename
|
||||
String file = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
|
||||
.extract().path("[0].originFile");
|
||||
Integer line = given().when().get("/api/projects/" + OLD + "/dynamic-calls/unresolved").then().statusCode(200)
|
||||
.extract().path("[0].lineNo");
|
||||
given().contentType("application/json")
|
||||
.body(Map.of("originFile", file, "lineNo", line, "targets", List.of("CALLEE"), "variable", "#TGT"))
|
||||
.when().post("/api/projects/" + OLD + "/dynamic-calls/overrides").then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renameMovesNodesOverridesAndCounterpartReferences() {
|
||||
given().contentType("application/json").body(Map.of("newName", " "))
|
||||
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400).body("code", equalTo("INVALID_REQUEST"));
|
||||
given().contentType("application/json").body(Map.of("newName", OLD))
|
||||
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(400);
|
||||
given().contentType("application/json").body(Map.of("newName", PEER))
|
||||
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(409).body("code", equalTo("PROJECT_EXISTS"));
|
||||
given().contentType("application/json").body(Map.of("newName", NEW))
|
||||
.when().post("/api/projects/nope/rename").then().statusCode(404);
|
||||
|
||||
given().contentType("application/json").body(Map.of("newName", NEW))
|
||||
.when().post("/api/projects/" + OLD + "/rename").then().statusCode(200)
|
||||
.body("name", equalTo(NEW)).body("language", equalTo("natural")).body("ingest.filesPersisted", equalTo(2));
|
||||
|
||||
given().when().get("/api/projects/" + OLD).then().statusCode(404);
|
||||
given().when().get("/api/projects/" + NEW + "/modules").then().statusCode(200).body("name", containsInAnyOrder("CALLER", "CALLEE"));
|
||||
given().when().get("/api/projects/" + NEW + "/modules/CALLER/callees").then().statusCode(200)
|
||||
.body("items.name", hasItem("CALLEE"));
|
||||
given().when().get("/api/projects/" + NEW + "/dynamic-calls/overrides").then().statusCode(200)
|
||||
.body("size()", equalTo(1)).body("[0].targets", contains("CALLEE"));
|
||||
given().when().get("/api/projects/" + PEER).then().statusCode(200).body("counterparts", contains(NEW));
|
||||
given().when().get("/api/projects").then().statusCode(200)
|
||||
.body("name", hasItem(NEW)).body("name", not(hasItem(OLD)));
|
||||
|
||||
// a full delete takes the project's overrides with it (found on upms2: 10 orphaned override nodes)
|
||||
given().when().delete("/api/projects/" + NEW).then().statusCode(204);
|
||||
try (var session = driver.session()) {
|
||||
long orphans = session.run("MATCH (o:DynamicCallOverride {project: $p}) RETURN count(o) AS c", Map.of("p", NEW))
|
||||
.single().get("c").asLong();
|
||||
org.junit.jupiter.api.Assertions.assertEquals(0, orphans, "override nodes of a deleted project");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* Item 194: a React/Redux-Toolkit frontend with one store, two slices (one mounted under a key that
|
||||
* differs from its slice name) and consumers reading through {@code useAppSelector}, a wrapper hook
|
||||
* and {@code getState()}. After a deep refresh the store endpoints answer, the consumer reads are
|
||||
* resolved onto the slice's own field nodes across files, and a dispatched action creator shows up
|
||||
* as a call to the reducer. Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
|
||||
*/
|
||||
@QuarkusTest
|
||||
class StoreIT {
|
||||
|
||||
private static final String PROJECT = "store-frontend";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixture() {
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
|
||||
write(root, "package.json", """
|
||||
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
|
||||
""");
|
||||
Path src = root.resolve("app/src");
|
||||
write(src.resolve("store"), "rtk.ts", """
|
||||
// stand-in for @reduxjs/toolkit + react-redux (no node_modules in the fixture)
|
||||
export function createSlice(options: any): any { return options }
|
||||
export function configureStore(options: any): any { return options }
|
||||
export function createAsyncThunk(prefix: string, fn: any): any { return fn }
|
||||
export function useSelector<R>(selector: (state: any) => R): R { return selector({}) }
|
||||
""");
|
||||
write(src.resolve("store/slices"), "keytableSlice.ts", """
|
||||
import { createSlice, createAsyncThunk } from 'store/rtk'
|
||||
|
||||
export interface KeyTableState {
|
||||
requestStatus: string
|
||||
keyTableUseCaseSvcResult?: { result?: { tableId: string; purMode?: string } }
|
||||
sucheStatus: string
|
||||
}
|
||||
|
||||
const initialState: KeyTableState = { requestStatus: 'idle', sucheStatus: 'NONE' }
|
||||
const sliceName = 'schluesseltabelle'
|
||||
|
||||
export const sucheByServer = createAsyncThunk(`${sliceName}/suche`, async (tableId: string) => ({ result: { tableId } }))
|
||||
|
||||
const keytableSlice = createSlice({
|
||||
name: sliceName,
|
||||
initialState,
|
||||
reducers: {
|
||||
updateKeyTableUseCaseSvcResult(state, action) {
|
||||
if (state.keyTableUseCaseSvcResult?.result?.tableId !== action.payload?.result?.tableId) {
|
||||
state.sucheStatus = 'NONE'
|
||||
}
|
||||
state.keyTableUseCaseSvcResult = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(sucheByServer.fulfilled, (state) => {
|
||||
state.sucheStatus = 'SUCCESS'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { updateKeyTableUseCaseSvcResult } = keytableSlice.actions
|
||||
export const schluesseltabelleReducer = keytableSlice.reducer
|
||||
""");
|
||||
write(src.resolve("store/slices"), "generalAgreementSlice.ts", """
|
||||
import { createSlice } from 'store/rtk'
|
||||
|
||||
interface GeneralAgreementState { loading: boolean }
|
||||
|
||||
const generalAgreementSlice = createSlice({
|
||||
name: 'generalAgreement',
|
||||
initialState: { loading: false } as GeneralAgreementState,
|
||||
reducers: {
|
||||
setLoading(state, action) {
|
||||
state.loading = action.payload
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { setLoading } = generalAgreementSlice.actions
|
||||
export const gruppenprovisionReducer = generalAgreementSlice.reducer
|
||||
""");
|
||||
write(src.resolve("store"), "store.ts", """
|
||||
import { configureStore } from 'store/rtk'
|
||||
import { schluesseltabelleReducer } from 'store/slices/keytableSlice'
|
||||
import { gruppenprovisionReducer } from 'store/slices/generalAgreementSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
schluesseltabelle: schluesseltabelleReducer,
|
||||
gruppenprovision: gruppenprovisionReducer,
|
||||
},
|
||||
})
|
||||
""");
|
||||
write(src.resolve("store"), "redux-types.ts", """
|
||||
import { useSelector } from 'store/rtk'
|
||||
export const useAppSelector = useSelector
|
||||
""");
|
||||
write(src.resolve("store/hooks"), "useKeyTable.ts", """
|
||||
import { useAppSelector } from 'store/redux-types'
|
||||
|
||||
export const useSchluesseltabelleSelector = <R>(selector: (useCase: any) => R): R => {
|
||||
return useAppSelector((state) => selector(state.schluesseltabelle.keyTableUseCaseSvcResult))
|
||||
}
|
||||
""");
|
||||
write(src.resolve("components"), "KeyTablePage.tsx", """
|
||||
import { useAppSelector } from 'store/redux-types'
|
||||
import { useSchluesseltabelleSelector } from 'store/hooks/useKeyTable'
|
||||
import { updateKeyTableUseCaseSvcResult } from 'store/slices/keytableSlice'
|
||||
import { setLoading } from 'store/slices/generalAgreementSlice'
|
||||
import { store } from 'store/store'
|
||||
|
||||
export function KeyTablePage() {
|
||||
const requestStatus = useAppSelector((state) => state.schluesseltabelle.requestStatus)
|
||||
const purMode = useSchluesseltabelleSelector((useCase) => useCase?.result?.purMode)
|
||||
const { loading } = useAppSelector((state) => state.gruppenprovision)
|
||||
const reset = () => {
|
||||
if (store.getState().schluesseltabelle.sucheStatus === 'NONE') {
|
||||
store.dispatch(updateKeyTableUseCaseSvcResult(undefined))
|
||||
store.dispatch(setLoading(false))
|
||||
}
|
||||
}
|
||||
return <div onClick={reset}>{requestStatus} {purMode} {String(loading)}</div>
|
||||
}
|
||||
""");
|
||||
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "typescript", null, null))
|
||||
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200)
|
||||
.body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response get(String path) {
|
||||
return given().when().get("/api/projects/" + PROJECT + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeListsTheSlicesUnderTheirReducerKeys() {
|
||||
get("/store").then()
|
||||
.statusCode(200)
|
||||
.body("slice", contains("gruppenprovision", "schluesseltabelle"))
|
||||
.body("find { it.slice == 'gruppenprovision' }.sliceName", equalTo("generalAgreement"))
|
||||
.body("find { it.slice == 'gruppenprovision' }.module", equalTo("app/src/store/slices/generalAgreementSlice"))
|
||||
.body("find { it.slice == 'schluesseltabelle' }.stateType", equalTo("KeyTableState"))
|
||||
.body("find { it.slice == 'schluesseltabelle' }.fields.name", contains("requestStatus", "keyTableUseCaseSvcResult", "sucheStatus"))
|
||||
.body("find { it.slice == 'schluesseltabelle' }.fields.find { it.name == 'keyTableUseCaseSvcResult' }.optional", equalTo(true))
|
||||
.body("find { it.slice == 'schluesseltabelle' }.reducers", equalTo(2))
|
||||
// reducer read: tableId; consumer reads: requestStatus, purMode (via the wrapper), sucheStatus
|
||||
// (getState), plus the wrapper hook's own inner selector
|
||||
.body("find { it.slice == 'schluesseltabelle' }.reads", equalTo(5))
|
||||
// reducer writes: sucheStatus x2, keyTableUseCaseSvcResult
|
||||
.body("find { it.slice == 'schluesseltabelle' }.writes", equalTo(3));
|
||||
get("/store?slice=generalAgreement").then().statusCode(200).body("size()", equalTo(1)).body("[0].slice", equalTo("gruppenprovision"));
|
||||
get("/store?slice=nope").then().statusCode(200).body("size()", equalTo(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumerReadsResolveOntoTheSliceFieldsAcrossFiles() {
|
||||
get("/store/schluesseltabelle/accesses?mode=reads").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(5))
|
||||
.body("mode", everyItem(equalTo("READS")))
|
||||
.body("findAll { it.via == 'useAppSelector' }.function", containsInAnyOrder("KeyTablePage", "useSchluesseltabelleSelector"))
|
||||
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.field", equalTo("requestStatus"))
|
||||
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.module", equalTo("app/src/components/KeyTablePage"))
|
||||
.body("find { it.function == 'KeyTablePage' && it.via == 'useAppSelector' }.functionKind", equalTo("component"))
|
||||
.body("find { it.function == 'useSchluesseltabelleSelector' }.functionKind", equalTo("hook"))
|
||||
.body("find { it.function == 'useSchluesseltabelleSelector' }.path", equalTo("keyTableUseCaseSvcResult"))
|
||||
.body("find { it.via == 'useSchluesseltabelleSelector' }.path", equalTo("keyTableUseCaseSvcResult.result.purMode"))
|
||||
.body("find { it.via == 'getState' }.field", equalTo("sucheStatus"))
|
||||
.body("find { it.via == 'getState' }.lineNo", equalTo(12))
|
||||
.body("find { it.via == 'reducer' }.function", equalTo("schluesseltabelle/updateKeyTableUseCaseSvcResult"))
|
||||
.body("find { it.via == 'reducer' }.path", equalTo("keyTableUseCaseSvcResult.result.tableId"));
|
||||
get("/store/schluesseltabelle/accesses?mode=writes").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(3))
|
||||
.body("functionKind", everyItem(equalTo("reducer")))
|
||||
.body("find { it.function == 'schluesseltabelle/suche/fulfilled' }.field", equalTo("sucheStatus"));
|
||||
get("/store/schluesseltabelle/accesses?field=sucheStatus&countOnly=true").then().statusCode(200).body("count", equalTo(3));
|
||||
get("/store/schluesseltabelle/accesses?module=KeyTablePage").then().statusCode(200).body("size()", equalTo(3));
|
||||
get("/store/schluesseltabelle/accesses?mode=bogus").then().statusCode(400).body("code", equalTo("MODE_UNSUPPORTED"));
|
||||
get("/store/nope/accesses").then().statusCode(404).body("code", equalTo("SLICE_NOT_FOUND"));
|
||||
// the destructured whole-slice selector reads gruppenprovision.loading
|
||||
get("/store/generalAgreement/accesses?mode=reads").then().statusCode(200)
|
||||
.body("size()", equalTo(1)).body("[0].field", equalTo("loading")).body("[0].function", equalTo("KeyTablePage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeFieldsAnswerTheGenericVariableEndpointsToo() {
|
||||
get("/variables/schluesseltabelle.sucheStatus/writes").then()
|
||||
.statusCode(200)
|
||||
.body("function", containsInAnyOrder("schluesseltabelle/updateKeyTableUseCaseSvcResult", "schluesseltabelle/suche/fulfilled"));
|
||||
get("/variables/schluesseltabelle.requestStatus/reads").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(1))
|
||||
.body("[0].function", equalTo("KeyTablePage"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchedActionsAreCallsToTheSliceModulesAndReducersAreFunctions() {
|
||||
// the dispatched action creators resolve to the slice modules (the CALLS edge carries
|
||||
// calleeMethod = the reducer's action type; item 197 tracks exposing that at function level)
|
||||
get("/modules/KeyTablePage/callees").then()
|
||||
.statusCode(200)
|
||||
.body("items.name", hasItems("app/src/store/slices/keytableSlice", "app/src/store/slices/generalAgreementSlice"))
|
||||
.body("items.find { it.name == 'app/src/store/slices/keytableSlice' }.unresolved", equalTo(false));
|
||||
get("/modules/keytableSlice/functions").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.name == 'schluesseltabelle/suche/fulfilled' }.kind", equalTo("reducer"))
|
||||
.body("find { it.name == 'sucheByServer' }.kind", equalTo("thunk"));
|
||||
// no placeholder survives: every consumer read was redirected onto the slice's own nodes
|
||||
get("/search/identifier?name=schluesseltabelle.requestStatus").then().statusCode(200)
|
||||
.body("findAll { it.sourceFile == '' }.size()", equalTo(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Item 196: a frontend with an MUI theme, components styling through sx/style/styled with theme
|
||||
* tokens, constants and hard-coded literals, and a CSS file. After a deep refresh the theme tokens
|
||||
* carry use counts, token reads resolve across files, undeclared tokens are listed as such, and the
|
||||
* style inventory answers. Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
|
||||
*/
|
||||
@QuarkusTest
|
||||
class StylesIT {
|
||||
|
||||
private static final String PROJECT = "styles-frontend";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixture() {
|
||||
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write(root, "package.json", """
|
||||
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
|
||||
""");
|
||||
Path src = root.resolve("app/src");
|
||||
write(src, "mui.ts", """
|
||||
// stand-in for @mui/material (no node_modules in the fixture)
|
||||
export function createTheme(options: any): any { return options }
|
||||
export function styled(base: any, options?: any): any { return (fn: any) => base }
|
||||
export function useTheme(): any { return {} }
|
||||
export const Box: any = 'Box'
|
||||
""");
|
||||
write(src, "theme.ts", """
|
||||
import { createTheme } from 'mui'
|
||||
export const PRIMARY = '#005CA9'
|
||||
const PRIMARY_DARK = '#0054A2'
|
||||
export const UNUSED_GRAY = '#808080'
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
primary: { main: PRIMARY, dark: PRIMARY_DARK },
|
||||
background: { paper: '#FCFCFD' },
|
||||
},
|
||||
shape: { borderRadius: 4 },
|
||||
})
|
||||
""");
|
||||
// item 199: a second theme file declares the same primary.main (and only that) -> a read of it
|
||||
// resolves onto both declarations, and neither is reported unused
|
||||
write(src, "darkTheme.ts", """
|
||||
import { createTheme } from 'mui'
|
||||
import { PRIMARY } from './theme'
|
||||
export const darkTheme = createTheme({
|
||||
palette: { mode: 'dark', primary: { main: PRIMARY } },
|
||||
})
|
||||
""");
|
||||
write(src.resolve("components"), "Panel.tsx", """
|
||||
import { styled, Box } from 'mui'
|
||||
export const Panel = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(2),
|
||||
color: theme.palette.primary.dark,
|
||||
border: '1px solid #D2D2D2',
|
||||
}))
|
||||
""");
|
||||
write(src.resolve("components"), "Page.tsx", """
|
||||
import { Box, useTheme } from 'mui'
|
||||
import { PRIMARY, theme as appTheme } from 'theme'
|
||||
import { Panel } from './Panel'
|
||||
|
||||
export function Page() {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Panel sx={{ mt: 2, color: PRIMARY, borderColor: PRIMARY, '&:hover': { background: theme.palette.background.paper } }}>
|
||||
<Box style={{ color: appTheme.palette.primary.main, height: '17px' }} borderColor={theme.palette.grey['200']}>x</Box>
|
||||
<Box sx={{ width: '100%' }} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
""");
|
||||
write(src, "index.css", """
|
||||
body {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
}
|
||||
@font-face {
|
||||
font-family: OpenSans;
|
||||
src: url("/fonts/OpenSans.ttf");
|
||||
}
|
||||
""");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "typescript", null, null))
|
||||
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200)
|
||||
.body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response get(String path) {
|
||||
return given().when().get("/api/projects/" + PROJECT + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void themeTokensCarryValuesAndUseCounts() {
|
||||
get("/theme").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.token == 'palette.primary.dark' }.value", equalTo("#0054A2"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.constant", equalTo("PRIMARY_DARK"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.kind", equalTo("path"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.declared", equalTo(true))
|
||||
.body("find { it.token == 'palette.primary.dark' }.module", equalTo("app/src/theme"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'palette.primary.main' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'palette.background.paper' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'PRIMARY' }.kind", equalTo("constant"))
|
||||
.body("find { it.token == 'PRIMARY' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'shape.borderRadius' }.value", equalTo("4"))
|
||||
.body("find { it.token == 'shape.borderRadius' }.uses", equalTo(0))
|
||||
// read by the code, declared by no theme: MUI defaults / the spacing function
|
||||
.body("find { it.token == 'palette.grey.200' }.declared", equalTo(false))
|
||||
.body("find { it.token == 'palette.grey.200' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'spacing' }.declared", equalTo(false));
|
||||
get("/theme?unused=true").then().statusCode(200)
|
||||
.body("token", containsInAnyOrder("shape.borderRadius", "UNUSED_GRAY", "palette.mode"));
|
||||
// item 199: two declaring files, one row each, both counting the single read; no placeholder left
|
||||
get("/theme").then().statusCode(200)
|
||||
.body("findAll { it.token == 'palette.primary.main' }.module", containsInAnyOrder("app/src/theme", "app/src/darkTheme"))
|
||||
.body("findAll { it.token == 'palette.primary.main' }.uses", everyItem(equalTo(1)))
|
||||
.body("findAll { it.token == 'palette.primary.main' }.declared", everyItem(equalTo(true)));
|
||||
get("/theme/palette.primary.main/usages").then().statusCode(200)
|
||||
.body("size()", equalTo(1)).body("[0].function", equalTo("Page"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenUsagesNameTheBlockAndTheProperty() {
|
||||
get("/theme/palette.primary.dark/usages").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(1))
|
||||
.body("[0].function", equalTo("Panel"))
|
||||
.body("[0].styleKind", equalTo("styled"))
|
||||
.body("[0].element", equalTo("Box"))
|
||||
.body("[0].property", equalTo("color"))
|
||||
.body("[0].module", equalTo("app/src/components/Panel"))
|
||||
.body("[0].lineNo", equalTo(4));
|
||||
get("/theme/palette.grey.200/usages").then().statusCode(200)
|
||||
.body("[0].function", equalTo("Page"))
|
||||
.body("[0].context", equalTo("borderColor"))
|
||||
.body("[0].styleKind", nullValue());
|
||||
// item 199: two reads of one token on one line are one usage listing both keys
|
||||
get("/theme/PRIMARY/usages").then().statusCode(200)
|
||||
.body("size()", equalTo(1))
|
||||
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color,borderColor")).body("[0].element", equalTo("Panel"));
|
||||
get("/theme/nope/usages").then().statusCode(404).body("code", equalTo("TOKEN_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void styleInventoryListsBlocksLiteralsAndCssRules() {
|
||||
get("/styles").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(6))
|
||||
.body("findAll { it.styleKind == 'sx' }.size()", equalTo(2))
|
||||
.body("find { it.styleKind == 'styled' }.function", equalTo("Panel"))
|
||||
.body("find { it.styleKind == 'styled' }.properties", equalTo("padding,color,border"))
|
||||
.body("find { it.styleKind == 'styled' }.literals", equalTo("1px,#D2D2D2"))
|
||||
.body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark"))
|
||||
.body("find { it.styleKind == 'style' }.literals", equalTo("17px"))
|
||||
.body("find { it.styleKind == 'style' }.tokens", contains("palette.primary.main"))
|
||||
.body("find { it.name == 'Page.sx@8:12' }.properties", equalTo("mt,color,borderColor,&:hover.background"))
|
||||
.body("find { it.name == 'body@1' }.styleKind", equalTo("css"))
|
||||
.body("find { it.name == 'body@1' }.literals", equalTo("#333333"))
|
||||
.body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src"));
|
||||
get("/styles?withLiterals=true").then().statusCode(200).body("size()", equalTo(4));
|
||||
get("/styles?kind=css&countOnly=true").then().statusCode(200).body("count", equalTo(2));
|
||||
get("/styles?module=Page").then().statusCode(200).body("size()", equalTo(3));
|
||||
get("/styles?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SourceFiles#classify} — the extension → (language, node kind) mapping that
|
||||
@@ -78,6 +80,32 @@ class SourceFilesTest {
|
||||
SourceFiles.classify(Path.of("WSUBPX0S.nat"), "natjav"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeScriptAndCssAreModulesButDeclarationFilesAreNot() {
|
||||
// item 192
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-r-vstamm/src/store/slices/agstammSlice.ts")));
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-ui/src/App.tsx")));
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.CSS, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-ui/src/index.css")));
|
||||
assertNull(SourceFiles.classify(Path.of("pur-ui/src/vite-env.d.ts")), "type declarations carry no code");
|
||||
assertNull(SourceFiles.classify(Path.of("pur-ui/src/routeTree.gen.js")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyATypeScriptProjectIngestsTypeScript() {
|
||||
// item 192: a Java project with a bundled web UI (ac-ui) must not start parsing it.
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "typescript"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.CSS, "TypeScript"));
|
||||
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "java"));
|
||||
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.CSS, null));
|
||||
// Java and Natural stay language-agnostic: `ac` carries .cpy Natural fixtures next to its Java.
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.NATURAL, "java"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, "typescript"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownExtensionsAreNotIngestible() {
|
||||
assertNull(SourceFiles.classify(Path.of("WNAUTD0S.meta")));
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -6,6 +6,17 @@ import java.util.List;
|
||||
* Wrapper returned by the {@code /callers} and {@code /callees} endpoints: a deduplicated
|
||||
* {@code sourceFiles} index plus aggregated call references that use integer indices into
|
||||
* that list instead of repeating the full path on every entry.
|
||||
*
|
||||
* @param total item 181: how many references exist before paging
|
||||
* @param truncated item 181: {@code true} when {@code items} is a page that stops before {@code total}
|
||||
* — the default {@code limit=50} used to cut a fan-out silently
|
||||
*/
|
||||
public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) {
|
||||
public record CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items, int total, boolean truncated) {
|
||||
|
||||
/**
|
||||
* An unpaged response: everything is in {@code items}.
|
||||
*/
|
||||
public CallRefResponse(List<String> sourceFiles, List<AggregatedCallRef> items) {
|
||||
this(sourceFiles, items, items.size(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -157,6 +157,26 @@ public final class CypherQueries {
|
||||
* {@code (project, ownerModule)} would go further still, but has to earn its write-side cost
|
||||
* against <em>this</em> shape — measure before adding it.
|
||||
*/
|
||||
/**
|
||||
* Item 198: after a deep re-parse of a file, deletes every edge from that file's nodes that the
|
||||
* fresh parse did not re-emit — the edge carries the {@code ingestGen} the merge stamped on it
|
||||
* ({@link #mergeEdgesBatch}), and an edge the parse re-emitted was re-stamped through the MERGE
|
||||
* key. Language-agnostic: the three Natural reaps above cover their statement kinds, but a Java
|
||||
* or TypeScript call/import/type edge whose target the new parse names differently (a renamed
|
||||
* class, a {@code dist}→{@code src} mapping fix) lingered forever next to the fresh one, keeping
|
||||
* its placeholder alive. Finalize-built edges never carry a stamp unless a resolver copied it from
|
||||
* a parser edge, and those are rebuilt by the deep finalize that always follows. Runs after
|
||||
* {@code merge-edges}; deep only, because a Tier-1 pass emits far fewer edges than a deep one.
|
||||
* Keyed on the item-160 {@code (sourceFile, ownerModule)} pairs like the reaps above.
|
||||
*/
|
||||
public static final String DELETE_STALE_PARSED_EDGES = """
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f})-[r]->()
|
||||
WHERE src.ownerModule IN p.os
|
||||
AND r.ingestGen IS NOT NULL AND r.ingestGen <> $ingestGen
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
public static final String DELETE_STALE_PLACEHOLDER_NODES = """
|
||||
MATCH (n:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE n.ownerModule IN $owners
|
||||
@@ -181,7 +201,7 @@ public final class CypherQueries {
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f})-[r:READS|WRITES]->(fld:AstNode)
|
||||
WHERE src.ownerModule IN p.os
|
||||
AND (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
|
||||
AND (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT' OR fld.store = 'true' OR fld.binding = 'true')
|
||||
AND fld.sourceFile <> "" AND fld.sourceFile <> p.f
|
||||
DELETE r
|
||||
""";
|
||||
@@ -836,8 +856,9 @@ public final class CypherQueries {
|
||||
* through an {@code IMPLEMENTS}/{@code EXTENDS} chain (Class-Hierarchy-Analysis over-approximation).
|
||||
*
|
||||
* <p>Synthetic edges are tagged {@code resolvedVia: 'INHERITANCE'} so they {@code MERGE}
|
||||
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; the call-site
|
||||
* {@code lineNo} is copied from the originating interface/base call. Runs after placeholder
|
||||
* idempotently and stay disjoint from directly-parsed {@code CALLS} edges; since item 203 there is
|
||||
* one per originating interface/base call site ({@code lineNo}, {@code originFile}), carrying its
|
||||
* {@code callKind}, {@code calleeMethod} and {@code callerFn}. Runs after placeholder
|
||||
* resolution and dataflow so both the {@code CALLS} and {@code IMPLEMENTS}/{@code EXTENDS} edges
|
||||
* already point at real modules, and dataflow is not routed through the synthetic edges.
|
||||
*
|
||||
@@ -854,8 +875,14 @@ public final class CypherQueries {
|
||||
AND ( ()-[:IMPLEMENTS]->(base) OR ()-[:EXTENDS]->(base) )
|
||||
MATCH (impl:AstNode {type: 'MODULE', project: $project})-[:IMPLEMENTS|EXTENDS*1..]->(base)
|
||||
WHERE impl <> caller AND impl.sourceFile <> ""
|
||||
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE'}]->(impl)
|
||||
ON CREATE SET s.lineNo = r.lineNo, s.callKind = r.callKind
|
||||
// Item 203: one synthetic edge per originating call site, keyed like the parsed edge it
|
||||
// derives from. Keyed on the pair alone, the line was whichever base call the MERGE met
|
||||
// first (95 in one run, 97 in the next for the same pair) and calleeMethod/callerFn were
|
||||
// lost, so function-level callers could not see a call made through the interface.
|
||||
// `args` is deliberately not copied: dataflow is not routed through synthetic edges.
|
||||
MERGE (caller)-[s:CALLS {resolvedVia: 'INHERITANCE', lineNo: r.lineNo,
|
||||
originFile: coalesce(r.originFile, caller.sourceFile)}]->(impl)
|
||||
SET s.callKind = r.callKind, s.calleeMethod = r.calleeMethod, s.callerFn = r.callerFn
|
||||
""";
|
||||
|
||||
/**
|
||||
@@ -1462,19 +1489,39 @@ public final class CypherQueries {
|
||||
""";
|
||||
/**
|
||||
* Item 52: function-level callers â the {@code FUNCTION} nodes that {@code CALLS} the subroutine/method
|
||||
* {@code $function} defined in module {@code $name} (the intra-module {@code PERFORM} sites, and any
|
||||
* cross-class Java method callers). Complements module-granularity {@link #callers(String)}. Shaped
|
||||
* like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos) so it reuses the same DTO.
|
||||
* {@code $function} defined in module {@code $name}. Complements module-granularity
|
||||
* {@link #callers(String)}. Shaped like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos)
|
||||
* so it reuses the same DTO. Two branches (item 197): the direct {@code FUNCTION -CALLS-> FUNCTION}
|
||||
* edges (Natural {@code PERFORM}, same-class Java calls), and the cross-module calls, which the Java
|
||||
* and TypeScript parsers write as a {@code MODULE -CALLS-> MODULE} edge carrying {@code callerFn} and
|
||||
* {@code calleeMethod}, joined on those two names back to the calling {@code FUNCTION}. Methods are
|
||||
* matched by name, so overloads over-approximate (like {@link #LINK_ARGS_TO_PARAMS_JAVA_CROSS}); a
|
||||
* call whose {@code callerFn} is not a FUNCTION of the calling module (top-level code) has no row here
|
||||
* and shows only in the module-level {@code callers}.
|
||||
*/
|
||||
public static final String FUNCTION_CALLERS = """
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(callee:FUNCTION {name: $function})
|
||||
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'PERFORM') AS edgeKind,
|
||||
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
|
||||
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
|
||||
CALL {
|
||||
WITH callee
|
||||
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'PERFORM') AS edgeKind,
|
||||
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
|
||||
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
|
||||
UNION
|
||||
WITH m, callee
|
||||
MATCH (callerModule:MODULE {project: $project})-[r:CALLS]->(m)
|
||||
WHERE r.callerFn IS NOT NULL AND r.calleeMethod = callee.name
|
||||
AND coalesce(r.manualHidden, false) = false
|
||||
MATCH (callerModule)-[:CONTAINS]->(caller:FUNCTION {name: r.callerFn})
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'METHOD_CALL') AS edgeKind,
|
||||
collect(DISTINCT {lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, callerModule.sourceFile),
|
||||
viaCopycode: null, includedAt: null, includePath: null}) AS sites
|
||||
}
|
||||
RETURN name, type, sourceFile, edgeKind, sites
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
@@ -1529,12 +1576,15 @@ public final class CypherQueries {
|
||||
] AS memberRoots
|
||||
UNWIND (CASE WHEN size(memberRoots) > 0 THEN memberRoots ELSE canon END) AS s
|
||||
MATCH (s)-[:CONTAINS*1..]->(f:AstNode)
|
||||
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
|
||||
// FIELD: a TypeScript interface property (item 193); items 195 counts its bindings
|
||||
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE', 'FIELD']
|
||||
MATCH (p:AstNode)-[:CONTAINS]->(f)
|
||||
WHERE p = s OR (s)-[:CONTAINS*1..]->(p)
|
||||
RETURN DISTINCT f.name AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
|
||||
RETURN DISTINCT coalesce(f.field, f.name) AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
|
||||
f.startLine AS startLine, f.endLine AS endLine, f.scope AS scope,
|
||||
f.sourceFile AS sourceFile
|
||||
f.sourceFile AS sourceFile,
|
||||
size([(f)<-[r:READS]-() WHERE r.via = 'binding' | 1]) AS boundReads,
|
||||
size([(f)<-[r:WRITES]-() WHERE r.via = 'binding' | 1]) AS boundWrites
|
||||
""";
|
||||
/**
|
||||
* Deletes placeholder nodes ({@code sourceFile = ""}, type {@code MODULE}/
|
||||
@@ -1940,6 +1990,20 @@ public final class CypherQueries {
|
||||
SET r2.dynamicVar = dyn.dynamicVar, r2.args = dyn.args, r2.resolvedBy = 'manual'
|
||||
SET dyn.manualHidden = true
|
||||
""";
|
||||
/**
|
||||
* Item 200: the modules from whose files a dynamic call at {@code (originFile, lineNo)} is made —
|
||||
* the same fan-out as {@link #APPLY_MANUAL_DYNAMIC_CALLNAT} (every includer of a copycode site).
|
||||
* With both null: every module that currently owns a manual edge. The override apply/reset then
|
||||
* rebuilds exactly these modules' derived {@code CALLS_MODULE} edges so {@code reaches} and
|
||||
* {@code field-flow} see the pinned target without a refresh.
|
||||
*/
|
||||
public static final String CALLER_MODULES_AT_SITE = """
|
||||
MATCH (m:AstNode {type: 'MODULE', project: $project})-[:CONTAINS*0..1]->(src:AstNode)-[r:CALLS {callKind: 'CALLNAT_DYNAMIC'}]->()
|
||||
WHERE ($originFile IS NULL AND r.resolvedBy = 'manual')
|
||||
OR ($originFile IS NOT NULL AND coalesce(r.originFile, src.sourceFile) = $originFile
|
||||
AND ($lineNo IS NULL OR r.lineNo = $lineNo))
|
||||
RETURN collect(DISTINCT m.name) AS names
|
||||
""";
|
||||
/**
|
||||
* Upserts one manual override node, keyed by {@code (project, originFile, lineNo)}.
|
||||
*/
|
||||
@@ -2734,7 +2798,8 @@ public final class CypherQueries {
|
||||
""";
|
||||
public static final String CREATE_PROJECT = """
|
||||
CREATE (p:Project {name: $name, description: $description, root: $root, excludeDirs: $excludeDirs,
|
||||
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir})
|
||||
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir,
|
||||
counterparts: $counterparts})
|
||||
""";
|
||||
/**
|
||||
* Updates a project. {@code null} parameters leave the corresponding property unchanged
|
||||
@@ -2748,12 +2813,14 @@ public final class CypherQueries {
|
||||
p.excludeDirs = COALESCE($excludeDirs, p.excludeDirs),
|
||||
p.language = COALESCE($language, p.language),
|
||||
p.generatedDir = COALESCE($generatedDir, p.generatedDir),
|
||||
p.userExitDir = COALESCE($userExitDir, p.userExitDir)
|
||||
p.userExitDir = COALESCE($userExitDir, p.userExitDir),
|
||||
p.counterparts = COALESCE($counterparts, p.counterparts)
|
||||
""";
|
||||
public static final String GET_PROJECT = """
|
||||
MATCH (p:Project {name: $name})
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
p.counterparts AS counterparts,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
@@ -2770,6 +2837,38 @@ public final class CypherQueries {
|
||||
* graph. Split from the node delete so {@code recreate} can reuse the latter alone. Not batched: it
|
||||
* is a single node.
|
||||
*/
|
||||
/**
|
||||
* Item 202: renames a project. The name is the key on every {@code AstNode} ({@code project}), on the
|
||||
* {@code DynamicCallOverride}s and on the {@code Project} shell, and other shells may list it in
|
||||
* {@code counterparts}; edges carry no project. The node rewrite is batched like
|
||||
* {@link #DELETE_PROJECT_NODES} (implicit transaction) and runs first, so an interrupted rename is
|
||||
* finished by re-running it: nodes already moved answer to the new name, the shell still to the old.
|
||||
*/
|
||||
public static final String RENAME_PROJECT_NODES = """
|
||||
MATCH (n:AstNode {project: $from})
|
||||
CALL { WITH n SET n.project = $to } IN TRANSACTIONS OF $batchSize ROWS
|
||||
""";
|
||||
public static final String RENAME_PROJECT_OVERRIDES = """
|
||||
MATCH (o:DynamicCallOverride {project: $from})
|
||||
SET o.project = $to
|
||||
""";
|
||||
public static final String RENAME_PROJECT_COUNTERPART_REFS = """
|
||||
MATCH (p:Project)
|
||||
WHERE $from IN coalesce(p.counterparts, [])
|
||||
SET p.counterparts = [c IN p.counterparts | CASE WHEN c = $from THEN $to ELSE c END]
|
||||
""";
|
||||
public static final String RENAME_PROJECT_SHELL = """
|
||||
MATCH (p:Project {name: $from})
|
||||
SET p.name = $to
|
||||
""";
|
||||
/**
|
||||
* Item 202 (found while deleting {@code upms2}): a project's manual overrides are its own data and
|
||||
* go with the full delete. The recreate path (item 78) keeps them on purpose — they are config.
|
||||
*/
|
||||
public static final String DELETE_PROJECT_OVERRIDES = """
|
||||
MATCH (o:DynamicCallOverride {project: $name})
|
||||
DELETE o
|
||||
""";
|
||||
public static final String DELETE_PROJECT_SHELL = """
|
||||
MATCH (p:Project {name: $name})
|
||||
DELETE p
|
||||
@@ -2782,6 +2881,7 @@ public final class CypherQueries {
|
||||
MATCH (p:Project)
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
p.counterparts AS counterparts,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
@@ -2840,6 +2940,50 @@ public final class CypherQueries {
|
||||
// transaction for that exceeds the limit whatever the heap is set to.
|
||||
CALL { WITH c DELETE c } IN TRANSACTIONS OF $batchSize ROWS
|
||||
""";
|
||||
// Declared before their first use (REST_ENDPOINTS, BINDINGS): a static final String initialiser
|
||||
// that references a later field is an illegal forward reference; an IDE member sort had moved them.
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
// Item 193: a TypeScript endpoint FUNCTION (generated web-service client) stamps
|
||||
// outbound='true' itself — the frontend's calls, listed by the same query.
|
||||
(coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' OR f.outbound = 'true') AS outbound
|
||||
""";
|
||||
/**
|
||||
* Item 195: every binding site of the project — a component/hook reading or writing a DTO field
|
||||
* through a generated {@code Fields} path object — with the field's {@code COUNTERPART_OF} twin in
|
||||
* the backend project, so "which page edits Java {@code Broker.ebene}" is one query.
|
||||
*/
|
||||
private static final String BINDINGS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
|
||||
WHERE m.sourceFile <> '' AND ($dto IS NULL OR d.name = $dto) AND ($field IS NULL OR f.field = $field)
|
||||
MATCH (fn:AstNode)-[r:READS|WRITES]->(f)
|
||||
WHERE r.via = 'binding' AND ($mode IS NULL OR type(r) = $mode) AND ($partial IS NULL OR r.partial = $partial)
|
||||
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
|
||||
WITH d, f, r, fn, head(collect(om)) AS owner
|
||||
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
|
||||
OPTIONAL MATCH (f)-[:COUNTERPART_OF]->(c:AstNode)
|
||||
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
|
||||
WHERE cm.sourceFile <> ''
|
||||
WITH d, f, r, fn, owner, c, head(collect(cm)) AS cm
|
||||
""";
|
||||
private static final String BINDINGS_ROW = """
|
||||
type(r) AS mode, d.name AS dto, coalesce(f.field, f.name) AS field, r.path AS path, r.rootDto AS rootDto, r.kind AS kind,
|
||||
r.partial = 'true' AS partial, r.component AS component, r.attribute AS attribute,
|
||||
fn.name AS function, fn.type AS functionType, fn.kind AS functionKind, owner.name AS module,
|
||||
fn.sourceFile AS sourceFile, r.lineNo AS lineNo,
|
||||
c.project AS counterpartProject, cm.name AS counterpartModule, c.name AS counterpartField
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 130: the project's REST surface — one row per handler method, with the endpoint path
|
||||
* composed from the class-level and method-level {@code @Path} (item 130 persists both as
|
||||
@@ -2883,17 +3027,26 @@ public final class CypherQueries {
|
||||
// identical rows — 183 of 436 on `pur`.
|
||||
""";
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
* The projects whose counterpart edges must be (re)built after {@code $project} was refreshed:
|
||||
* the project itself, and every project that lists it as a counterpart. The per-file reconcile
|
||||
* {@code DETACH DELETE}s a refreshed handler together with the edges pointing at it, so a
|
||||
* backend refresh has to relink from the frontend side too.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' AS outbound
|
||||
public static final String COUNTERPART_HOLDERS = """
|
||||
MATCH (p:Project)
|
||||
WHERE p.name = $project OR $project IN coalesce(p.counterparts, [])
|
||||
RETURN p.name AS name
|
||||
""";
|
||||
/**
|
||||
* Step 3: the fields of a linked DTO, by name.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_FIELD = """
|
||||
MATCH (d:AstNode {project: $project, type: 'DATA_STRUCTURE'})-[:COUNTERPART_OF {via: 'dto'}]->(j)
|
||||
MATCH (d)-[:CONTAINS]->(fd:AstNode {type: 'FIELD'})
|
||||
// a TypeScript field is named <Interface>.<member> (item 195); `field` is the bare member
|
||||
MATCH (j)-[:CONTAINS]->(jf:AstNode {type: 'FIELD', name: coalesce(fd.field, fd.name)})
|
||||
MERGE (fd)-[r:COUNTERPART_OF]->(jf)
|
||||
SET r.via = 'field'
|
||||
""";
|
||||
public static final String REST_ENDPOINTS = REST_ENDPOINTS_CORE + "RETURN " + REST_ENDPOINTS_ROW + """
|
||||
ORDER BY path, httpMethod
|
||||
@@ -4174,6 +4327,342 @@ public final class CypherQueries {
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Item 193: COUNTERPART_OF — the same thing in another project.
|
||||
// ------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Step 0: this project's own counterpart edges are rebuilt from scratch, never accumulated.
|
||||
*/
|
||||
public static final String DELETE_COUNTERPART_EDGES = """
|
||||
MATCH (n:AstNode {project: $project})-[r:COUNTERPART_OF]->()
|
||||
DELETE r
|
||||
""";
|
||||
/**
|
||||
* Step 1: an outbound web-service call ({@code FUNCTION} with {@code outbound='true'},
|
||||
* {@code restPath}, {@code httpMethod} — a TypeScript endpoint, item 193) is linked to the handler
|
||||
* in a counterpart project that serves the same verb and path shape (every {@code {param}}
|
||||
* segment compares as {@code {}}, empty segments are ignored). The handler's path is composed
|
||||
* exactly as {@link #REST_ENDPOINTS} composes it (class-level + method-level {@code @Path},
|
||||
* inherited from the nearest ancestor) — <b>keep the two in step</b>. When several handlers match
|
||||
* (the backend project holds more than one application), the one whose source lives under the
|
||||
* frontend's {@code backend} name wins.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_REST = """
|
||||
MATCH (p:Project {name: $project})
|
||||
UNWIND coalesce(p.counterparts, []) AS cp
|
||||
MATCH (fm:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE f.outbound = 'true' AND f.restPath IS NOT NULL AND f.httpMethod IS NOT NULL
|
||||
MATCH (m:AstNode {project: cp, type: 'MODULE'})-[:CONTAINS]->(h:AstNode {type: 'FUNCTION'})
|
||||
WHERE h.httpMethod = f.httpMethod AND h.sourceFile <> ''
|
||||
OPTIONAL MATCH ancestry = (m)-[:EXTENDS|IMPLEMENTS*1..4]->(base:AstNode {type: 'MODULE'})
|
||||
WHERE base.restPath IS NOT NULL
|
||||
WITH f, m, h, base, length(ancestry) AS depth
|
||||
ORDER BY depth
|
||||
WITH f, m, h, head(collect(base.restPath)) AS inheritedPath
|
||||
WITH f, m, h, coalesce(m.restPath, inheritedPath, '') AS classPath, coalesce(h.restPath, '') AS methodPath
|
||||
WITH f, m, h, [x IN [classPath, methodPath] WHERE x <> '' AND x <> '/' |
|
||||
CASE WHEN left(x, 1) = '/' THEN substring(x, 1) ELSE x END] AS lead
|
||||
WITH f, m, h, [x IN lead | CASE WHEN size(x) > 0 AND right(x, 1) = '/' THEN left(x, size(x) - 1) ELSE x END] AS parts
|
||||
WITH f, m, h, '/' + reduce(acc = '', x IN parts | CASE WHEN acc = '' THEN x ELSE acc + '/' + x END) AS hpath
|
||||
WITH f, m, h, reduce(acc = '', s IN split(hpath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS hkey,
|
||||
reduce(acc = '', s IN split(f.restPath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS fkey
|
||||
WHERE hkey = fkey
|
||||
WITH f, h, CASE WHEN f.backend IS NOT NULL AND m.sourceFile CONTAINS f.backend THEN 0 ELSE 1 END AS pref
|
||||
ORDER BY pref
|
||||
WITH f, collect(h)[0] AS h
|
||||
MERGE (f)-[r:COUNTERPART_OF]->(h)
|
||||
SET r.via = 'rest'
|
||||
""";
|
||||
/**
|
||||
* Step 2: a data structure declared in a <em>generated</em> module (the frontend's mirror of the
|
||||
* backend DTOs) is linked to the counterpart project's class of the same simple name — only when
|
||||
* that name is unique there; an ambiguous name is left unlinked rather than guessed.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_DTO = """
|
||||
MATCH (p:Project {name: $project})
|
||||
UNWIND coalesce(p.counterparts, []) AS cp
|
||||
MATCH (gm:AstNode {project: $project, type: 'MODULE', generated: 'true'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
MATCH (j:AstNode {project: cp, type: 'MODULE', simpleName: d.name})
|
||||
WHERE j.sourceFile <> ''
|
||||
WITH d, collect(DISTINCT j) AS js
|
||||
WHERE size(js) = 1
|
||||
WITH d, js[0] AS j
|
||||
MERGE (d)-[r:COUNTERPART_OF]->(j)
|
||||
SET r.via = 'dto'
|
||||
""";
|
||||
/**
|
||||
* Item 196: redirects {@code REFERENCES} from a theme-token placeholder ({@code FIELD},
|
||||
* {@code sourceFile=""}, {@code theme='true'}) onto every real token of the same name — with a
|
||||
* light and a dark theme file the read reaches whichever is active, so each declaring file's row
|
||||
* counts the use (item 199); an undeclared token (an MUI default, a typo) keeps its
|
||||
* placeholder on purpose — {@code GET /theme} lists it with {@code declared=false}.
|
||||
*/
|
||||
public static final String RESOLVE_THEME_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.theme = 'true'
|
||||
MATCH (real:AstNode {project: $project, type: 'FIELD', name: ph.name})
|
||||
WHERE real.sourceFile <> "" AND real.theme = 'true'
|
||||
WITH ph, collect(real) AS reals
|
||||
MATCH (src:AstNode)-[r:REFERENCES]->(ph)
|
||||
UNWIND reals AS real
|
||||
MERGE (src)-[r2:REFERENCES {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
WITH DISTINCT r
|
||||
DELETE r
|
||||
""".formatted(edgeKey(EdgeType.REFERENCES, "coalesce(r.lineNo, -1)", "r.originFile", "src"));
|
||||
/**
|
||||
* Item 194: a store placeholder whose every access was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_STORE_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD'] AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart listing. Candidates are this project's outbound calls, its generated
|
||||
* data structures and their fields; each row carries its twin, or nulls when {@code $unmatched}
|
||||
* selects exactly the ones without one — "what is not served / not mirrored yet".
|
||||
*/
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 194: the frontend Redux store — slices, fields, reducer/selector accesses
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 194: every slice of the project's store with its state keys and access counts.
|
||||
* {@code $slice} narrows to one slice by reducer key or by RTK slice name.
|
||||
*/
|
||||
public static final String STORE_SLICES = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(s:AstNode {project: $project, type: 'STORE_SLICE'})
|
||||
WHERE s.sourceFile <> '' AND ($slice IS NULL OR s.name = $slice OR s.sliceName = $slice)
|
||||
OPTIONAL MATCH (s)-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
|
||||
WITH m, s, f ORDER BY f.startLine, f.name
|
||||
WITH m, s, collect(CASE WHEN f IS NULL THEN null ELSE {
|
||||
name: f.field, type: f.dataType, optional: f.optional = 'true',
|
||||
reads: size([(f)<-[:READS]-() | 1]), writes: size([(f)<-[:WRITES]-() | 1])} END) AS fields
|
||||
RETURN s.name AS slice, s.sliceName AS sliceName, m.name AS module, s.sourceFile AS sourceFile,
|
||||
s.startLine AS startLine, s.endLine AS endLine, s.stateType AS stateType, fields,
|
||||
size([(m)-[:CONTAINS]->(r:AstNode {type: 'FUNCTION', kind: 'reducer', slice: s.name}) | 1]) AS reducers,
|
||||
size([(s)<-[:READS]-() | 1]) AS sliceReads, size([(s)<-[:WRITES]-() | 1]) AS sliceWrites
|
||||
ORDER BY slice
|
||||
""";
|
||||
/**
|
||||
* Item 195: a binding placeholder whose every edge was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_BINDING_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.binding = 'true' AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
private static final Map<EdgeType, String> RESOLVE_STORE_PLACEHOLDERS = buildResolveStorePlaceholderQueries();
|
||||
/**
|
||||
* Item 194: who reads and writes a slice's state — reducers (the writers, {@code functionKind =
|
||||
* reducer}) and the components/hooks/thunks selecting from it. A row per access site; {@code field}
|
||||
* is null for an access to the whole slice state, {@code path} is the full sub-path as written
|
||||
* ({@code agstammUseCaseSvcResult.result.purMode}), {@code via} the hook or {@code reducer}/{@code getState}.
|
||||
*/
|
||||
private static final String STORE_ACCESSES_CORE = """
|
||||
MATCH (s:AstNode {project: $project, type: 'STORE_SLICE'})
|
||||
WHERE s.sourceFile <> '' AND (s.name = $slice OR s.sliceName = $slice)
|
||||
MATCH (s)-[:CONTAINS*0..1]->(t:AstNode)
|
||||
WHERE (t = s OR t.type = 'FIELD') AND ($field IS NULL OR t.field = $field)
|
||||
MATCH (fn:AstNode)-[r:READS|WRITES]->(t)
|
||||
WHERE $mode IS NULL OR type(r) = $mode
|
||||
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
|
||||
WITH s, t, r, fn, head(collect(om)) AS owner
|
||||
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
|
||||
""";
|
||||
private static final String STORE_ACCESSES_ROW = """
|
||||
type(r) AS mode, s.name AS slice, t.field AS field, r.path AS path, fn.name AS function, fn.type AS functionType,
|
||||
fn.kind AS functionKind, owner.name AS module, fn.sourceFile AS sourceFile, r.lineNo AS lineNo, r.via AS via
|
||||
""";
|
||||
public static final String STORE_ACCESSES = STORE_ACCESSES_CORE + "RETURN " + STORE_ACCESSES_ROW + """
|
||||
ORDER BY mode, field, module, lineNo
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String STORE_ACCESSES_COUNT = STORE_ACCESSES_CORE + "WITH " + STORE_ACCESSES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
private static final Map<EdgeType, String> RESOLVE_BINDING_PLACEHOLDERS = buildResolveBindingPlaceholderQueries();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 195: DTO field bindings
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 196: a theme placeholder whose every reference was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_THEME_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.theme = 'true' AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
/**
|
||||
* Item 196: every theme token — declared ones (under the {@code theme} structure) and the
|
||||
* undeclared ones the code reads (placeholders) — with its project-side use count.
|
||||
*/
|
||||
public static final String THEME_TOKENS = """
|
||||
MATCH (t:AstNode {project: $project, type: 'FIELD'})
|
||||
WHERE t.theme = 'true'
|
||||
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS]->(:AstNode {type: 'DATA_STRUCTURE'})-[:CONTAINS]->(t)
|
||||
WITH t, head(collect(m)) AS m, size([(t)<-[:REFERENCES]-() | 1]) AS uses
|
||||
WHERE NOT $unused OR uses = 0
|
||||
RETURN coalesce(t.token, t.name) AS token, t.tokenKind AS kind, t.value AS value, t.constant AS constant,
|
||||
t.sourceFile <> '' AS declared, m.name AS module, t.startLine AS lineNo, uses
|
||||
ORDER BY declared DESC, token
|
||||
""";
|
||||
public static final String BINDINGS = BINDINGS_CORE + "RETURN " + BINDINGS_ROW + """
|
||||
ORDER BY dto, field, mode, module, lineNo
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String BINDINGS_COUNT = BINDINGS_CORE + "WITH " + BINDINGS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 196: styling — theme tokens, style blocks
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 196: where one token is read — style blocks (with the key they feed) and plain code/prop reads.
|
||||
*/
|
||||
public static final String THEME_USAGES = """
|
||||
MATCH (t:AstNode {project: $project, type: 'FIELD'})
|
||||
WHERE t.theme = 'true' AND (t.token = $token OR t.name = $token)
|
||||
MATCH (x:AstNode)-[r:REFERENCES]->(t)
|
||||
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(x)
|
||||
WITH r, x, CASE WHEN x.type = 'FUNCTION' THEN x ELSE fn END AS f
|
||||
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS*0..2]->(x)
|
||||
WITH r, x, f, head(collect(m)) AS m
|
||||
// DISTINCT: a read resolved onto two theme files (item 199) is one usage, not two
|
||||
RETURN DISTINCT coalesce(f.name, x.name) AS function, f.kind AS functionKind, m.name AS module, x.sourceFile AS sourceFile,
|
||||
r.lineNo AS lineNo, x.styleKind AS styleKind, x.element AS element, r.property AS property, r.context AS context
|
||||
ORDER BY module, lineNo
|
||||
""";
|
||||
/**
|
||||
* Item 196: the style inventory — every sx/style/styled block and CSS rule with keys, literals and tokens.
|
||||
*/
|
||||
private static final String STYLES_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(s:AstNode {type: 'STYLE'})
|
||||
WHERE m.sourceFile <> ''
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
AND ($kind IS NULL OR s.styleKind = $kind)
|
||||
AND (NOT $withLiterals OR (s.literals IS NOT NULL AND s.literals <> ''))
|
||||
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(s)
|
||||
WITH DISTINCT m, s, head(collect(fn)) AS fn
|
||||
""";
|
||||
private static final String STYLES_ROW = """
|
||||
s.name AS name, s.styleKind AS styleKind, s.element AS element, s.selector AS selector, fn.name AS function,
|
||||
m.name AS module, s.sourceFile AS sourceFile, s.startLine AS lineNo, s.properties AS properties,
|
||||
s.literals AS literals, s.dynamic = 'true' AS dynamic,
|
||||
reduce(acc = [], tk IN [(s)-[:REFERENCES]->(t) | coalesce(t.token, t.name)] |
|
||||
CASE WHEN tk IN acc THEN acc ELSE acc + tk END) AS tokens
|
||||
""";
|
||||
public static final String STYLES = STYLES_CORE + "RETURN " + STYLES_ROW + """
|
||||
ORDER BY module, lineNo, name
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String STYLES_COUNT = STYLES_CORE + "WITH " + STYLES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
private static final String COUNTERPARTS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(n:AstNode)
|
||||
WHERE m.sourceFile <> ''
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
AND ( (n.type = 'FUNCTION' AND n.outbound = 'true' AND n.restPath IS NOT NULL)
|
||||
OR (n.type = 'DATA_STRUCTURE' AND m.generated = 'true')
|
||||
OR (n.type = 'FIELD' AND m.generated = 'true'))
|
||||
WITH DISTINCT m, n, CASE n.type WHEN 'FUNCTION' THEN 'rest' WHEN 'DATA_STRUCTURE' THEN 'dto' ELSE 'field' END AS kind
|
||||
WHERE $kind IS NULL OR kind = $kind
|
||||
OPTIONAL MATCH (n)-[:COUNTERPART_OF]->(c:AstNode)
|
||||
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
|
||||
WHERE cm.sourceFile <> ''
|
||||
WITH m, n, kind, c, head(collect(cm)) AS cm
|
||||
WHERE NOT $unmatched OR c IS NULL
|
||||
""";
|
||||
private static final String COUNTERPARTS_ROW = """
|
||||
kind AS kind, n.name AS name, m.name AS module, n.sourceFile AS sourceFile, n.startLine AS startLine,
|
||||
n.httpMethod AS httpMethod, n.restPath AS path,
|
||||
c.project AS counterpartProject, c.name AS counterpartName,
|
||||
CASE WHEN c IS NULL THEN null WHEN c.type = 'MODULE' THEN c.name ELSE cm.name END AS counterpartModule,
|
||||
c.sourceFile AS counterpartSourceFile, c.startLine AS counterpartStartLine
|
||||
""";
|
||||
public static final String COUNTERPARTS = COUNTERPARTS_CORE + "RETURN " + COUNTERPARTS_ROW + """
|
||||
ORDER BY kind, module, name
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String COUNTERPARTS_COUNT = COUNTERPARTS_CORE + "WITH " + COUNTERPARTS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 194: redirects {@code READS}/{@code WRITES} from a store placeholder — a {@code STORE_SLICE}
|
||||
* or store {@code FIELD} with {@code sourceFile=""} and {@code store='true'}, minted by the file
|
||||
* that reads {@code state.<key>.<field>} — onto the real node of the same type and name (the slice
|
||||
* file declares {@code <key>.<field>} with {@code store='true'}). Store names are unique by
|
||||
* construction (one reducer key per store), so a plain name match is exact; the generic
|
||||
* placeholder resolver never sees these because it is limited to {@code MODULE}/{@code DATA_STRUCTURE}.
|
||||
* Cheap (bounded by the project's store placeholders), so it runs in every finalize mode.
|
||||
*/
|
||||
public static String resolveStorePlaceholders(EdgeType type) {
|
||||
String query = RESOLVE_STORE_PLACEHOLDERS.get(type);
|
||||
if (query == null) {
|
||||
throw new IllegalArgumentException("Not a store access edge type: " + type);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static Map<EdgeType, String> buildResolveStorePlaceholderQueries() {
|
||||
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
|
||||
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
queries.put(type, """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD']
|
||||
MATCH (real:AstNode {project: $project, type: ph.type, name: ph.name})
|
||||
WHERE real.sourceFile <> "" AND real.store = 'true'
|
||||
MATCH (src:AstNode)-[r:%s]->(ph)
|
||||
MERGE (src)-[r2:%s {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
DELETE r
|
||||
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 195: redirects {@code READS}/{@code WRITES} from a binding placeholder — a {@code FIELD}
|
||||
* with {@code sourceFile=""}, {@code binding='true'}, {@code owner}, {@code field} and
|
||||
* {@code targetModule} — onto the real {@code FIELD} declared under that {@code DATA_STRUCTURE} in
|
||||
* that module. Exact by construction (module → structure → field), which is why the generic
|
||||
* placeholder resolver (name only, and never a module-owned structure — item 74) is not used.
|
||||
*/
|
||||
public static String resolveBindingPlaceholders(EdgeType type) {
|
||||
String query = RESOLVE_BINDING_PLACEHOLDERS.get(type);
|
||||
if (query == null) {
|
||||
throw new IllegalArgumentException("Not a binding edge type: " + type);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static Map<EdgeType, String> buildResolveBindingPlaceholderQueries() {
|
||||
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
|
||||
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
queries.put(type, """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.binding = 'true'
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE', name: ph.targetModule})
|
||||
-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE', name: ph.owner})
|
||||
-[:CONTAINS]->(real:AstNode {type: 'FIELD', field: ph.field})
|
||||
WHERE m.sourceFile <> "" AND real.sourceFile <> ""
|
||||
MATCH (src:AstNode)-[r:%s]->(ph)
|
||||
MERGE (src)-[r2:%s {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
DELETE r
|
||||
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
private static String edgeKey(EdgeType type, String lineNoExpr, String originExpr, String sourceNodeVar) {
|
||||
if (!needsFileDiscriminator(type)) {
|
||||
return "lineNo: %s".formatted(lineNoExpr);
|
||||
@@ -4192,6 +4681,7 @@ public final class CypherQueries {
|
||||
MERGE (a)-[r:%s {%s}]->(b)
|
||||
SET r.value = e.value
|
||||
SET r += e.properties
|
||||
SET r.ingestGen = $ingestGen
|
||||
""".formatted(type.name(), edgeKey(type, "e.lineNo", "e.properties.originFile", "a")));
|
||||
}
|
||||
return queries;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -139,6 +139,21 @@ public class GraphRepository {
|
||||
statements.add(new EnrichmentStep("resolve-placeholder " + type,
|
||||
CypherQueries.resolvePlaceholderTargets(type), true));
|
||||
}
|
||||
// Item 194: store READS/WRITES from selectors and reducers onto the real slice/field nodes.
|
||||
// Cheap (bounded by the store placeholders of a TypeScript project, none elsewhere) and
|
||||
// exact by name, so it runs in every mode and its placeholders are dropped right away.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-store-placeholder " + type, CypherQueries.resolveStorePlaceholders(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-store-placeholders", CypherQueries.DELETE_RESOLVED_STORE_PLACEHOLDERS));
|
||||
// Item 195: DTO field bindings onto the generated interface's FIELD (module -> structure -> field).
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-binding-placeholder " + type, CypherQueries.resolveBindingPlaceholders(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-binding-placeholders", CypherQueries.DELETE_RESOLVED_BINDING_PLACEHOLDERS));
|
||||
// Item 196: theme-token reads onto the declared token (exact name, one theme per project).
|
||||
statements.add(new EnrichmentStep("resolve-theme-placeholder", CypherQueries.RESOLVE_THEME_PLACEHOLDERS));
|
||||
statements.add(new EnrichmentStep("delete-resolved-theme-placeholders", CypherQueries.DELETE_RESOLVED_THEME_PLACEHOLDERS));
|
||||
// J7: resolve Panache-ness inherited through a project base class (needs EXTENDS pointing at
|
||||
// real nodes, i.e. after the placeholder loop above) by setting repositoryEntity on the
|
||||
// concrete repository; must run before resolve-java-db-access, which reads it. Project-wide,
|
||||
@@ -312,6 +327,12 @@ public class GraphRepository {
|
||||
return statements;
|
||||
}
|
||||
|
||||
private static final List<EnrichmentStep> COUNTERPART_STEPS = List.of(
|
||||
new EnrichmentStep("delete-counterpart-edges", CypherQueries.DELETE_COUNTERPART_EDGES),
|
||||
new EnrichmentStep("link-counterparts-rest", CypherQueries.LINK_COUNTERPARTS_REST),
|
||||
new EnrichmentStep("link-counterparts-dto", CypherQueries.LINK_COUNTERPARTS_DTO),
|
||||
new EnrichmentStep("link-counterparts-field", CypherQueries.LINK_COUNTERPARTS_FIELD));
|
||||
|
||||
/**
|
||||
* Strips a single leading Natural sigil ({@code #}, {@code &}, {@code +}) so name search is sigil-insensitive.
|
||||
*/
|
||||
@@ -923,21 +944,6 @@ public class GraphRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static DataStructureField toDataStructureField(Record record) {
|
||||
return new DataStructureField(
|
||||
record.get("name").asString(),
|
||||
record.get("type").asString(),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("parent").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|
||||
|| record.get("sourceFile").asString().isEmpty()
|
||||
? null : record.get("sourceFile").asString());
|
||||
}
|
||||
|
||||
public Uni<List<DataStructureField>> dbTableColumns(String project, String name) {
|
||||
return read(CypherQueries.DB_TABLE_COLUMNS, moduleParams(project, name, ANY_SOURCE_FILE),
|
||||
GraphRepository::toDataStructureField);
|
||||
@@ -1393,7 +1399,9 @@ public class GraphRepository {
|
||||
.toList();
|
||||
reindexed.add(new AggregatedCallRef(item.name(), item.type(), newIdx, item.edgeKind(), sites, item.unresolved()));
|
||||
}
|
||||
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed);
|
||||
int total = resp.items().size();
|
||||
return new CallRefResponse(new ArrayList<>(fileIndex.keySet()), reindexed, total,
|
||||
Math.max(offset, 0) + reindexed.size() < total);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1426,20 +1434,21 @@ public class GraphRepository {
|
||||
// Write operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
|
||||
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
|
||||
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
|
||||
*/
|
||||
private static ProjectInfo toProjectInfo(Record record) {
|
||||
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
|
||||
String root = record.get("root").isNull() ? "" : record.get("root").asString();
|
||||
List<String> excludeDirs = record.get("excludeDirs").isNull()
|
||||
? List.of()
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
|
||||
private static DataStructureField toDataStructureField(Record record) {
|
||||
return new DataStructureField(
|
||||
record.get("name").asString(),
|
||||
record.get("type").asString(),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("parent").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|
||||
|| record.get("sourceFile").asString().isEmpty()
|
||||
? null : record.get("sourceFile").asString(),
|
||||
record.containsKey("boundReads") ? record.get("boundReads").asInt(0) : 0,
|
||||
record.containsKey("boundWrites") ? record.get("boundWrites").asInt(0) : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1872,6 +1881,25 @@ public class GraphRepository {
|
||||
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep");
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
|
||||
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
|
||||
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
|
||||
*/
|
||||
private static ProjectInfo toProjectInfo(Record record) {
|
||||
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
|
||||
String root = record.get("root").isNull() ? "" : record.get("root").asString();
|
||||
List<String> excludeDirs = record.get("excludeDirs").isNull()
|
||||
? List.of()
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
List<String> counterparts = !record.containsKey("counterparts") || record.get("counterparts").isNull()
|
||||
? List.of()
|
||||
: record.get("counterparts").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record), counterparts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param level how far enrichment goes — see {@link EnrichmentLevel}. Field-placeholder
|
||||
* resolution (the expensive part) runs only for {@link EnrichmentLevel#FULL};
|
||||
@@ -1888,7 +1916,170 @@ public class GraphRepository {
|
||||
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
|
||||
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
|
||||
Map.of("project", project, "batchSize", deleteBatchSize),
|
||||
level.name().toLowerCase(java.util.Locale.ROOT), profile);
|
||||
level.name().toLowerCase(java.util.Locale.ROOT), profile)
|
||||
.chain(() -> linkCounterparts(project));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: (re)builds the {@code COUNTERPART_OF} edges of {@code project} and of every project
|
||||
* that lists it as a counterpart — the latter because a refresh of the backend
|
||||
* {@code DETACH DELETE}d the handlers the frontend's edges pointed at.
|
||||
*/
|
||||
public Uni<Void> linkCounterparts(String project) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
return read(CypherQueries.COUNTERPART_HOLDERS, params, record -> record.get("name").asString())
|
||||
.chain(holders -> {
|
||||
Uni<Void> chain = Uni.createFrom().voidItem();
|
||||
for (String holder : holders) {
|
||||
chain = chain.chain(() -> runEnrichment(holder, COUNTERPART_STEPS, Map.of("project", holder), "counterparts"));
|
||||
}
|
||||
return chain;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart listing as a page (see {@link CypherQueries#COUNTERPARTS}).
|
||||
*
|
||||
* @param kind {@code rest}, {@code dto}, {@code field} or {@code null} for all
|
||||
* @param unmatched only rows without a twin
|
||||
*/
|
||||
/**
|
||||
* Item 194: the slices of the project's Redux store, optionally one by reducer key or slice name.
|
||||
*/
|
||||
public Uni<List<StoreSlice>> storeSlices(String project, @Nullable String slice) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("slice", slice);
|
||||
return read(CypherQueries.STORE_SLICES, params, record -> {
|
||||
List<StoreSlice.Field> fields = new ArrayList<>();
|
||||
int reads = record.get("sliceReads").asInt();
|
||||
int writes = record.get("sliceWrites").asInt();
|
||||
for (Value f : record.get("fields").asList(v -> v)) {
|
||||
int fr = f.get("reads").asInt();
|
||||
int fw = f.get("writes").asInt();
|
||||
reads += fr;
|
||||
writes += fw;
|
||||
fields.add(new StoreSlice.Field(f.get("name").asString(), f.get("type").isNull() ? null : f.get("type").asString(),
|
||||
f.get("optional").asBoolean(false), fr, fw));
|
||||
}
|
||||
return new StoreSlice(record.get("slice").asString(), nullableString(record, "sliceName"), record.get("module").asString(),
|
||||
record.get("sourceFile").asString(), record.get("startLine").asInt(), record.get("endLine").asInt(),
|
||||
nullableString(record, "stateType"), fields, record.get("reducers").asInt(), reads, writes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 194: the access sites of one slice — {@code mode} = {@code READS}/{@code WRITES}/null (both),
|
||||
* {@code field} = one state key or null (all, including whole-slice accesses), {@code module} = only
|
||||
* accesses from that module.
|
||||
*/
|
||||
public Uni<Page<StoreAccess>> storeAccessesPage(String project, String slice, @Nullable String field, @Nullable String mode,
|
||||
@Nullable String module, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("slice", slice);
|
||||
params.put("field", field);
|
||||
params.put("mode", mode);
|
||||
params.put("module", module);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.STORE_ACCESSES, params, record -> new StoreAccess(
|
||||
record.get("mode").asString(), record.get("slice").asString(), nullableString(record, "field"),
|
||||
nullableString(record, "path"), record.get("function").asString(), record.get("functionType").asString(),
|
||||
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "via")))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STORE_ACCESSES_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: every theme token with its use count; {@code unused} = only tokens no project code references.
|
||||
*/
|
||||
public Uni<List<ThemeToken>> themeTokens(String project, boolean unused) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("unused", unused);
|
||||
return read(CypherQueries.THEME_TOKENS, params, record -> new ThemeToken(record.get("token").asString(),
|
||||
nullableString(record, "kind"), nullableString(record, "value"), nullableString(record, "constant"),
|
||||
record.get("declared").asBoolean(false), nullableString(record, "module"), intOrNull(record.get("lineNo")),
|
||||
record.get("uses").asInt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: where one theme token (by token path or {@code theme.<token>} name) is read.
|
||||
*/
|
||||
public Uni<List<ThemeUsage>> themeUsages(String project, String token) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("token", token);
|
||||
return read(CypherQueries.THEME_USAGES, params, record -> new ThemeUsage(record.get("function").asString(),
|
||||
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "styleKind"), nullableString(record, "element"),
|
||||
nullableString(record, "property"), nullableString(record, "context")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: the style inventory, optionally one module, one kind ({@code sx|style|styled|css}) or only blocks with literals.
|
||||
*/
|
||||
public Uni<Page<StyleBlock>> stylesPage(String project, @Nullable String module, @Nullable String kind, boolean withLiterals,
|
||||
int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
params.put("kind", kind);
|
||||
params.put("withLiterals", withLiterals);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.STYLES, params, record -> new StyleBlock(record.get("name").asString(),
|
||||
record.get("styleKind").asString(), nullableString(record, "element"), nullableString(record, "selector"),
|
||||
nullableString(record, "function"), record.get("module").asString(), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "properties"), nullableString(record, "literals"),
|
||||
record.get("dynamic").asBoolean(false), record.get("tokens").asList(Value::asString)))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STYLES_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 195: the binding sites of DTO fields, each with the field's backend counterpart.
|
||||
*/
|
||||
public Uni<Page<Binding>> bindingsPage(String project, @Nullable String dto, @Nullable String field, @Nullable String mode,
|
||||
@Nullable String module, @Nullable Boolean partial, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("dto", dto);
|
||||
params.put("field", field);
|
||||
params.put("mode", mode);
|
||||
params.put("module", module);
|
||||
params.put("partial", partial == null ? null : partial.toString());
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.BINDINGS, params, record -> new Binding(
|
||||
record.get("mode").asString(), record.get("dto").asString(), record.get("field").asString(),
|
||||
nullableString(record, "path"), nullableString(record, "rootDto"), nullableString(record, "kind"),
|
||||
record.get("partial").asBoolean(false), nullableString(record, "component"), nullableString(record, "attribute"),
|
||||
record.get("function").asString(), record.get("functionType").asString(), nullableString(record, "functionKind"),
|
||||
nullableString(record, "module"), record.get("sourceFile").asString(), intOrNull(record.get("lineNo")),
|
||||
nullableString(record, "counterpartProject"), nullableString(record, "counterpartModule"),
|
||||
nullableString(record, "counterpartField")))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.BINDINGS_COUNT, params)));
|
||||
}
|
||||
|
||||
public Uni<Page<Counterpart>> counterpartsPage(String project, @Nullable String module, @Nullable String kind,
|
||||
boolean unmatched, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
params.put("kind", kind);
|
||||
params.put("unmatched", unmatched);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.COUNTERPARTS, params, record -> new Counterpart(
|
||||
record.get("kind").asString(), record.get("name").asString(), record.get("module").asString(),
|
||||
record.get("sourceFile").asString(), record.get("startLine").asInt(),
|
||||
nullableString(record, "httpMethod"), nullableString(record, "path"),
|
||||
nullableString(record, "counterpartProject"), nullableString(record, "counterpartName"),
|
||||
nullableString(record, "counterpartModule"), nullableString(record, "counterpartSourceFile"),
|
||||
intOrNull(record.get("counterpartStartLine"))))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.COUNTERPARTS_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2209,6 +2400,16 @@ public class GraphRepository {
|
||||
String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir) {
|
||||
return createProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param counterparts item 193: projects this one's web-service calls and generated DTOs are linked to
|
||||
*/
|
||||
public Uni<ProjectOpResult> createProject(String name, @Nullable String description,
|
||||
String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir, List<String> counterparts) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
@@ -2223,6 +2424,7 @@ public class GraphRepository {
|
||||
params.put("language", language);
|
||||
params.put("generatedDir", generatedDir);
|
||||
params.put("userExitDir", userExitDir);
|
||||
params.put("counterparts", counterparts);
|
||||
tx.run(CypherQueries.CREATE_PROJECT, params);
|
||||
return ProjectOpResult.SUCCESS;
|
||||
});
|
||||
@@ -2240,6 +2442,16 @@ public class GraphRepository {
|
||||
@Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir) {
|
||||
return updateProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param counterparts item 193; {@code null} leaves the list unchanged
|
||||
*/
|
||||
public Uni<ProjectOpResult> updateProject(String name, @Nullable String description,
|
||||
@Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir, @Nullable List<String> counterparts) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
@@ -2254,6 +2466,7 @@ public class GraphRepository {
|
||||
params.put("language", language);
|
||||
params.put("generatedDir", generatedDir);
|
||||
params.put("userExitDir", userExitDir);
|
||||
params.put("counterparts", counterparts);
|
||||
tx.run(CypherQueries.UPDATE_PROJECT, params);
|
||||
return ProjectOpResult.SUCCESS;
|
||||
});
|
||||
@@ -2268,12 +2481,39 @@ public class GraphRepository {
|
||||
return ProjectOpResult.NOT_FOUND;
|
||||
}
|
||||
deleteNodesBatched(session, name);
|
||||
session.run(CypherQueries.DELETE_PROJECT_OVERRIDES, Map.of("name", name)).consume();
|
||||
session.run(CypherQueries.DELETE_PROJECT_SHELL, Map.of("name", name)).consume();
|
||||
return ProjectOpResult.SUCCESS;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 202: renames {@code from} to {@code to} — nodes (batched, implicit transaction like the
|
||||
* delete), overrides, other projects' {@code counterparts} entries, then the shell.
|
||||
*
|
||||
* @return {@code NOT_FOUND} if {@code from} does not exist, {@code CONFLICT} if {@code to} already
|
||||
* does, else {@code SUCCESS}.
|
||||
*/
|
||||
public Uni<ProjectOpResult> renameProject(String from, String to) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
if (!session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", from)).hasNext()) {
|
||||
return ProjectOpResult.NOT_FOUND;
|
||||
}
|
||||
if (session.run(CypherQueries.PROJECT_EXISTS, Map.of("name", to)).hasNext()) {
|
||||
return ProjectOpResult.CONFLICT;
|
||||
}
|
||||
Map<String, Object> params = Map.of("from", from, "to", to, "batchSize", deleteBatchSize);
|
||||
session.run(CypherQueries.RENAME_PROJECT_NODES, params).consume();
|
||||
session.run(CypherQueries.RENAME_PROJECT_OVERRIDES, params).consume();
|
||||
session.run(CypherQueries.RENAME_PROJECT_COUNTERPART_REFS, params).consume();
|
||||
session.run(CypherQueries.RENAME_PROJECT_SHELL, params).consume();
|
||||
return ProjectOpResult.SUCCESS;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 78: deletes a project's graph but keeps its {@code (:Project)} shell, so the config survives.
|
||||
*
|
||||
@@ -2487,41 +2727,13 @@ public class GraphRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a manual override for the call site {@code (originFile, lineNo)} and applies it to the
|
||||
* graph immediately (no refresh needed). Rejects the request — returning the offending names in
|
||||
* {@link OverrideUpsertResult#invalidTargets()} — if any target is not a real {@code MODULE}.
|
||||
*/
|
||||
public Uni<OverrideUpsertResult> upsertDynamicCallOverride(String project, String originFile, int lineNo,
|
||||
List<String> targets, @Nullable String variable,
|
||||
@Nullable String note, @Nullable String createdBy) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
List<String> existing = tx.run(CypherQueries.EXISTING_MODULE_TARGETS,
|
||||
Map.of("project", project, "targets", targets))
|
||||
.single().get("existing").asList(Value::asString);
|
||||
List<String> invalid = targets.stream().distinct()
|
||||
.filter(t -> !existing.contains(t)).toList();
|
||||
if (!invalid.isEmpty()) {
|
||||
return OverrideUpsertResult.invalid(invalid);
|
||||
}
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("originFile", originFile);
|
||||
params.put("lineNo", lineNo);
|
||||
params.put("targets", targets);
|
||||
params.put("variable", variable);
|
||||
params.put("note", note);
|
||||
params.put("createdBy", createdBy);
|
||||
params.put("createdAt", java.time.Instant.now().toString());
|
||||
Record r = tx.run(CypherQueries.UPSERT_DYNAMIC_CALL_OVERRIDE, params).single();
|
||||
// Apply immediately so the resolved edges show up without a refresh.
|
||||
tx.run(CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT, Map.of("project", project));
|
||||
return OverrideUpsertResult.ok(mapOverride(r));
|
||||
});
|
||||
}
|
||||
});
|
||||
private static List<String> callerModulesAtSite(TransactionContext tx, String project,
|
||||
@Nullable String originFile, @Nullable Integer lineNo) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("originFile", originFile);
|
||||
params.put("lineNo", lineNo);
|
||||
return tx.run(CypherQueries.CALLER_MODULES_AT_SITE, params).single().get("names").asList(Value::asString);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2790,6 +3002,15 @@ public class GraphRepository {
|
||||
stats.runQuery("merge-edges", tx, CypherQueries.mergeEdgesBatch(entry.getKey()),
|
||||
Map.of("edges", entry.getValue(), "ingestGen", ingestGen));
|
||||
}
|
||||
// Item 198: every edge from a re-parsed file's nodes that this parse did not re-emit (its
|
||||
// stamp is older than this run's) — the Java/TypeScript counterpart of the three Natural
|
||||
// reaps, for every edge type the parser owns. Before the node sweep on purpose: a resolved
|
||||
// edge to a real target of an older generation goes too, and the deep finalize rebuilds it
|
||||
// from the fresh placeholder edge; a placeholder left edgeless falls to the finalize sweeps.
|
||||
if (reconcile && !freshFiles.isEmpty()) {
|
||||
stats.runQuery("reap-stale-parsed-edges", tx, CypherQueries.DELETE_STALE_PARSED_EDGES,
|
||||
Map.of("project", project, "files", freshFiles, "ingestGen", ingestGen));
|
||||
}
|
||||
// Item 58: after merging the fresh nodes (whose ids are now written), delete each re-parsed
|
||||
// file's nodes that the fresh parse no longer produced (renamed/removed fields, moved
|
||||
// statements) so a refresh purges stale nodes instead of leaving them to shadow the new ones.
|
||||
@@ -2949,6 +3170,62 @@ public class GraphRepository {
|
||||
return searchByValue(project, value, substring, includeComments).map(list -> paginate(list, limit, offset));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 200: an override changes a module's real callees, and the item-68 {@code CALLS_MODULE}
|
||||
* projection is only rebuilt by the finalize. Rebuilding it for the calling modules here keeps
|
||||
* "applied immediately" true for {@code reaches} / {@code field-flow} too.
|
||||
*/
|
||||
private static void rebuildCallsModuleAtSite(TransactionContext tx, String project, String originFile, int lineNo) {
|
||||
rebuildCallsModule(tx, project, callerModulesAtSite(tx, project, originFile, lineNo));
|
||||
}
|
||||
|
||||
private static void rebuildCallsModule(TransactionContext tx, String project, List<String> modules) {
|
||||
if (modules.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> params = Map.of("project", project, "names", modules);
|
||||
tx.run(CypherQueries.DELETE_CALLS_MODULE_SCOPED, params);
|
||||
tx.run(CypherQueries.BUILD_CALLS_MODULE_SCOPED, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a manual override for the call site {@code (originFile, lineNo)} and applies it to the
|
||||
* graph immediately (no refresh needed). Rejects the request — returning the offending names in
|
||||
* {@link OverrideUpsertResult#invalidTargets()} — if any target is not a real {@code MODULE}.
|
||||
*/
|
||||
public Uni<OverrideUpsertResult> upsertDynamicCallOverride(String project, String originFile, int lineNo,
|
||||
List<String> targets, @Nullable String variable,
|
||||
@Nullable String note, @Nullable String createdBy) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
List<String> existing = tx.run(CypherQueries.EXISTING_MODULE_TARGETS,
|
||||
Map.of("project", project, "targets", targets))
|
||||
.single().get("existing").asList(Value::asString);
|
||||
List<String> invalid = targets.stream().distinct()
|
||||
.filter(t -> !existing.contains(t)).toList();
|
||||
if (!invalid.isEmpty()) {
|
||||
return OverrideUpsertResult.invalid(invalid);
|
||||
}
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("originFile", originFile);
|
||||
params.put("lineNo", lineNo);
|
||||
params.put("targets", targets);
|
||||
params.put("variable", variable);
|
||||
params.put("note", note);
|
||||
params.put("createdBy", createdBy);
|
||||
params.put("createdAt", java.time.Instant.now().toString());
|
||||
Record r = tx.run(CypherQueries.UPSERT_DYNAMIC_CALL_OVERRIDE, params).single();
|
||||
// Apply immediately so the resolved edges show up without a refresh.
|
||||
tx.run(CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT, Map.of("project", project));
|
||||
rebuildCallsModuleAtSite(tx, project, originFile, lineNo);
|
||||
return OverrideUpsertResult.ok(mapOverride(r));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets manual overrides — deletes the manual edges, un-hides the placeholder markers (so the
|
||||
* call sites read as unresolved again, inline, without a refresh) and removes the override nodes.
|
||||
@@ -2964,10 +3241,14 @@ public class GraphRepository {
|
||||
params.put("project", project);
|
||||
params.put("originFile", originFile);
|
||||
params.put("lineNo", lineNo);
|
||||
// Item 200: the callers must be collected before their manual edges are gone.
|
||||
List<String> callers = callerModulesAtSite(tx, project, originFile, lineNo);
|
||||
tx.run(CypherQueries.RESET_MANUAL_DYNAMIC_EDGES, params);
|
||||
tx.run(CypherQueries.UNHIDE_MANUAL_DYNAMIC_PLACEHOLDERS, params);
|
||||
return tx.run(CypherQueries.DELETE_DYNAMIC_CALL_OVERRIDE, params)
|
||||
int removed = tx.run(CypherQueries.DELETE_DYNAMIC_CALL_OVERRIDE, params)
|
||||
.consume().counters().nodesDeleted();
|
||||
rebuildCallsModule(tx, project, callers);
|
||||
return removed;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.util.List;
|
||||
* such projects must be updated with a root before they can be (re-)ingested. {@code excludeDirs}
|
||||
* holds path components skipped when walking {@code root} (e.g. {@code generated_sources}).
|
||||
*
|
||||
* <p>Item 193: {@code counterparts} names the projects whose nodes this project's outbound web-service
|
||||
* calls and generated DTOs are linked to by {@code COUNTERPART_OF} edges (a frontend lists its backend).
|
||||
*
|
||||
* <p>{@code language} is the project's declared source language ({@code "natural"}/{@code "java"});
|
||||
* required on create, {@code null} for legacy projects. It is an attribute only — ingest still
|
||||
* classifies files by extension. {@code generatedDir}/{@code userExitDir} (item 47) are directory
|
||||
@@ -21,13 +24,22 @@ import java.util.List;
|
||||
*/
|
||||
public record ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable ProjectIngestInfo ingest) {
|
||||
@Nullable ProjectIngestInfo ingest, List<String> counterparts) {
|
||||
|
||||
/**
|
||||
* Pre-item-193 shape: no counterparts.
|
||||
*/
|
||||
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable ProjectIngestInfo ingest) {
|
||||
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, ingest, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy convenience constructor for projects without a language / user-exit split.
|
||||
*/
|
||||
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs) {
|
||||
this(name, description, root, excludeDirs, null, null, null, null);
|
||||
this(name, description, root, excludeDirs, null, null, null, null, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +49,6 @@ public record ProjectInfo(String name, @Nullable String description, String root
|
||||
*/
|
||||
public ProjectInfo(String name, @Nullable String description, String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
this(name, description, root, excludeDirs, language, generatedDir, userExitDir, null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Item 194: one slice of a frontend Redux store — {@code slice} is the reducer key it is mounted
|
||||
* under ({@code state.<slice>}), {@code sliceName} the RTK name (the action-type prefix; usually the
|
||||
* same). {@code reads}/{@code writes} count access sites over the slice and all its fields.
|
||||
*/
|
||||
public record StoreSlice(String slice, @Nullable String sliceName, String module, String sourceFile, int startLine,
|
||||
int endLine,
|
||||
@Nullable String stateType, List<Field> fields, int reducers, int reads, int writes) {
|
||||
|
||||
/**
|
||||
* A top-level key of the slice state ({@code state.<slice>.<name>}).
|
||||
*/
|
||||
public record Field(String name, @Nullable String type, boolean optional, int reads, int writes) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.agenticcode.neo4jstore.graph;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Item 196: one style block — an {@code sx}/{@code style}/{@code styled} literal under a component
|
||||
* ({@code element} = the JSX tag or styled base) or a CSS rule ({@code styleKind=css}, {@code selector}).
|
||||
* {@code properties} = its CSS keys (nested selectors flattened), {@code literals} = hard-coded
|
||||
* colours/lengths, {@code tokens} = the theme tokens it reads, {@code dynamic} = a value the sidecar
|
||||
* could not classify.
|
||||
*/
|
||||
public record StyleBlock(String name, String styleKind, @Nullable String element, @Nullable String selector,
|
||||
@Nullable String function, String module, String sourceFile, @Nullable Integer lineNo,
|
||||
@Nullable String properties, @Nullable String literals, boolean dynamic, List<String> tokens) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -49,5 +49,13 @@ public enum NodeType {
|
||||
* <p>Attached to the declaration it documents by {@link EdgeType#DOCUMENTS}, never by
|
||||
* {@code CONTAINS} — see that constant for why. Exposed by {@code /modules/{name}/comments}.
|
||||
*/
|
||||
COMMENT
|
||||
COMMENT,
|
||||
/**
|
||||
* Item 194: a Redux Toolkit slice — one mounted piece of the frontend store, named by its reducer key.
|
||||
*/
|
||||
STORE_SLICE,
|
||||
/**
|
||||
* Item 196: one style block — an {@code sx}/{@code style}/{@code styled()} literal or a CSS rule.
|
||||
*/
|
||||
STYLE
|
||||
}
|
||||
|
||||
@@ -264,50 +264,461 @@ public final class JavaParser implements LanguageParser {
|
||||
* {@code L} suffix); references to another constant in the same class (bare {@code NAME} or
|
||||
* {@code ThisClass.NAME}) are resolved transitively.
|
||||
*/
|
||||
private static Map<String, String> collectConstants(TypeDeclaration<?> type) {
|
||||
private static Map<String, String> collectConstants(TypeDeclaration<?> type, Set<String> templates) {
|
||||
String className = type.getNameAsString();
|
||||
Map<String, Expression> initializers = new LinkedHashMap<>();
|
||||
Set<String> stringTyped = new HashSet<>();
|
||||
for (FieldDeclaration field : type.getFields()) {
|
||||
if (!(field.isStatic() && field.isFinal())) {
|
||||
continue;
|
||||
}
|
||||
for (VariableDeclarator variable : field.getVariables()) {
|
||||
variable.getInitializer().ifPresent(init -> initializers.put(variable.getNameAsString(), init));
|
||||
if (variable.getTypeAsString().equals("String")) {
|
||||
stringTyped.add(variable.getNameAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, String> resolved = new HashMap<>();
|
||||
ConstantScope scope = new ConstantScope(className, initializers, stringTyped, new HashMap<>(), templates);
|
||||
for (String name : initializers.keySet()) {
|
||||
resolveConstant(name, className, initializers, resolved, new HashSet<>());
|
||||
resolveConstant(name, scope, new HashSet<>());
|
||||
}
|
||||
return resolved;
|
||||
return scope.resolved();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String resolveConstant(String name, ConstantScope scope, Set<String> visiting) {
|
||||
if (scope.resolved().containsKey(name)) {
|
||||
return scope.resolved().get(name);
|
||||
}
|
||||
Expression init = scope.initializers().get(name);
|
||||
if (init == null || !visiting.add(name)) {
|
||||
return null; // unknown, or cycle
|
||||
}
|
||||
String referenced = referencedConstantName(init, scope.className());
|
||||
@Nullable String value;
|
||||
if (referenced != null) {
|
||||
value = resolveConstant(referenced, scope, visiting);
|
||||
if (scope.templates().contains(referenced)) {
|
||||
scope.templates().add(name);
|
||||
}
|
||||
} else if (scope.stringTyped().contains(name)) {
|
||||
value = foldString(name, init, scope, visiting);
|
||||
} else {
|
||||
value = literalValue(init);
|
||||
}
|
||||
visiting.remove(name);
|
||||
if (value != null) {
|
||||
scope.resolved().put(name, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constant value resolution (item 16)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Item 139: the value of a {@code String} constant built from text blocks, literals, same-class
|
||||
* constants and {@code +} — {@code CypherQueries}-style query text used to be invisible to
|
||||
* {@code search/value} because only a single plain literal was resolved. {@code "...".formatted(...)}
|
||||
* yields its template (with the {@code %s} placeholders) and marks {@code name} a template. Anything
|
||||
* not resolvable (a method call, another class's constant) makes the whole value {@code null}:
|
||||
* nothing is guessed.
|
||||
*/
|
||||
@Nullable
|
||||
private static String resolveConstant(String name, String className, Map<String, Expression> initializers,
|
||||
Map<String, String> resolved, Set<String> visiting) {
|
||||
if (resolved.containsKey(name)) {
|
||||
return resolved.get(name);
|
||||
private static String foldString(String name, Expression expr, ConstantScope scope, Set<String> visiting) {
|
||||
if (expr instanceof EnclosedExpr enclosed) {
|
||||
return foldString(name, enclosed.getInner(), scope, visiting);
|
||||
}
|
||||
Expression init = initializers.get(name);
|
||||
if (init == null || !visiting.add(name)) {
|
||||
return null; // unknown, or cycle
|
||||
if (expr instanceof TextBlockLiteralExpr text) {
|
||||
return text.asString();
|
||||
}
|
||||
String referenced = referencedConstantName(init, className);
|
||||
String value;
|
||||
String referenced = referencedConstantName(expr, scope.className());
|
||||
if (referenced != null) {
|
||||
value = resolveConstant(referenced, className, initializers, resolved, visiting);
|
||||
} else {
|
||||
value = literalValue(init);
|
||||
@Nullable String value = resolveConstant(referenced, scope, visiting);
|
||||
if (value != null && scope.templates().contains(referenced)) {
|
||||
scope.templates().add(name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
visiting.remove(name);
|
||||
if (value != null) {
|
||||
resolved.put(name, value);
|
||||
if (expr instanceof BinaryExpr binary && binary.getOperator() == BinaryExpr.Operator.PLUS) {
|
||||
@Nullable String left = foldString(name, binary.getLeft(), scope, visiting);
|
||||
@Nullable String right = left == null ? null : foldString(name, binary.getRight(), scope, visiting);
|
||||
return left == null || right == null ? null : left + right;
|
||||
}
|
||||
return value;
|
||||
if (expr instanceof MethodCallExpr call && call.getNameAsString().equals("formatted")
|
||||
&& call.getScope().isPresent()) {
|
||||
@Nullable String template = foldString(name, call.getScope().get(), scope, visiting);
|
||||
if (template != null) {
|
||||
scope.templates().add(name);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
return literalValue(expr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParseResult parse(String sourceFile, String content) {
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
|
||||
com.github.javaparser.ParseResult<CompilationUnit> parsed =
|
||||
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
|
||||
CompilationUnit unit = parsed.getResult()
|
||||
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
|
||||
|
||||
TypeResolver types = new TypeResolver(unit);
|
||||
|
||||
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
|
||||
TypeFacts facts = facts(type);
|
||||
String className = type.getNameAsString();
|
||||
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
|
||||
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
|
||||
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
|
||||
// and the API still accepts it as the convenient short form.
|
||||
// Local and anonymous classes have no FQN and keep the simple name.
|
||||
String fqn = type.getFullyQualifiedName().orElse(className);
|
||||
Map<String, String> moduleProps = new HashMap<>();
|
||||
moduleProps.put("simpleName", className);
|
||||
moduleProps.put("fqn", fqn);
|
||||
// J3: distinguish interfaces from classes (interface -> implementation resolution).
|
||||
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
|
||||
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
|
||||
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
|
||||
moduleProps.put("moduleKind", facts.kind());
|
||||
// The JPA/Panache heuristics below are class/interface notions — a record is never a
|
||||
// Panache repository, and generalizing them would apply guesswork to types the assumption
|
||||
// was never written for.
|
||||
@Nullable String repositoryEntity = null;
|
||||
if (type instanceof ClassOrInterfaceDeclaration cls) {
|
||||
// J1: tag repository classes with their managed entity so the enrichment step can map
|
||||
// a repository method call to the entity's DB_TABLE.
|
||||
repositoryEntity = repositoryEntityType(cls);
|
||||
if (repositoryEntity != null) {
|
||||
moduleProps.put("repositoryEntity", repositoryEntity);
|
||||
}
|
||||
// J7: a project base class that passes Panache-ness one level up to its subclasses
|
||||
// (e.g. AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>).
|
||||
// Tag it with its entity type parameter + own parameter list so the graph-side
|
||||
// enrichment step can bind a concrete subclass's EXTENDS type argument to it.
|
||||
@Nullable String panacheParam = panacheEntityTypeParam(cls);
|
||||
if (panacheParam != null) {
|
||||
moduleProps.put("panacheEntityTypeParam", panacheParam);
|
||||
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
|
||||
}
|
||||
// J1b: a repository-named interface with no generic entity argument anywhere in sight
|
||||
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
|
||||
if (repositoryEntity == null && panacheParam == null
|
||||
&& cls.isInterface() && isRepositoryReceiverName(className)) {
|
||||
repositoryEntity = fallbackRepositoryEntity(cls);
|
||||
if (repositoryEntity != null) {
|
||||
moduleProps.put("repositoryEntity", repositoryEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
type.getJavadoc().ifPresent(jd -> {
|
||||
String firstLine = jd.getDescription().toText().lines()
|
||||
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
|
||||
if (!firstLine.isEmpty()) {
|
||||
moduleProps.put("description", firstLine);
|
||||
}
|
||||
});
|
||||
// Collected before the module props below, because item 130's class-level @Path may be
|
||||
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
|
||||
// a @Column name does.
|
||||
Set<String> templateConstants = new HashSet<>();
|
||||
Map<String, String> constants = collectConstants(type, templateConstants);
|
||||
// Item 29: generic annotation capture for search_annotation, independent of any
|
||||
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
|
||||
@Nullable String typeAnnotations = annotationNames(type);
|
||||
if (typeAnnotations != null) {
|
||||
moduleProps.put("annotations", typeAnnotations);
|
||||
}
|
||||
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
|
||||
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
|
||||
// path lives half on the class and half on the method — composing it needed the source.
|
||||
@Nullable String typePath = restPath(type, className, constants);
|
||||
if (typePath != null) {
|
||||
moduleProps.put("restPath", typePath);
|
||||
}
|
||||
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
|
||||
type.getBegin().map(p -> p.line).orElse(1),
|
||||
type.getEnd().map(p -> p.line).orElse(1),
|
||||
null, null, moduleProps);
|
||||
nodes.add(typeNode);
|
||||
|
||||
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
|
||||
// and cross-class CALLS targets) by name, so repeated references don't create
|
||||
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
|
||||
Map<String, AstNode> referencedModules = new HashMap<>();
|
||||
|
||||
for (ClassOrInterfaceType extended : facts.extended()) {
|
||||
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
|
||||
Map<String, String> extendsProps = extendsTypeArgs(extended);
|
||||
edges.add(extendsProps.isEmpty()
|
||||
? edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine())
|
||||
: edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine(), null, extendsProps));
|
||||
}
|
||||
for (ClassOrInterfaceType implemented : facts.implemented()) {
|
||||
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
|
||||
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
|
||||
}
|
||||
|
||||
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
|
||||
|
||||
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
|
||||
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
|
||||
boolean isEntity = annotation(type, "Entity").isPresent()
|
||||
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
|
||||
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
|
||||
if (isEntity && !isMappedSuperclass) {
|
||||
String tableName = resolveTableName(type, className, constants);
|
||||
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
|
||||
nodes.add(tableNode);
|
||||
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
|
||||
}
|
||||
|
||||
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
|
||||
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
|
||||
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
|
||||
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
|
||||
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
|
||||
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
|
||||
// belongs to the class that declares it.
|
||||
Map<String, AstNode> fieldsByName = new HashMap<>();
|
||||
Map<String, String> fieldTypes = enclosingFieldTypes(type);
|
||||
for (FieldDeclaration field : type.getFields()) {
|
||||
boolean isConstant = field.isStatic() && field.isFinal();
|
||||
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
|
||||
@Nullable String fieldAnnotations = annotationNames(field);
|
||||
for (VariableDeclarator variable : field.getVariables()) {
|
||||
String dataType = variable.getTypeAsString();
|
||||
fieldTypes.put(variable.getNameAsString(), dataType);
|
||||
int startLine = field.getBegin().map(p -> p.line).orElse(1);
|
||||
int endLine = field.getEnd().map(p -> p.line).orElse(1);
|
||||
|
||||
AstNode fieldNode;
|
||||
if (column != null) {
|
||||
Map<String, String> props = columnProperties(field, column, variable, className, constants);
|
||||
if (fieldAnnotations != null) {
|
||||
props.put("annotations", fieldAnnotations);
|
||||
}
|
||||
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
|
||||
startLine, endLine, dataType, null, props);
|
||||
} else {
|
||||
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
|
||||
Map<String, String> fieldProps = new LinkedHashMap<>();
|
||||
if (fieldAnnotations != null) {
|
||||
fieldProps.put("annotations", fieldAnnotations);
|
||||
}
|
||||
if (value != null && templateConstants.contains(variable.getNameAsString())) {
|
||||
fieldProps.put("valueKind", "template"); // item 139: `"...".formatted(...)`
|
||||
}
|
||||
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
|
||||
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
|
||||
fieldProps.isEmpty() ? null : fieldProps);
|
||||
}
|
||||
nodes.add(fieldNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
|
||||
fieldsByName.put(variable.getNameAsString(), fieldNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
|
||||
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
|
||||
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
|
||||
// which is the failure mode item 114 is about.
|
||||
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
|
||||
for (Parameter component : record.getParameters()) {
|
||||
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
|
||||
line, line, component.getTypeAsString(), null,
|
||||
Map.of("recordComponent", "true"));
|
||||
nodes.add(componentNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
|
||||
fieldsByName.put(component.getNameAsString(), componentNode);
|
||||
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
|
||||
}
|
||||
}
|
||||
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
|
||||
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
|
||||
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
// The constant's own class body (a per-constant override) is not modelled as a
|
||||
// separate module; its methods would need an identity no source-level name gives them.
|
||||
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
|
||||
line, line, className, null, Map.of("enumConstant", "true"));
|
||||
nodes.add(constantNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
|
||||
fieldsByName.put(constant.getNameAsString(), constantNode);
|
||||
}
|
||||
}
|
||||
for (AnnotationMemberDeclaration member : type.getMembers().stream()
|
||||
.filter(AnnotationMemberDeclaration.class::isInstance)
|
||||
.map(AnnotationMemberDeclaration.class::cast).toList()) {
|
||||
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
Map<String, String> memberProps = new HashMap<>();
|
||||
memberProps.put("annotationMember", "true");
|
||||
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
|
||||
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
|
||||
// it carries, not which methods it declares.
|
||||
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
|
||||
line, line, member.getType().asString(), null, memberProps);
|
||||
nodes.add(memberNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
|
||||
fieldsByName.put(member.getNameAsString(), memberNode);
|
||||
}
|
||||
|
||||
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
|
||||
Map<String, AstNode> methods = new HashMap<>();
|
||||
List<CallableDeclaration<?>> callables = new ArrayList<>();
|
||||
List<AstNode> callableNodes = new ArrayList<>();
|
||||
for (MethodDeclaration method : type.getMethods()) {
|
||||
@Nullable String methodAnnotations = annotationNames(method);
|
||||
Map<String, String> methodProps = new HashMap<>();
|
||||
if (methodAnnotations != null) {
|
||||
methodProps.put("annotations", methodAnnotations);
|
||||
}
|
||||
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
|
||||
// means "the class path itself") and the HTTP verb annotation.
|
||||
@Nullable String methodPath = restPath(method, className, constants);
|
||||
if (methodPath != null) {
|
||||
methodProps.put("restPath", methodPath);
|
||||
}
|
||||
@Nullable String httpMethod = httpMethod(method);
|
||||
if (httpMethod != null) {
|
||||
methodProps.put("httpMethod", httpMethod);
|
||||
}
|
||||
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
|
||||
// implement/not override" without reading the base class source by hand.
|
||||
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
|
||||
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
|
||||
method.getBegin().map(p -> p.line).orElse(1),
|
||||
method.getEnd().map(p -> p.line).orElse(1),
|
||||
method.getTypeAsString(), null, methodProps);
|
||||
methods.put(method.getNameAsString(), methodNode);
|
||||
nodes.add(methodNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
|
||||
callables.add(method);
|
||||
callableNodes.add(methodNode);
|
||||
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
|
||||
// its JPQL/native-SQL DB access is captured here, at the declaration.
|
||||
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
|
||||
}
|
||||
for (ConstructorDeclaration ctor : type.getConstructors()) {
|
||||
@Nullable String ctorAnnotations = annotationNames(ctor);
|
||||
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
|
||||
ctor.getBegin().map(p -> p.line).orElse(1),
|
||||
ctor.getEnd().map(p -> p.line).orElse(1),
|
||||
className, null,
|
||||
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
|
||||
nodes.add(ctorNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
|
||||
callables.add(ctor);
|
||||
callableNodes.add(ctorNode);
|
||||
}
|
||||
|
||||
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
|
||||
for (int ci = 0; ci < callables.size(); ci++) {
|
||||
CallableDeclaration<?> callable = callables.get(ci);
|
||||
AstNode callableNode = callableNodes.get(ci);
|
||||
|
||||
Set<String> shadowed = new HashSet<>();
|
||||
// declaredTypes: field types plus this callable's parameter and local types, for
|
||||
// resolving a typed receiver (svc.method()) to its class.
|
||||
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
|
||||
int paramPosition = 0;
|
||||
for (Parameter parameter : callable.getParameters()) {
|
||||
shadowed.add(parameter.getNameAsString());
|
||||
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
|
||||
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
|
||||
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
|
||||
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
|
||||
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
|
||||
nodes.add(paramNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
|
||||
}
|
||||
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
|
||||
vde.getVariables().forEach(v -> {
|
||||
shadowed.add(v.getNameAsString());
|
||||
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
|
||||
}));
|
||||
// Every *nested* parameter too — lambda and catch parameters. They are bound names
|
||||
// like any local, but they are not VariableDeclarationExpr and not the callable's own
|
||||
// parameter list, so both loops above miss them. Names only: an implicit lambda
|
||||
// parameter has no usable declared type (see isProbableFieldReceiver).
|
||||
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
|
||||
|
||||
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
|
||||
|
||||
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
|
||||
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
|
||||
@Nullable Expression scope = call.getScope().orElse(null);
|
||||
if (scope == null || scope.isThisExpr()) {
|
||||
// Intra-class call.
|
||||
AstNode target = methods.get(call.getNameAsString());
|
||||
if (target != null) {
|
||||
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
|
||||
callProps(call, CallKind.METHOD_CALL)));
|
||||
}
|
||||
} else {
|
||||
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
|
||||
// callees/call-tree (which traverse from the module) span files uniformly
|
||||
// with Natural's module-level CALLNAT.
|
||||
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
|
||||
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
|
||||
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
|
||||
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
|
||||
// Item 116b: the receiver names a field this class does not declare — almost
|
||||
// always one inherited from a supertype, which lives in another file the
|
||||
// parser never sees. Record the receiver's *name* against a placeholder so
|
||||
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
|
||||
// Dropping it here is what left a service with 185 tests reporting no callers.
|
||||
AstNode marker = referencedModule(referencedModules, nodes,
|
||||
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
|
||||
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
|
||||
props.put("calleeMethod", call.getNameAsString());
|
||||
props.put("callerFn", callableNode.name());
|
||||
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
|
||||
}
|
||||
if (targetClass != null) {
|
||||
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
|
||||
// J5: carry the invoked method + enclosing function so enrichment can map
|
||||
// this cross-class call's arguments to the callee method's parameters.
|
||||
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
|
||||
props.put("calleeMethod", call.getNameAsString());
|
||||
props.put("callerFn", callableNode.name());
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
|
||||
}
|
||||
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
|
||||
boolean staticReceiver = scope.isNameExpr()
|
||||
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
|
||||
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
|
||||
callableNode, nodes, edges);
|
||||
}
|
||||
}
|
||||
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
|
||||
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
|
||||
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
|
||||
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
|
||||
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
|
||||
}
|
||||
}
|
||||
|
||||
// J2: CDI injection + class-literal wiring edges.
|
||||
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
|
||||
// Item 128: the reference index — imports, declared type positions, annotation usages.
|
||||
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
|
||||
}
|
||||
|
||||
|
||||
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
|
||||
// with nested types would emit every comment once per type.
|
||||
JavaComments.collect(unit, "java", sourceFile, nodes, edges, UnaryOperator.identity());
|
||||
|
||||
return new ParseResult(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1191,358 +1602,12 @@ public final class JavaParser implements LanguageParser {
|
||||
// DI + class-literal wiring edges (item J2)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public ParseResult parse(String sourceFile, String content) {
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
|
||||
com.github.javaparser.ParseResult<CompilationUnit> parsed =
|
||||
new com.github.javaparser.JavaParser(PARSER_CONFIGURATION).parse(content);
|
||||
CompilationUnit unit = parsed.getResult()
|
||||
.orElseThrow(() -> new ParseProblemException(parsed.getProblems()));
|
||||
|
||||
TypeResolver types = new TypeResolver(unit);
|
||||
|
||||
for (TypeDeclaration<?> type : unit.findAll(TypeDeclaration.class)) {
|
||||
TypeFacts facts = facts(type);
|
||||
String className = type.getNameAsString();
|
||||
// Item 117: the fully-qualified name IS the module's identity — a simple name does not
|
||||
// identify a class (nested @Nested classes, Builder, WorkingStorage: 8% of one real
|
||||
// codebase's modules were only ambiguously addressable). `simpleName` stays for display,
|
||||
// and the API still accepts it as the convenient short form.
|
||||
// Local and anonymous classes have no FQN and keep the simple name.
|
||||
String fqn = type.getFullyQualifiedName().orElse(className);
|
||||
Map<String, String> moduleProps = new HashMap<>();
|
||||
moduleProps.put("simpleName", className);
|
||||
moduleProps.put("fqn", fqn);
|
||||
// J3: distinguish interfaces from classes (interface -> implementation resolution).
|
||||
moduleProps.put("isInterface", String.valueOf(facts.isInterface()));
|
||||
// Item 1 / item 119: persisted module sub-kind for API filtering ("list all interfaces").
|
||||
// CLASS | INTERFACE | ENUM | RECORD | ANNOTATION.
|
||||
moduleProps.put("moduleKind", facts.kind());
|
||||
// The JPA/Panache heuristics below are class/interface notions — a record is never a
|
||||
// Panache repository, and generalizing them would apply guesswork to types the assumption
|
||||
// was never written for.
|
||||
@Nullable String repositoryEntity = null;
|
||||
if (type instanceof ClassOrInterfaceDeclaration cls) {
|
||||
// J1: tag repository classes with their managed entity so the enrichment step can map
|
||||
// a repository method call to the entity's DB_TABLE.
|
||||
repositoryEntity = repositoryEntityType(cls);
|
||||
if (repositoryEntity != null) {
|
||||
moduleProps.put("repositoryEntity", repositoryEntity);
|
||||
}
|
||||
// J7: a project base class that passes Panache-ness one level up to its subclasses
|
||||
// (e.g. AbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>).
|
||||
// Tag it with its entity type parameter + own parameter list so the graph-side
|
||||
// enrichment step can bind a concrete subclass's EXTENDS type argument to it.
|
||||
@Nullable String panacheParam = panacheEntityTypeParam(cls);
|
||||
if (panacheParam != null) {
|
||||
moduleProps.put("panacheEntityTypeParam", panacheParam);
|
||||
moduleProps.put("typeParams", String.join(",", ownTypeParamNames(cls)));
|
||||
}
|
||||
// J1b: a repository-named interface with no generic entity argument anywhere in sight
|
||||
// (e.g. a project-specific IRiskRepository) — guess the entity from a method return type.
|
||||
if (repositoryEntity == null && panacheParam == null
|
||||
&& cls.isInterface() && isRepositoryReceiverName(className)) {
|
||||
repositoryEntity = fallbackRepositoryEntity(cls);
|
||||
if (repositoryEntity != null) {
|
||||
moduleProps.put("repositoryEntity", repositoryEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
type.getJavadoc().ifPresent(jd -> {
|
||||
String firstLine = jd.getDescription().toText().lines()
|
||||
.map(String::strip).filter(s -> !s.isEmpty()).findFirst().orElse("");
|
||||
if (!firstLine.isEmpty()) {
|
||||
moduleProps.put("description", firstLine);
|
||||
}
|
||||
});
|
||||
// Collected before the module props below, because item 130's class-level @Path may be
|
||||
// written as a constant reference (@Path(PurPaths.PARTNER)) and must resolve the same way
|
||||
// a @Column name does.
|
||||
Map<String, String> constants = collectConstants(type);
|
||||
// Item 29: generic annotation capture for search_annotation, independent of any
|
||||
// annotation's specific interpretation above (@Entity/@Query/repository base types/...).
|
||||
@Nullable String typeAnnotations = annotationNames(type);
|
||||
if (typeAnnotations != null) {
|
||||
moduleProps.put("annotations", typeAnnotations);
|
||||
}
|
||||
// Item 130: the class-level @Path, so "which endpoint path reaches this handler" is
|
||||
// answerable from the graph. Annotations are otherwise stored by name only, and a JAX-RS
|
||||
// path lives half on the class and half on the method — composing it needed the source.
|
||||
@Nullable String typePath = restPath(type, className, constants);
|
||||
if (typePath != null) {
|
||||
moduleProps.put("restPath", typePath);
|
||||
}
|
||||
AstNode typeNode = node(NodeType.MODULE, fqn, sourceFile,
|
||||
type.getBegin().map(p -> p.line).orElse(1),
|
||||
type.getEnd().map(p -> p.line).orElse(1),
|
||||
null, null, moduleProps);
|
||||
nodes.add(typeNode);
|
||||
|
||||
// Dedupe placeholder MODULE nodes for referenced classes (extends/implements targets
|
||||
// and cross-class CALLS targets) by name, so repeated references don't create
|
||||
// duplicate nodes that collide on the (type, name, sourceFile, project) merge key.
|
||||
Map<String, AstNode> referencedModules = new HashMap<>();
|
||||
|
||||
for (ClassOrInterfaceType extended : facts.extended()) {
|
||||
AstNode superType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(extended, className)));
|
||||
Map<String, String> extendsProps = extendsTypeArgs(extended);
|
||||
edges.add(extendsProps.isEmpty()
|
||||
? edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine())
|
||||
: edge(EdgeType.EXTENDS, typeNode.id(), superType.id(), typeNode.startLine(), null, extendsProps));
|
||||
}
|
||||
for (ClassOrInterfaceType implemented : facts.implemented()) {
|
||||
AstNode interfaceType = referencedModule(referencedModules, nodes, types.resolve(supertypeName(implemented, className)));
|
||||
edges.add(edge(EdgeType.IMPLEMENTS, typeNode.id(), interfaceType.id(), typeNode.startLine()));
|
||||
}
|
||||
|
||||
// Pass 1: collect resolved constant values (needed before resolving the entity table name).
|
||||
|
||||
// JPA entity: resolve the table name and link the class to its DB_TABLE. A Panache
|
||||
// active-record entity (extends PanacheEntity[Base]) is an entity even without @Entity.
|
||||
boolean isEntity = annotation(type, "Entity").isPresent()
|
||||
|| (type instanceof ClassOrInterfaceDeclaration cls && extendsPanacheEntity(cls));
|
||||
boolean isMappedSuperclass = annotation(type, "MappedSuperclass").isPresent();
|
||||
if (isEntity && !isMappedSuperclass) {
|
||||
String tableName = resolveTableName(type, className, constants);
|
||||
AstNode tableNode = node(NodeType.DB_TABLE, tableName, "", 1, 1, null, null);
|
||||
nodes.add(tableNode);
|
||||
edges.add(edge(EdgeType.MAPS_TO, typeNode.id(), tableNode.id(), typeNode.startLine()));
|
||||
}
|
||||
|
||||
// Pass 2: emit field/constant nodes (with JPA column metadata where present).
|
||||
// fieldsByName maps a field/constant name to its node, for READS/WRITES resolution;
|
||||
// fieldTypes maps a field name to its declared type, for cross-class call resolution.
|
||||
// Item 116a: fieldTypes starts from the enclosing classes' fields, so a nested class
|
||||
// resolves a call on a field it inherits lexically (the @Nested/JUnit 5 layout). Only
|
||||
// fieldTypes is widened — fieldsByName stays this type's own fields, since a field node
|
||||
// belongs to the class that declares it.
|
||||
Map<String, AstNode> fieldsByName = new HashMap<>();
|
||||
Map<String, String> fieldTypes = enclosingFieldTypes(type);
|
||||
for (FieldDeclaration field : type.getFields()) {
|
||||
boolean isConstant = field.isStatic() && field.isFinal();
|
||||
@Nullable AnnotationExpr column = annotation(field, "Column").orElse(null);
|
||||
@Nullable String fieldAnnotations = annotationNames(field);
|
||||
for (VariableDeclarator variable : field.getVariables()) {
|
||||
String dataType = variable.getTypeAsString();
|
||||
fieldTypes.put(variable.getNameAsString(), dataType);
|
||||
int startLine = field.getBegin().map(p -> p.line).orElse(1);
|
||||
int endLine = field.getEnd().map(p -> p.line).orElse(1);
|
||||
|
||||
AstNode fieldNode;
|
||||
if (column != null) {
|
||||
Map<String, String> props = columnProperties(field, column, variable, className, constants);
|
||||
if (fieldAnnotations != null) {
|
||||
props.put("annotations", fieldAnnotations);
|
||||
}
|
||||
fieldNode = node(NodeType.FIELD, variable.getNameAsString(), sourceFile,
|
||||
startLine, endLine, dataType, null, props);
|
||||
} else {
|
||||
@Nullable String value = isConstant ? constants.get(variable.getNameAsString()) : null;
|
||||
fieldNode = node(isConstant ? NodeType.CONSTANT : NodeType.FIELD,
|
||||
variable.getNameAsString(), sourceFile, startLine, endLine, dataType, value,
|
||||
fieldAnnotations != null ? Map.of("annotations", fieldAnnotations) : null);
|
||||
}
|
||||
nodes.add(fieldNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), fieldNode.id(), fieldNode.startLine()));
|
||||
fieldsByName.put(variable.getNameAsString(), fieldNode);
|
||||
}
|
||||
}
|
||||
|
||||
// Item 119: the state a record, an enum or an annotation type carries is not declared as a
|
||||
// FieldDeclaration, so the loop above sees none of it. Without these three the types would
|
||||
// be modules with an empty body — "analysed, nothing found" for a record DTO's components,
|
||||
// which is the failure mode item 114 is about.
|
||||
for (RecordDeclaration record : type instanceof RecordDeclaration r ? List.of(r) : List.<RecordDeclaration>of()) {
|
||||
for (Parameter component : record.getParameters()) {
|
||||
int line = component.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
AstNode componentNode = node(NodeType.FIELD, component.getNameAsString(), sourceFile,
|
||||
line, line, component.getTypeAsString(), null,
|
||||
Map.of("recordComponent", "true"));
|
||||
nodes.add(componentNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), componentNode.id(), line));
|
||||
fieldsByName.put(component.getNameAsString(), componentNode);
|
||||
fieldTypes.put(component.getNameAsString(), component.getTypeAsString());
|
||||
}
|
||||
}
|
||||
for (EnumDeclaration enumeration : type instanceof EnumDeclaration e ? List.of(e) : List.<EnumDeclaration>of()) {
|
||||
for (EnumConstantDeclaration constant : enumeration.getEntries()) {
|
||||
int line = constant.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
// The constant's own class body (a per-constant override) is not modelled as a
|
||||
// separate module; its methods would need an identity no source-level name gives them.
|
||||
AstNode constantNode = node(NodeType.CONSTANT, constant.getNameAsString(), sourceFile,
|
||||
line, line, className, null, Map.of("enumConstant", "true"));
|
||||
nodes.add(constantNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), constantNode.id(), line));
|
||||
fieldsByName.put(constant.getNameAsString(), constantNode);
|
||||
}
|
||||
}
|
||||
for (AnnotationMemberDeclaration member : type.getMembers().stream()
|
||||
.filter(AnnotationMemberDeclaration.class::isInstance)
|
||||
.map(AnnotationMemberDeclaration.class::cast).toList()) {
|
||||
int line = member.getBegin().map(p -> p.line).orElse(typeNode.startLine());
|
||||
Map<String, String> memberProps = new HashMap<>();
|
||||
memberProps.put("annotationMember", "true");
|
||||
member.getDefaultValue().ifPresent(v -> memberProps.put("defaultValue", v.toString()));
|
||||
// A FIELD, not a FUNCTION: the question asked of an annotation type is which attributes
|
||||
// it carries, not which methods it declares.
|
||||
AstNode memberNode = node(NodeType.FIELD, member.getNameAsString(), sourceFile,
|
||||
line, line, member.getType().asString(), null, memberProps);
|
||||
nodes.add(memberNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), memberNode.id(), line));
|
||||
fieldsByName.put(member.getNameAsString(), memberNode);
|
||||
}
|
||||
|
||||
// Functions: methods + constructors (constructors are FUNCTION nodes named after the class).
|
||||
Map<String, AstNode> methods = new HashMap<>();
|
||||
List<CallableDeclaration<?>> callables = new ArrayList<>();
|
||||
List<AstNode> callableNodes = new ArrayList<>();
|
||||
for (MethodDeclaration method : type.getMethods()) {
|
||||
@Nullable String methodAnnotations = annotationNames(method);
|
||||
Map<String, String> methodProps = new HashMap<>();
|
||||
if (methodAnnotations != null) {
|
||||
methodProps.put("annotations", methodAnnotations);
|
||||
}
|
||||
// Item 130: the method half of a JAX-RS endpoint — its own @Path (often absent, which
|
||||
// means "the class path itself") and the HTTP verb annotation.
|
||||
@Nullable String methodPath = restPath(method, className, constants);
|
||||
if (methodPath != null) {
|
||||
methodProps.put("restPath", methodPath);
|
||||
}
|
||||
@Nullable String httpMethod = httpMethod(method);
|
||||
if (httpMethod != null) {
|
||||
methodProps.put("httpMethod", httpMethod);
|
||||
}
|
||||
// Item 33: modifier-derived kind, so an agent can ask "what must a subclass
|
||||
// implement/not override" without reading the base class source by hand.
|
||||
methodProps.put("kind", method.isAbstract() ? "abstract" : method.isFinal() ? "final" : "overridable");
|
||||
AstNode methodNode = node(NodeType.FUNCTION, method.getNameAsString(), sourceFile,
|
||||
method.getBegin().map(p -> p.line).orElse(1),
|
||||
method.getEnd().map(p -> p.line).orElse(1),
|
||||
method.getTypeAsString(), null, methodProps);
|
||||
methods.put(method.getNameAsString(), methodNode);
|
||||
nodes.add(methodNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), methodNode.id(), methodNode.startLine()));
|
||||
callables.add(method);
|
||||
callableNodes.add(methodNode);
|
||||
// J1b: a @Query-annotated repository method is abstract (no call sites to scan), so
|
||||
// its JPQL/native-SQL DB access is captured here, at the declaration.
|
||||
addQueryDbAccessCandidate(method, methodNode, repositoryEntity, className, constants, sourceFile, nodes, edges);
|
||||
}
|
||||
for (ConstructorDeclaration ctor : type.getConstructors()) {
|
||||
@Nullable String ctorAnnotations = annotationNames(ctor);
|
||||
AstNode ctorNode = node(NodeType.FUNCTION, className, sourceFile,
|
||||
ctor.getBegin().map(p -> p.line).orElse(1),
|
||||
ctor.getEnd().map(p -> p.line).orElse(1),
|
||||
className, null,
|
||||
ctorAnnotations != null ? Map.of("annotations", ctorAnnotations) : null);
|
||||
nodes.add(ctorNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, typeNode.id(), ctorNode.id(), ctorNode.startLine()));
|
||||
callables.add(ctor);
|
||||
callableNodes.add(ctorNode);
|
||||
}
|
||||
|
||||
// Parameters, field READS/WRITES, and intra-class CALLS per callable.
|
||||
for (int ci = 0; ci < callables.size(); ci++) {
|
||||
CallableDeclaration<?> callable = callables.get(ci);
|
||||
AstNode callableNode = callableNodes.get(ci);
|
||||
|
||||
Set<String> shadowed = new HashSet<>();
|
||||
// declaredTypes: field types plus this callable's parameter and local types, for
|
||||
// resolving a typed receiver (svc.method()) to its class.
|
||||
Map<String, String> declaredTypes = new HashMap<>(fieldTypes);
|
||||
int paramPosition = 0;
|
||||
for (Parameter parameter : callable.getParameters()) {
|
||||
shadowed.add(parameter.getNameAsString());
|
||||
declaredTypes.put(parameter.getNameAsString(), parameter.getTypeAsString());
|
||||
AstNode paramNode = node(NodeType.VARIABLE, parameter.getNameAsString(), sourceFile,
|
||||
parameter.getBegin().map(p -> p.line).orElse(callableNode.startLine()),
|
||||
parameter.getEnd().map(p -> p.line).orElse(callableNode.startLine()),
|
||||
parameter.getTypeAsString(), null, Map.of("paramPosition", String.valueOf(paramPosition++)));
|
||||
nodes.add(paramNode);
|
||||
edges.add(edge(EdgeType.CONTAINS, callableNode.id(), paramNode.id(), paramNode.startLine()));
|
||||
}
|
||||
callable.findAll(VariableDeclarationExpr.class).forEach(vde ->
|
||||
vde.getVariables().forEach(v -> {
|
||||
shadowed.add(v.getNameAsString());
|
||||
declaredTypes.put(v.getNameAsString(), v.getTypeAsString());
|
||||
}));
|
||||
// Every *nested* parameter too — lambda and catch parameters. They are bound names
|
||||
// like any local, but they are not VariableDeclarationExpr and not the callable's own
|
||||
// parameter list, so both loops above miss them. Names only: an implicit lambda
|
||||
// parameter has no usable declared type (see isProbableFieldReceiver).
|
||||
callable.findAll(Parameter.class).forEach(p -> shadowed.add(p.getNameAsString()));
|
||||
|
||||
addFieldAccessEdges(callable, callableNode, fieldsByName, shadowed, edges);
|
||||
|
||||
for (MethodCallExpr call : callable.findAll(MethodCallExpr.class)) {
|
||||
int callLine = call.getBegin().map(p -> p.line).orElse(callableNode.startLine());
|
||||
@Nullable Expression scope = call.getScope().orElse(null);
|
||||
if (scope == null || scope.isThisExpr()) {
|
||||
// Intra-class call.
|
||||
AstNode target = methods.get(call.getNameAsString());
|
||||
if (target != null) {
|
||||
edges.add(edge(EdgeType.CALLS, callableNode.id(), target.id(), callLine, null,
|
||||
callProps(call, CallKind.METHOD_CALL)));
|
||||
}
|
||||
} else {
|
||||
// Cross-class call: a class-level (MODULE->MODULE) CALLS edge, so callers/
|
||||
// callees/call-tree (which traverse from the module) span files uniformly
|
||||
// with Natural's module-level CALLNAT.
|
||||
@Nullable String simpleTarget = resolveReceiverClass(scope, declaredTypes);
|
||||
@Nullable String targetClass = simpleTarget == null ? null : types.resolve(simpleTarget);
|
||||
// The DB-access candidate keeps the receiver as written (see extendsTypeArgs).
|
||||
if (targetClass == null && isProbableFieldReceiver(scope, declaredTypes, shadowed)) {
|
||||
// Item 116b: the receiver names a field this class does not declare — almost
|
||||
// always one inherited from a supertype, which lives in another file the
|
||||
// parser never sees. Record the receiver's *name* against a placeholder so
|
||||
// the enrichment stage, which does know the EXTENDS chain, can resolve it.
|
||||
// Dropping it here is what left a service with 185 tests reporting no callers.
|
||||
AstNode marker = referencedModule(referencedModules, nodes,
|
||||
UNRESOLVED_FIELD_RECEIVER_PREFIX + scope.asNameExpr().getNameAsString());
|
||||
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
|
||||
props.put("calleeMethod", call.getNameAsString());
|
||||
props.put("callerFn", callableNode.name());
|
||||
props.put(UNRESOLVED_FIELD_RECEIVER, scope.asNameExpr().getNameAsString());
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), marker.id(), callLine, null, props));
|
||||
}
|
||||
if (targetClass != null) {
|
||||
AstNode mod = referencedModule(referencedModules, nodes, targetClass);
|
||||
// J5: carry the invoked method + enclosing function so enrichment can map
|
||||
// this cross-class call's arguments to the callee method's parameters.
|
||||
Map<String, String> props = callProps(call, CallKind.METHOD_CALL);
|
||||
props.put("calleeMethod", call.getNameAsString());
|
||||
props.put("callerFn", callableNode.name());
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null, props));
|
||||
}
|
||||
// J1: a persistence-shaped call on this receiver becomes a DB_ACCESS candidate.
|
||||
boolean staticReceiver = scope.isNameExpr()
|
||||
&& !declaredTypes.containsKey(scope.asNameExpr().getNameAsString());
|
||||
addDbAccessCandidate(call, simpleTarget, staticReceiver, declaredTypes, sourceFile,
|
||||
callableNode, nodes, edges);
|
||||
}
|
||||
}
|
||||
// Constructor calls (new Foo(...)) -> class-level CALLS to the instantiated class.
|
||||
for (ObjectCreationExpr creation : callable.findAll(ObjectCreationExpr.class)) {
|
||||
int callLine = creation.getBegin().map(p -> p.line).orElse(callableNode.startLine());
|
||||
AstNode mod = referencedModule(referencedModules, nodes, types.resolve(creation.getType().getNameAsString()));
|
||||
edges.add(edge(EdgeType.CALLS, typeNode.id(), mod.id(), callLine, null,
|
||||
Map.of("callKind", CallKind.CONSTRUCTOR.name())));
|
||||
}
|
||||
}
|
||||
|
||||
// J2: CDI injection + class-literal wiring edges.
|
||||
addWiringEdges(type, typeNode, referencedModules, types, nodes, edges);
|
||||
// Item 128: the reference index — imports, declared type positions, annotation usages.
|
||||
addReferenceEdges(unit, type, typeNode, referencedModules, types, nodes, edges);
|
||||
}
|
||||
|
||||
// Item 141: comments are file-level, so this runs once after the type loop — inside it, a file
|
||||
// with nested types would emit every comment once per type.
|
||||
JavaComments.collect(unit, "java", sourceFile, nodes, edges, UnaryOperator.identity());
|
||||
|
||||
return new ParseResult(nodes, edges);
|
||||
/**
|
||||
* The per-class state of constant resolution. {@code templates} collects the names whose value is
|
||||
* a {@code .formatted(...)} template rather than the final string (item 139).
|
||||
*/
|
||||
private record ConstantScope(String className, Map<String, Expression> initializers, Set<String> stringTyped,
|
||||
Map<String, String> resolved, Set<String> templates) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -496,4 +496,35 @@ class JavaParserTest {
|
||||
assertEquals("ServiceEndpoint: partner.update.partnercs.Update\n\nUpmsObject: PartnerCs",
|
||||
commentAt(result, 2).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringConstantsFoldTextBlocksConcatenationsAndTemplates() {
|
||||
// Item 139: CypherQueries-style constants used to have no value, so search/value never found them.
|
||||
String content = """
|
||||
package q;
|
||||
public class Queries {
|
||||
private static final String ROW = "n.name AS name";
|
||||
public static final String CORE = \"""
|
||||
MATCH (n:AstNode {project: $project})
|
||||
\""";
|
||||
public static final String LIST = CORE + "RETURN " + ROW;
|
||||
public static final String KEYED = \"""
|
||||
MERGE (a)-[r:%s]->(b)
|
||||
\""".formatted("CALLS");
|
||||
public static final String ALIAS = KEYED;
|
||||
public static final String OPAQUE = CORE + helper();
|
||||
public static final int COUNT = 1 + 2;
|
||||
static String helper() { return "x"; }
|
||||
}
|
||||
""";
|
||||
LanguageParser.ParseResult result = parser.parse("Queries.java", content);
|
||||
assertEquals("MATCH (n:AstNode {project: $project})\n", findNode(result, NodeType.CONSTANT, "CORE").value());
|
||||
assertEquals("MATCH (n:AstNode {project: $project})\nRETURN n.name AS name", findNode(result, NodeType.CONSTANT, "LIST").value());
|
||||
AstNode keyed = findNode(result, NodeType.CONSTANT, "KEYED");
|
||||
assertEquals("MERGE (a)-[r:%s]->(b)\n", keyed.value());
|
||||
assertEquals("template", java.util.Objects.requireNonNull(keyed.properties()).get("valueKind"));
|
||||
assertEquals("template", java.util.Objects.requireNonNull(findNode(result, NodeType.CONSTANT, "ALIAS").properties()).get("valueKind"));
|
||||
assertNull(findNode(result, NodeType.CONSTANT, "OPAQUE").value(), "a method call is not folded - nothing is guessed");
|
||||
assertNull(findNode(result, NodeType.CONSTANT, "COUNT").value(), "only String constants are concatenated");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.agenticcode.parsernatural;
|
||||
import com.agenticcode.parsercore.ast.model.AstEdge;
|
||||
import com.agenticcode.parsercore.ast.model.AstNode;
|
||||
import com.agenticcode.parsercore.ast.model.CopycodeProperties;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
@@ -144,11 +145,25 @@ public final class CopycodePreprocessor {
|
||||
*/
|
||||
public static ParseResult remap(String hostSourceFile, ParseResult raw, List<LineOrigin> origins) {
|
||||
List<AstNode> nodes = new ArrayList<>(raw.nodes().size());
|
||||
int hostLines = 0;
|
||||
for (LineOrigin o : origins) {
|
||||
if (o.sourceFile().equals(hostSourceFile)) {
|
||||
hostLines = Math.max(hostLines, o.line());
|
||||
}
|
||||
}
|
||||
for (AstNode n : raw.nodes()) {
|
||||
if (!n.sourceFile().equals(hostSourceFile)) {
|
||||
nodes.add(n); // placeholder / already-foreign node
|
||||
continue;
|
||||
}
|
||||
if (n.type() == NodeType.MODULE) {
|
||||
// Item 201: the module node is the host file itself, whatever its first or last
|
||||
// expanded line came from. A program that starts with an INCLUDE used to get the
|
||||
// copycode as sourceFile here and became a second MODULE next to the Tier-1 shell.
|
||||
nodes.add(new AstNode(n.id(), n.type(), n.name(), hostSourceFile, n.language(),
|
||||
1, Math.max(hostLines, 1), n.dataType(), n.value(), n.properties()));
|
||||
continue;
|
||||
}
|
||||
LineOrigin start = originAt(origins, n.startLine(), hostSourceFile);
|
||||
LineOrigin end = originAt(origins, n.endLine(), hostSourceFile);
|
||||
nodes.add(new AstNode(n.id(), n.type(), n.name(), start.sourceFile(), n.language(),
|
||||
@@ -271,7 +286,6 @@ public final class CopycodePreprocessor {
|
||||
}
|
||||
|
||||
private static String stripInlineComment(String s) {
|
||||
int comment = s.indexOf("/*");
|
||||
return comment >= 0 ? s.substring(0, comment) : s;
|
||||
return NaturalLines.stripInlineComment(s); // item 152: the shared, quote-aware rule
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public final class NaturalLineCounter implements LineCounter {
|
||||
if (trimmed.charAt(0) == '*') {
|
||||
return false;
|
||||
}
|
||||
int inline = line.indexOf("/*");
|
||||
int inline = NaturalLines.commentStart(line); // item 152: quote-aware
|
||||
String code = inline >= 0 ? line.substring(0, inline) : line;
|
||||
return !code.isBlank();
|
||||
}
|
||||
|
||||
@@ -47,14 +47,38 @@ final class NaturalLines {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code s} truncated at the first {@code /*}, which starts a Natural comment running to
|
||||
* end of line.
|
||||
* @return {@code s} truncated at the {@code /*} that starts a Natural comment running to end of
|
||||
* line, or {@code s} unchanged if it has none (see {@link #commentStart}).
|
||||
*/
|
||||
static String stripInlineComment(String s) {
|
||||
int comment = s.indexOf("/*");
|
||||
int comment = commentStart(s);
|
||||
return comment >= 0 ? s.substring(0, comment) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 152: the index of the {@code /*} that starts a trailing comment, or -1 — quote-aware, so
|
||||
* {@code MOVE 'A/*B' TO #X} and {@code MOVE "A/*B" TO #X} hold no comment. Natural delimits a
|
||||
* literal with {@code '} or {@code "}; the other character is plain text inside it, and a doubled
|
||||
* delimiter toggles twice, which is the same as not toggling. A literal still open at end of line
|
||||
* means "no comment" — the conservative reading, which truncates nothing.
|
||||
*/
|
||||
static int commentStart(String line) {
|
||||
char quote = 0;
|
||||
for (int i = 0; i < line.length() - 1; i++) {
|
||||
char c = line.charAt(i);
|
||||
if (quote != 0) {
|
||||
if (c == quote) {
|
||||
quote = 0;
|
||||
}
|
||||
} else if (c == '\'' || c == '"') {
|
||||
quote = c;
|
||||
} else if (c == '/' && line.charAt(i + 1) == '*') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances {@code matcher} to the first match whose start lies outside a quoted string literal,
|
||||
* leaving the matcher positioned on it so {@code group()}/{@code end()} apply to that match.
|
||||
|
||||
@@ -1121,7 +1121,7 @@ public final class NaturalParser implements LanguageParser {
|
||||
blockStart = -1;
|
||||
blockKind = null;
|
||||
}
|
||||
int inline = inlineCommentStart(line);
|
||||
int inline = NaturalLines.commentStart(line);
|
||||
if (inline >= 0) {
|
||||
emitComment(sourceFile, i + 1, i + 1, line.substring(inline + 2),
|
||||
CommentProperties.KIND_NATURAL_INLINE, targets, nodes, edges);
|
||||
@@ -1177,26 +1177,6 @@ public final class NaturalParser implements LanguageParser {
|
||||
return stripped.substring(i).stripLeading();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the index of the {@code /*} that starts a trailing comment on a code line, or -1.
|
||||
*
|
||||
* <p>Quote-aware, unlike {@link NaturalLines#stripInlineComment}: {@code MOVE 'A/*B' TO #X} holds
|
||||
* no comment. That the strip path is not quote-aware is a separate, pre-existing precision bug —
|
||||
* there it merely truncates a line, here it would manufacture a comment node out of a literal.
|
||||
*/
|
||||
private static int inlineCommentStart(String line) {
|
||||
boolean inQuote = false;
|
||||
for (int i = 0; i < line.length() - 1; i++) {
|
||||
char c = line.charAt(i);
|
||||
if (c == '\'') {
|
||||
inQuote = !inQuote; // a doubled '' toggles twice, which is the same as not toggling
|
||||
} else if (!inQuote && c == '/' && line.charAt(i + 1) == '*') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private ParseResult parseModule(String sourceFile, String[] lines) {
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.agenticcode.parsernatural;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Item 152: the trailing-comment rule every Natural scan path shares is quote-aware.
|
||||
*/
|
||||
class NaturalLinesCommentTest {
|
||||
|
||||
@Test
|
||||
void aSlashStarInsideALiteralIsNotAComment() {
|
||||
assertEquals("MOVE 'A/*B' TO #X", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X"));
|
||||
assertEquals("MOVE \"A/*B\" TO #X", NaturalLines.stripInlineComment("MOVE \"A/*B\" TO #X"));
|
||||
assertEquals("MOVE 'A/*B' TO #X ", NaturalLines.stripInlineComment("MOVE 'A/*B' TO #X /* real one"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOtherDelimiterIsPlainTextInsideALiteral() {
|
||||
assertEquals("MOVE 'it\"s /* x' TO #X", NaturalLines.stripInlineComment("MOVE 'it\"s /* x' TO #X"));
|
||||
assertEquals("MOVE 'it''s' TO #X ", NaturalLines.stripInlineComment("MOVE 'it''s' TO #X /* doubled quote"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainCommentsAndOpenLiteralsBehave() {
|
||||
assertEquals("CALLNAT 'MOD' ", NaturalLines.stripInlineComment("CALLNAT 'MOD' /* comment"));
|
||||
assertEquals("", NaturalLines.stripInlineComment("/* whole line"));
|
||||
assertEquals(-1, NaturalLines.commentStart("MOVE 'unterminated /* TO #X"), "an open literal truncates nothing");
|
||||
assertEquals(-1, NaturalLines.commentStart("no comment here"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theParserKeepsTheStatementBehindAQuotedSlashStar() throws Exception {
|
||||
String src = """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
01 #X (A10)
|
||||
END-DEFINE
|
||||
MOVE 'A/*B' TO #X
|
||||
CALLNAT 'TARGETQ' #X
|
||||
END
|
||||
""";
|
||||
var result = new NaturalParser().parse("QUOTED.nat", src);
|
||||
var writes = result.edges().stream()
|
||||
.filter(e -> e.type() == com.agenticcode.parsercore.ast.model.EdgeType.WRITES && e.lineNo() == 5).count();
|
||||
assertEquals(1, writes, "MOVE 'A/*B' TO #X still writes #X");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package com.agenticcode.parsernatural;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.AstEdge;
|
||||
import com.agenticcode.parsercore.ast.model.AstNode;
|
||||
import com.agenticcode.parsercore.ast.model.CommentProperties;
|
||||
import com.agenticcode.parsercore.ast.model.EdgeType;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -2047,4 +2043,32 @@ class NaturalParserTest {
|
||||
|
||||
assertEquals(List.of(), comments(parser.parse("WPARTX0S.nat", content)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHostThatStartsWithAnIncludeKeepsItsOwnFileOnTheModuleNode() {
|
||||
// Item 201: ZDTSTBP6.nat in upms is `INCLUDE ZDTSTBC6` / END. Expanded line 1 originates in the
|
||||
// copycode, and the module node used to be remapped onto it -> a second MODULE named after the
|
||||
// program with the .cpy as sourceFile, next to the Tier-1 shell.
|
||||
String host = """
|
||||
INCLUDE ONLYCPY
|
||||
*
|
||||
END
|
||||
""";
|
||||
String copycode = """
|
||||
* nothing but a comment
|
||||
""";
|
||||
CopycodeResolver resolver = name -> "ONLYCPY".equalsIgnoreCase(name)
|
||||
? new CopycodeResolver.Copycode("copycode/ONLYCPY.cpy", copycode)
|
||||
: null;
|
||||
LanguageParser.ParseResult result = parser.parse("program/ZDTSTBP6.nat", host, resolver);
|
||||
List<AstNode> modules = result.nodes().stream()
|
||||
.filter(n -> n.type() == NodeType.MODULE && n.name().equals("ZDTSTBP6")).toList();
|
||||
assertEquals(1, modules.size(), "exactly one MODULE node for the host");
|
||||
AstNode module = modules.get(0);
|
||||
assertEquals("program/ZDTSTBP6.nat", module.sourceFile());
|
||||
assertEquals(1, module.startLine());
|
||||
// the parser counts the trailing newline as a line, like it does for any host: 4 here
|
||||
assertEquals(host.split("\n", -1).length, module.endLine(), "the host's own line count, not an expanded one");
|
||||
assertNull(java.util.Objects.requireNonNull(module.properties()).get("viaCopycode"));
|
||||
}
|
||||
}
|
||||
|
||||
6
ac-parser-typescript/ac-parser-typescript.iml
Normal file
6
ac-parser-typescript/ac-parser-typescript.iml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module version="4">
|
||||
<component name="CheckStyle-IDEA-Module" serialisationVersion="2">
|
||||
<option name="activeLocationsIds" />
|
||||
</component>
|
||||
</module>
|
||||
59
ac-parser-typescript/pom.xml
Normal file
59
ac-parser-typescript/pom.xml
Normal file
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>agenticcode</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>ac-parser-typescript</name>
|
||||
<description>TypeScript/React source parser: Tier-1 regex coarse scan in Java, Tier-2 facts from the Node sidecar
|
||||
(roadmap item 192)
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jspecify</groupId>
|
||||
<artifactId>jspecify</artifactId>
|
||||
</dependency>
|
||||
<!-- the sidecar's JSON facts document; version managed by the Quarkus BOM -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-mvn-plugins</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
1
ac-parser-typescript/sidecar/.gitignore
vendored
Normal file
1
ac-parser-typescript/sidecar/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1536
ac-parser-typescript/sidecar/extract.mjs
Normal file
1536
ac-parser-typescript/sidecar/extract.mjs
Normal file
File diff suppressed because it is too large
Load Diff
31
ac-parser-typescript/sidecar/package-lock.json
generated
Normal file
31
ac-parser-typescript/sidecar/package-lock.json
generated
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ac-ts-sidecar",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ac-ts-sidecar",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
ac-parser-typescript/sidecar/package.json
Normal file
14
ac-parser-typescript/sidecar/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "ac-ts-sidecar",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "AgenticCode Tier-2 facts extractor for TypeScript/React on the TypeScript compiler API (roadmap item 192)",
|
||||
"type": "module",
|
||||
"main": "extract.mjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.CoarseScanner;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Tier-1 coarse scanner for TypeScript/TSX and plain CSS (roadmap item 192): a pure-Java, per-file
|
||||
* regex outline that needs no Node. It yields the {@code MODULE} shell (with {@code sourceHash},
|
||||
* {@code loc}/{@code sloc}, {@code simpleName}, {@code workspace}, {@code generated}), one
|
||||
* {@code FUNCTION} per top-level function / arrow function / class (property {@code kind}:
|
||||
* {@code function}, {@code component}, {@code hook}, {@code thunk}, {@code styled}, {@code class};
|
||||
* {@code exported}), one {@code DATA_STRUCTURE} per top-level {@code interface}/{@code type}/{@code
|
||||
* enum}, and a {@code REFERENCES} edge per import to a placeholder {@code MODULE} ({@code sourceFile
|
||||
* == ""}) named by {@link TypeScriptModuleNames#resolveImport}. npm packages are listed on the module
|
||||
* as {@code externalImports} and get no placeholder. Calls, store access, DTO bindings and styles are
|
||||
* Tier-2 and come from the sidecar facts.
|
||||
*
|
||||
* <p>Declaration end lines come from brace matching that skips strings, templates and comments —
|
||||
* good enough for an outline; the sidecar's positions replace them.
|
||||
*/
|
||||
public final class TypeScriptCoarseScanner implements CoarseScanner {
|
||||
|
||||
/**
|
||||
* The frontend's runtime dependencies (root package.json, 2026-09-22); the server passes the real list.
|
||||
*/
|
||||
public static final Set<String> DEFAULT_EXTERNAL_PACKAGES = Set.of(
|
||||
"react", "react-dom", "react-redux", "lodash", "moment", "zod", "notistack", "jwt-decode",
|
||||
"react-oidc-context", "ts-object-path", "react-dropzone", "ag-grid-react", "ag-grid-enterprise",
|
||||
"ag-grid-community", "vite", "jest", "typescript");
|
||||
|
||||
private static final Pattern IMPORT = Pattern.compile(
|
||||
"^\\s*(?:import|export)\\s+(?:type\\s+)?(?:([^'\"`;]*?)\\s+from\\s+)?['\"]([^'\"]+)['\"]", Pattern.MULTILINE);
|
||||
private static final Pattern DYNAMIC_IMPORT = Pattern.compile("\\bimport\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)");
|
||||
private static final Pattern FUNCTION_DECL = Pattern.compile(
|
||||
"^(export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*([A-Za-z_$][\\w$]*)?", Pattern.MULTILINE);
|
||||
private static final Pattern CLASS_DECL = Pattern.compile(
|
||||
"^(export\\s+)?(?:default\\s+)?(?:abstract\\s+)?class\\s+([A-Za-z_$][\\w$]*)", Pattern.MULTILINE);
|
||||
private static final Pattern CONST_DECL = Pattern.compile(
|
||||
"^(export\\s+)?(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]*?)?=\\s*([^\\n]*)", Pattern.MULTILINE);
|
||||
private static final Pattern TYPE_DECL = Pattern.compile(
|
||||
"^(export\\s+)?(?:declare\\s+)?(interface|type|enum)\\s+([A-Za-z_$][\\w$]*)", Pattern.MULTILINE);
|
||||
private static final Pattern ARROW_HEAD = Pattern.compile(
|
||||
"^(?:async\\s+)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[^=]*?)?=>|^(?:async\\s+)?<[^>]*>\\s*\\(");
|
||||
private static final Pattern THUNK_HEAD = Pattern.compile("^create(?:App)?AsyncThunk\\b");
|
||||
private static final Pattern STYLED_HEAD = Pattern.compile("^styled\\s*[(<]");
|
||||
private static final Pattern WRAPPED_HEAD = Pattern.compile(
|
||||
"^(?:React\\.)?(?:memo|forwardRef|lazy)\\s*[(<]");
|
||||
|
||||
private final Set<String> workspaces;
|
||||
private final Set<String> externalPackages;
|
||||
private final TypeScriptLineCounter tsCounter = new TypeScriptLineCounter();
|
||||
private final CssLineCounter cssCounter = new CssLineCounter();
|
||||
|
||||
public TypeScriptCoarseScanner() {
|
||||
this(Set.of(), DEFAULT_EXTERNAL_PACKAGES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param workspaces npm workspace names (first path segment of their files), so a bare
|
||||
* {@code pur-ui-common} import resolves to that workspace's barrel
|
||||
* @param externalPackages npm dependency names; imports of them are recorded, not placeholdered
|
||||
*/
|
||||
public TypeScriptCoarseScanner(Set<String> workspaces, Set<String> externalPackages) {
|
||||
this.workspaces = Set.copyOf(workspaces);
|
||||
this.externalPackages = Set.copyOf(externalPackages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-1 resolution of one import (see {@link TypeScriptModuleNames#resolveImport}).
|
||||
*/
|
||||
static @Nullable String resolveImport(TypeScriptProject project, String importer, String specifier) {
|
||||
return TypeScriptModuleNames.resolveImport(importer, specifier, project.workspaces(), project.externalPackages());
|
||||
}
|
||||
|
||||
private static final Pattern CSS_DECLARATION = Pattern.compile("([a-zA-Z-]+)\\s*:\\s*([^;{}]+)");
|
||||
private static final Pattern CSS_LITERAL = Pattern.compile("#[0-9a-fA-F]{3,8}|-?\\d+(?:\\.\\d+)?(?:px|rem|em|%|vh|vw|pt)|rgba?\\([^)]*\\)|calc\\([^)]*\\)");
|
||||
|
||||
/**
|
||||
* Item 196: one {@code STYLE} per CSS rule ({@code body@7}, {@code @font-face@2}, a nested
|
||||
* {@code @media} block is one rule with its inner selectors' declarations) — {@code selector},
|
||||
* {@code properties} (declaration keys) and {@code literals} (hard-coded colours/lengths).
|
||||
*/
|
||||
static void cssRules(AstNode module, String content, List<AstNode> nodes, List<AstEdge> edges) {
|
||||
StringBuilder sb = new StringBuilder(content.length());
|
||||
// blank out comments, keeping line structure
|
||||
Matcher c = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(content);
|
||||
int last = 0;
|
||||
while (c.find()) {
|
||||
sb.append(content, last, c.start()).append(c.group().replaceAll("[^\\n]", " "));
|
||||
last = c.end();
|
||||
}
|
||||
sb.append(content.substring(last));
|
||||
String text = sb.toString();
|
||||
// braces inside strings are masked here so the declaration regex sees `content: " "`
|
||||
StringBuilder masked = new StringBuilder(text);
|
||||
int depth = 0;
|
||||
int ruleStart = 0;
|
||||
int selectorFrom = 0;
|
||||
char quote = 0;
|
||||
Set<String> names = new HashSet<>();
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char ch = text.charAt(i);
|
||||
// Item 199: braces inside a string (`content: "{"`) do not open or close a block.
|
||||
if (quote != 0) {
|
||||
if (ch == '\\') {
|
||||
i++;
|
||||
} else if (ch == quote) {
|
||||
quote = 0;
|
||||
} else if (ch == '{' || ch == '}') {
|
||||
masked.setCharAt(i, ' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '"' || ch == '\'') {
|
||||
quote = ch;
|
||||
} else if (ch == ';' && depth == 0) {
|
||||
// a block-less at-statement (`@import url(...);`, `@charset`, `@layer a, b;`) ends here,
|
||||
// not at the next rule's closing brace
|
||||
selectorFrom = i + 1;
|
||||
} else if (ch == '{') {
|
||||
if (depth == 0) {
|
||||
ruleStart = i;
|
||||
}
|
||||
depth++;
|
||||
} else if (ch == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
String selector = text.substring(selectorFrom, ruleStart).replaceAll("\\s+", " ").trim();
|
||||
// a nested rule head inside an at-rule body (`a:hover {`) is a selector, not a declaration
|
||||
String body = masked.substring(ruleStart + 1, i).replaceAll("[^{};]*\\{", "{");
|
||||
int line = lineOf(text, ruleStart);
|
||||
if (!selector.isEmpty()) {
|
||||
Set<String> props = new java.util.LinkedHashSet<>();
|
||||
Set<String> literals = new java.util.LinkedHashSet<>();
|
||||
Matcher d = CSS_DECLARATION.matcher(body);
|
||||
while (d.find()) {
|
||||
props.add(d.group(1));
|
||||
Matcher l = CSS_LITERAL.matcher(d.group(2));
|
||||
while (l.find()) {
|
||||
literals.add(l.group());
|
||||
}
|
||||
}
|
||||
Map<String, String> p = new LinkedHashMap<>();
|
||||
p.put("styleKind", "css");
|
||||
p.put("selector", selector);
|
||||
p.put("properties", String.join(",", props));
|
||||
if (!literals.isEmpty()) {
|
||||
p.put("literals", String.join(",", literals));
|
||||
}
|
||||
String name = selector + "@" + line;
|
||||
if (!names.add(name)) {
|
||||
// minified CSS: same selector twice on one line keeps both rules apart by column
|
||||
name = name + ":" + (ruleStart - text.lastIndexOf('\n', ruleStart));
|
||||
names.add(name);
|
||||
}
|
||||
AstNode rule = new AstNode(UUID.randomUUID(), NodeType.STYLE, name, module.sourceFile(),
|
||||
CssLineCounter.LANGUAGE, line, lineOf(text, i), selector, null, p);
|
||||
nodes.add(rule);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), rule.id(), line, null));
|
||||
}
|
||||
selectorFrom = i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static AstEdge importEdge(AstNode module, AstNode placeholder, String specifier, String names, int line) {
|
||||
Map<String, String> edgeProps = new LinkedHashMap<>();
|
||||
edgeProps.put("specifier", specifier);
|
||||
return new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, module.id(), placeholder.id(), line,
|
||||
names.isEmpty() ? null : names, edgeProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the generator that wrote this file, or {@code null} for hand-written code. The legacy
|
||||
* Java-side generator marks its output with {@code // @ts-nocheck} under a {@code /generated/}
|
||||
* directory; hey-api writes an "auto-generated by" banner.
|
||||
*/
|
||||
static @Nullable String generatedBy(String sourceFile, String content) {
|
||||
String head = content.length() > 600 ? content.substring(0, 600) : content;
|
||||
if (head.contains("@hey-api/openapi-ts")) {
|
||||
return "hey-api";
|
||||
}
|
||||
if (head.contains("EndpointGenerator") || head.contains("typescript-generator")) {
|
||||
return "typescript-generator";
|
||||
}
|
||||
if (sourceFile.contains("/generated/") && (head.contains("@ts-nocheck") || head.contains("auto-generated"))) {
|
||||
return "unknown";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void addImport(Shell shell, TypeScriptProject project, String specifier, String names, int line,
|
||||
Placeholders placeholders, Set<String> external, List<AstEdge> edges) {
|
||||
String target = resolveImport(project, shell.module().name(), specifier);
|
||||
if (target == null) {
|
||||
external.add(specifier);
|
||||
return;
|
||||
}
|
||||
edges.add(importEdge(shell.module(), placeholders.module(target), specifier, names, line));
|
||||
}
|
||||
|
||||
private static void addFunction(AstNode module, String sourceFile, String content, List<AstNode> nodes,
|
||||
List<AstEdge> edges, String name, int offset, boolean exported, String kind) {
|
||||
int line = lineOf(content, offset);
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("kind", kind);
|
||||
props.put("exported", Boolean.toString(exported));
|
||||
AstNode fn = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, name, sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
line, endLineOf(content, offset, line), null, null, props);
|
||||
nodes.add(fn);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), fn.id(), line, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the {@code kind} for a {@code const NAME = <init>} declaration, or {@code null} when the
|
||||
* initializer is data rather than a callable (a plain object, a literal, a {@code createSlice}).
|
||||
*/
|
||||
private static @Nullable String initializerKind(String name, boolean tsx, String init) {
|
||||
if (THUNK_HEAD.matcher(init).find()) {
|
||||
return "thunk";
|
||||
}
|
||||
if (STYLED_HEAD.matcher(init).find()) {
|
||||
return "styled";
|
||||
}
|
||||
if (WRAPPED_HEAD.matcher(init).find()) {
|
||||
return kindOf(name, tsx, "component");
|
||||
}
|
||||
if (ARROW_HEAD.matcher(init).find()) {
|
||||
return kindOf(name, tsx, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String kindOf(String name, boolean tsx, @Nullable String fallback) {
|
||||
if (name.length() > 3 && name.startsWith("use") && Character.isUpperCase(name.charAt(3))) {
|
||||
return "hook";
|
||||
}
|
||||
if (tsx && Character.isUpperCase(name.charAt(0))) {
|
||||
return "component";
|
||||
}
|
||||
return fallback != null ? fallback : "function";
|
||||
}
|
||||
|
||||
private static int lineOf(String content, int offset) {
|
||||
int line = 1;
|
||||
for (int i = 0; i < offset && i < content.length(); i++) {
|
||||
if (content.charAt(i) == '\n') {
|
||||
line++;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end line of the declaration starting at {@code offset}: the first line end at which every
|
||||
* bracket ({@code {}}, {@code ()}, {@code []}) opened since {@code offset} is closed again, skipping
|
||||
* strings, templates and comments. So {@code function f() {...}} ends at its closing brace, a
|
||||
* {@code createAsyncThunk(...)} at its closing parenthesis, and a one-line arrow or type alias at
|
||||
* its own line. A multi-line union with no brackets ends at its first line — an outline, not a
|
||||
* parse; the sidecar's positions replace these.
|
||||
*/
|
||||
static int endLineOf(String content, int offset, int startLine) {
|
||||
int n = content.length();
|
||||
int depth = 0;
|
||||
boolean inBlock = false;
|
||||
char quote = 0;
|
||||
int line = startLine;
|
||||
for (int i = offset; i < n; i++) {
|
||||
char c = content.charAt(i);
|
||||
if (c == '\n') {
|
||||
if (depth <= 0) {
|
||||
return line;
|
||||
}
|
||||
line++;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) {
|
||||
if (c == '*' && i + 1 < n && content.charAt(i + 1) == '/') {
|
||||
inBlock = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote != 0) {
|
||||
if (c == '\\') {
|
||||
i++;
|
||||
} else if (c == quote) {
|
||||
quote = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '/' && i + 1 < n && content.charAt(i + 1) == '/') {
|
||||
while (i + 1 < n && content.charAt(i + 1) != '\n') {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '/' && i + 1 < n && content.charAt(i + 1) == '*') {
|
||||
inBlock = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == '"' || c == '\'' || c == '`') {
|
||||
quote = c;
|
||||
} else if (c == '{' || c == '(' || c == '[') {
|
||||
depth++;
|
||||
} else if (c == '}' || c == ')' || c == ']') {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String language() {
|
||||
return TypeScriptLineCounter.LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace/package sets this scanner was built with, as a facts-less project context.
|
||||
*/
|
||||
TypeScriptProject defaults() {
|
||||
return new TypeScriptProject(workspaces, externalPackages, TypeScriptFacts.NONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParseResult scan(String sourceFile, String content) {
|
||||
return scan(sourceFile, content, new TypeScriptProject(workspaces, externalPackages, TypeScriptFacts.NONE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The outline with the project's own workspace/package knowledge (see {@link TypeScriptProject}).
|
||||
*/
|
||||
public ParseResult scan(String sourceFile, String content, TypeScriptProject project) {
|
||||
Shell shell = shell(sourceFile, content);
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
nodes.add(shell.module());
|
||||
if (shell.css()) {
|
||||
cssRules(shell.module(), content, nodes, edges);
|
||||
return new ParseResult(nodes, edges);
|
||||
}
|
||||
Placeholders placeholders = new Placeholders(nodes);
|
||||
Set<String> external = new TreeSet<>();
|
||||
Matcher m = IMPORT.matcher(content);
|
||||
while (m.find()) {
|
||||
String names = m.group(1) == null ? "" : m.group(1).replaceAll("\\s+", " ").trim();
|
||||
addImport(shell, project, m.group(2), names, lineOf(content, m.start()), placeholders, external, edges);
|
||||
}
|
||||
Matcher d = DYNAMIC_IMPORT.matcher(content);
|
||||
while (d.find()) {
|
||||
addImport(shell, project, d.group(1), "import()", lineOf(content, d.start()), placeholders, external, edges);
|
||||
}
|
||||
shell.setExternalImports(external);
|
||||
scanDeclarations(shell.module(), sourceFile, content, nodes, edges);
|
||||
return new ParseResult(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code MODULE} node of {@code sourceFile} with identity, metrics, hash and generator flags —
|
||||
* shared by the Tier-1 outline and the Tier-2 facts merge in {@link TypeScriptParser}.
|
||||
*/
|
||||
Shell shell(String sourceFile, String content) {
|
||||
boolean css = sourceFile.toLowerCase(java.util.Locale.ROOT).endsWith(".css");
|
||||
String language = css ? CssLineCounter.LANGUAGE : TypeScriptLineCounter.LANGUAGE;
|
||||
LocMetrics metrics = css ? cssCounter.count(content) : tsCounter.count(content);
|
||||
String moduleName = TypeScriptModuleNames.moduleName(sourceFile);
|
||||
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("simpleName", TypeScriptModuleNames.simpleName(moduleName));
|
||||
props.put("workspace", TypeScriptModuleNames.workspace(moduleName));
|
||||
props.put("moduleKind", css ? "css" : sourceFile.endsWith(".tsx") ? "tsx" : "ts");
|
||||
props.put(SourceHash.PROPERTY, SourceHash.of(content));
|
||||
props.put(LocMetrics.LOC, Integer.toString(metrics.loc()));
|
||||
props.put(LocMetrics.SLOC, Integer.toString(metrics.sloc()));
|
||||
if (!css) {
|
||||
String generated = generatedBy(sourceFile, content);
|
||||
props.put("generated", Boolean.toString(generated != null));
|
||||
if (generated != null) {
|
||||
props.put("generator", generated);
|
||||
}
|
||||
}
|
||||
AstNode module = new AstNode(UUID.randomUUID(), NodeType.MODULE, moduleName, sourceFile, language,
|
||||
1, Math.max(1, metrics.loc()), null, null, props);
|
||||
return new Shell(module, css, props);
|
||||
}
|
||||
|
||||
private void scanDeclarations(AstNode module, String sourceFile, String content, List<AstNode> nodes,
|
||||
List<AstEdge> edges) {
|
||||
boolean tsx = sourceFile.endsWith(".tsx");
|
||||
Matcher f = FUNCTION_DECL.matcher(content);
|
||||
while (f.find()) {
|
||||
String name = f.group(2) != null ? f.group(2) : TypeScriptModuleNames.simpleName(module.name());
|
||||
addFunction(module, sourceFile, content, nodes, edges, name, f.start(), f.group(1) != null,
|
||||
kindOf(name, tsx, null));
|
||||
}
|
||||
Matcher c = CLASS_DECL.matcher(content);
|
||||
while (c.find()) {
|
||||
addFunction(module, sourceFile, content, nodes, edges, c.group(2), c.start(), c.group(1) != null, "class");
|
||||
}
|
||||
Matcher v = CONST_DECL.matcher(content);
|
||||
while (v.find()) {
|
||||
String init = v.group(3).trim();
|
||||
String kind = initializerKind(v.group(2), tsx, init);
|
||||
if (kind == null) {
|
||||
continue;
|
||||
}
|
||||
addFunction(module, sourceFile, content, nodes, edges, v.group(2), v.start(), v.group(1) != null, kind);
|
||||
}
|
||||
Matcher t = TYPE_DECL.matcher(content);
|
||||
while (t.find()) {
|
||||
int line = lineOf(content, t.start());
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("exported", Boolean.toString(t.group(1) != null));
|
||||
AstNode ds = new AstNode(UUID.randomUUID(), NodeType.DATA_STRUCTURE, t.group(3), sourceFile,
|
||||
TypeScriptLineCounter.LANGUAGE, line, endLineOf(content, t.end(), line), t.group(2), null, props);
|
||||
nodes.add(ds);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), ds.id(), line, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A module shell; {@code props} is the live property map of {@code module} so flags can be added after creation.
|
||||
*/
|
||||
record Shell(AstNode module, boolean css, Map<String, String> props) {
|
||||
void setExternalImports(Set<String> external) {
|
||||
if (!external.isEmpty()) {
|
||||
props.put("externalImports", String.join(",", external));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder {@code MODULE}s ({@code sourceFile == ""}) deduped by name, appended to {@code nodes} on first use.
|
||||
*/
|
||||
static final class Placeholders {
|
||||
private final Map<String, AstNode> byName = new LinkedHashMap<>();
|
||||
private final List<AstNode> nodes;
|
||||
|
||||
Placeholders(List<AstNode> nodes) {
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
AstNode module(String name) {
|
||||
return byName.computeIfAbsent(name, n -> {
|
||||
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.MODULE, n, "", TypeScriptLineCounter.LANGUAGE, 1, 1, null, null);
|
||||
nodes.add(ph);
|
||||
return ph;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 194: a store placeholder — a {@code STORE_SLICE} ({@code <key>}) or a store {@code FIELD}
|
||||
* ({@code <key>.<field>}) another file declares. Carries {@code store=true} so the store
|
||||
* resolver matches it to the real node of the same type and name.
|
||||
*/
|
||||
/**
|
||||
* Item 195: a DTO-field placeholder — {@code <Dto>.<field>} declared as a {@code FIELD} of the
|
||||
* {@code DATA_STRUCTURE} {@code owner} in module {@code targetModule}; carries {@code binding=true}
|
||||
* so the binding resolver matches it exactly (module → structure → field).
|
||||
*/
|
||||
AstNode binding(String owner, String field, String targetModule) {
|
||||
return byName.computeIfAbsent("BINDING|" + owner + "." + field, n -> {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("binding", "true");
|
||||
props.put("owner", owner);
|
||||
props.put("field", field);
|
||||
props.put("targetModule", targetModule);
|
||||
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.FIELD, owner + "." + field, "", TypeScriptLineCounter.LANGUAGE,
|
||||
1, 1, null, null, props);
|
||||
nodes.add(ph);
|
||||
return ph;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: a theme-token placeholder {@code theme.<token>} ({@code theme=true}), resolved by exact name.
|
||||
*/
|
||||
AstNode theme(String token) {
|
||||
return byName.computeIfAbsent("THEME|" + token, n -> {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("theme", "true");
|
||||
props.put("token", token);
|
||||
AstNode ph = new AstNode(UUID.randomUUID(), NodeType.FIELD, "theme." + token, "", TypeScriptLineCounter.LANGUAGE,
|
||||
1, 1, null, null, props);
|
||||
nodes.add(ph);
|
||||
return ph;
|
||||
});
|
||||
}
|
||||
|
||||
AstNode store(NodeType type, String name) {
|
||||
return byName.computeIfAbsent(type + "|" + name, n -> {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("store", "true");
|
||||
AstNode ph = new AstNode(UUID.randomUUID(), type, name, "", TypeScriptLineCounter.LANGUAGE, 1, 1, null, null, props);
|
||||
nodes.add(ph);
|
||||
return ph;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tier-2 facts for one ingest, produced by the Node sidecar ({@code sidecar/extract.mjs}) on the
|
||||
* TypeScript compiler API and keyed by project-root-relative source file (roadmap item 192). Built
|
||||
* once per ingest like the Natural {@code CopycodeLibrary} and handed into every per-file
|
||||
* {@link TypeScriptParser#parse(String, String, TypeScriptFacts)} call. {@link #NONE} carries no
|
||||
* facts, which is what a Tier-1 coarse scan and the outline tests use. The JSON contract is
|
||||
* documented at the top of {@code extract.mjs} and read by {@link TypeScriptFactsReader}.
|
||||
*/
|
||||
public record TypeScriptFacts(Map<String, FileFacts> byFile, Map<String, FileFacts> byModule,
|
||||
Map<String, String> storeKeys) {
|
||||
|
||||
public static final TypeScriptFacts NONE = new TypeScriptFacts(Map.of(), Map.of(), Map.of());
|
||||
|
||||
public TypeScriptFacts {
|
||||
byFile = Map.copyOf(byFile);
|
||||
byModule = Map.copyOf(byModule);
|
||||
storeKeys = Map.copyOf(storeKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the facts with the derived module-name index ({@link TypeScriptModuleNames#moduleName})
|
||||
* and the item-194 store index: every {@code configureStore} of the project contributes
|
||||
* {@code <slice module>|<slice name> -> reducer key}, so a slice file parsed on its own still
|
||||
* learns the key it is mounted under ({@code generalAgreementSlice} is {@code state.gruppenprovision}).
|
||||
*/
|
||||
public static TypeScriptFacts of(Map<String, FileFacts> byFile) {
|
||||
Map<String, FileFacts> byModule = new HashMap<>();
|
||||
Map<String, String> storeKeys = new HashMap<>();
|
||||
byFile.forEach((file, facts) -> {
|
||||
byModule.put(TypeScriptModuleNames.moduleName(file), facts);
|
||||
StoreFact store = facts.store();
|
||||
if (store != null) {
|
||||
for (StoreKeyFact k : store.keys()) {
|
||||
if (k.sliceFile() != null && k.sliceName() != null) {
|
||||
storeKeys.put(k.sliceFile() + "|" + k.sliceName(), k.key());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return new TypeScriptFacts(byFile, byModule, storeKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* One facts document per workspace run; a file present in several keeps the last.
|
||||
*/
|
||||
public static TypeScriptFacts merge(List<TypeScriptFacts> parts) {
|
||||
Map<String, FileFacts> all = new HashMap<>();
|
||||
for (TypeScriptFacts part : parts) {
|
||||
all.putAll(part.byFile());
|
||||
}
|
||||
return of(all);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the reducer key a slice is mounted under in a store of the project, or its own name when no store mounts it
|
||||
*/
|
||||
public String storeKey(String sliceModule, String sliceName) {
|
||||
String key = storeKeys.get(sliceModule + "|" + sliceName);
|
||||
return key != null ? key : sliceName;
|
||||
}
|
||||
|
||||
public boolean has(String sourceFile) {
|
||||
return byFile.containsKey(sourceFile);
|
||||
}
|
||||
|
||||
public @Nullable FileFacts get(String sourceFile) {
|
||||
return byFile.get(sourceFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the facts of the file whose module name (path without extension) is {@code moduleName}, or {@code null}
|
||||
*/
|
||||
public @Nullable FileFacts byModuleName(String moduleName) {
|
||||
return byModule.get(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the sidecar knows about one file. {@code endpoints} is item 193; {@code slices},
|
||||
* {@code store} and {@code stateAccesses} are item 194.
|
||||
*/
|
||||
public record FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
|
||||
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
|
||||
List<StateAccessFact> stateAccesses, List<BindingFact> bindings,
|
||||
List<ThemeTokenFact> themeTokens, List<StyleFact> styles, List<TokenRefFact> tokenRefs) {
|
||||
|
||||
/**
|
||||
* The item-195 shape, without styling facts.
|
||||
*/
|
||||
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
|
||||
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
|
||||
List<StateAccessFact> stateAccesses, List<BindingFact> bindings) {
|
||||
this(imports, declarations, calls, endpoints, slices, store, stateAccesses, bindings, List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The item-192/193 shape, without store facts and bindings.
|
||||
*/
|
||||
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
|
||||
List<EndpointFact> endpoints) {
|
||||
this(imports, declarations, calls, endpoints, List.of(), null, List.of(), List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* The item-194 shape, without bindings.
|
||||
*/
|
||||
public FileFacts(List<ImportFact> imports, List<DeclarationFact> declarations, List<CallFact> calls,
|
||||
List<EndpointFact> endpoints, List<SliceFact> slices, @Nullable StoreFact store,
|
||||
List<StateAccessFact> stateAccesses) {
|
||||
this(imports, declarations, calls, endpoints, slices, store, stateAccesses, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A theme token (item 196): a leaf of the {@code createTheme({...})} literal ({@code kind=path},
|
||||
* {@code palette.primary.dark}) or an exported string constant of the theme file ({@code kind=constant},
|
||||
* {@code PRIMARY}); {@code value} folded through constants, {@code constant} the constant a path leaf names.
|
||||
*/
|
||||
public record ThemeTokenFact(String token, String kind, @Nullable String value, @Nullable String constant, int line,
|
||||
int variants) {
|
||||
public ThemeTokenFact(String token, String kind, @Nullable String value, @Nullable String constant, int line) {
|
||||
this(token, kind, value, constant, line, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One {@code sx={}} / {@code style={}} / {@code styled(X)(...)} block (item 196).
|
||||
*
|
||||
* @param element the JSX tag or the styled base ({@code Box}, {@code 'div'}), or {@code null}
|
||||
* @param properties the CSS keys, nested selectors flattened ({@code &:hover.color})
|
||||
* @param literals hard-coded colours/lengths ({@code #005CA9}, {@code 17px})
|
||||
* @param dynamic a value is an expression the sidecar could not classify (or the whole block is)
|
||||
* @param tokens the theme tokens the block reads, each with the key it feeds
|
||||
*/
|
||||
public record StyleFact(String fromDecl, String styleKind, @Nullable String element, int line, int col,
|
||||
List<String> properties,
|
||||
List<String> literals, boolean dynamic, boolean spread, List<StyleTokenFact> tokens) {
|
||||
}
|
||||
|
||||
public record StyleTokenFact(String token, @Nullable String property, int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A theme token read outside a style block (item 196); {@code context} = the JSX attribute, or {@code code}.
|
||||
*/
|
||||
public record TokenRefFact(String fromDecl, String token, String context, int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A use of a generated {@code Fields} path object (item 195): {@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}.
|
||||
*
|
||||
* @param kind {@code field} for a scalar leaf, {@code prefix} when a whole sub-object is handed on
|
||||
* @param rootDto the DTO the path starts from ({@code AgstammUseCase})
|
||||
* @param ownerDto the DTO declaring the leaf ({@code Broker}); {@code ownerFile} its module
|
||||
* @param field the leaf ({@code ebene})
|
||||
* @param path the dotted hops from the root ({@code broker.ebene}, {@code list[]} for an indexed hop)
|
||||
* @param partial the root is a prop or local, so only the tail of the path is known
|
||||
* @param component the JSX tag the expression is a prop of, or {@code null}
|
||||
* @param attribute the prop / object property / callee text the expression is passed as
|
||||
*/
|
||||
public record BindingFact(String fromDecl, String kind, String rootDto, String ownerDto, String ownerFile,
|
||||
String field,
|
||||
String path, boolean partial, @Nullable String component, @Nullable String attribute,
|
||||
int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code createSlice} (item 194).
|
||||
*
|
||||
* @param name the const holding the slice ({@code keytableSlice})
|
||||
* @param sliceName the RTK slice name — the action-type prefix ({@code schluesseltabelle})
|
||||
* @param stateType the checker's name for the state type, or {@code null}
|
||||
* @param fields the top-level keys of the state
|
||||
* @param reducers case reducers, extra-reducer cases and matchers
|
||||
*/
|
||||
public record SliceFact(String name, String sliceName, @Nullable String stateType, boolean exported, int startLine,
|
||||
int endLine, List<MemberFact> fields, List<ReducerFact> reducers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One reducer function of a slice (item 194).
|
||||
*
|
||||
* @param name the action type it handles ({@code schluesseltabelle/updateX}, {@code schluesseltabelle/suche/fulfilled})
|
||||
* or, for a matcher, {@code <slice>/matcher:<expression>}
|
||||
* @param kind {@code reducer} (in {@code reducers: {}}), {@code case}, {@code matcher} or {@code default}
|
||||
* @param trigger for a case/matcher, what fires it; {@code null} for a plain reducer
|
||||
* @param accesses the state paths it reads and writes, rooted at the slice state
|
||||
*/
|
||||
public record ReducerFact(String name, String kind, @Nullable TriggerFact trigger, int startLine, int endLine,
|
||||
List<AccessFact> accesses) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param expression the trigger as written ({@code loadX.fulfilled}, {@code isSlicePending(sliceName)})
|
||||
* @param actionType the action type when the trigger is a thunk lifecycle action or a slice action
|
||||
* @param file the module declaring the thunk/slice, {@code null} when unresolved
|
||||
* @param decl the thunk const or slice const in {@code file}
|
||||
*/
|
||||
public record TriggerFact(String expression, @Nullable String actionType, @Nullable String file,
|
||||
@Nullable String decl) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mode {@code read} or {@code write}; {@code path} is empty for the whole slice state
|
||||
*/
|
||||
public record AccessFact(String mode, List<String> path, int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code configureStore} (item 194): which slice each reducer key mounts.
|
||||
*/
|
||||
public record StoreFact(int line, List<StoreKeyFact> keys) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sliceFile the module declaring the mounted slice, {@code null} when the reducer could not be traced
|
||||
*/
|
||||
public record StoreKeyFact(String key, @Nullable String sliceFile, @Nullable String sliceName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A store read outside a reducer (item 194): a selector arrow, a wrapper-hook selector or a
|
||||
* {@code getState()} chain. {@code path} is rooted at the store, so {@code path[0]} is the reducer key.
|
||||
*
|
||||
* @param via {@code useAppSelector}, {@code useSelector}, the wrapper hook's name, or {@code getState}
|
||||
*/
|
||||
public record StateAccessFact(String fromDecl, List<String> path, int line, String via) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param specifier the string in the {@code import}/{@code export ... from}/{@code import()}
|
||||
* @param resolved the module name (root-relative path, no extension) the specifier resolved to
|
||||
* inside the project, or {@code null}
|
||||
* @param packageName the npm package it resolved to, or {@code null}; both {@code null} = unresolved
|
||||
* @param names the import clause text ({@code { a, b }}, {@code * as X}, {@code import()})
|
||||
*/
|
||||
public record ImportFact(String specifier, @Nullable String resolved, @Nullable String packageName,
|
||||
int line, String names) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param kind {@code function}, {@code component}, {@code hook}, {@code thunk}, {@code styled},
|
||||
* {@code class}, {@code slice}, {@code const}, {@code interface}, {@code type}, {@code enum}
|
||||
*/
|
||||
public record DeclarationFact(String name, String kind, boolean exported, int startLine, int endLine,
|
||||
List<MemberFact> members) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A property of an interface or type-literal alias (item 193): the DTO shape the frontend binds to.
|
||||
*/
|
||||
public record MemberFact(String name, String type, boolean optional, int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A generated web-service call (item 193). {@code name} is the graph {@code FUNCTION} name:
|
||||
* {@code <Class>.<member>} for the legacy generator, the exported const for hey-api.
|
||||
*
|
||||
* @param owner the legacy Endpoint class, or {@code null} for hey-api
|
||||
* @param member the legacy member property, or {@code null}
|
||||
* @param generator {@code typescript-generator} or {@code hey-api}
|
||||
* @param backend from the URL builder: {@code pur}, {@code pur-r-vstamm}, {@code dynamic}; {@code null} for hey-api
|
||||
* @param url the composed URL template as written, {@code {param}} for substitutions, may carry a query string
|
||||
*/
|
||||
public record EndpointFact(String name, @Nullable String owner, @Nullable String member, String httpMethod,
|
||||
String generator, @Nullable String backend, String url, @Nullable String requestType,
|
||||
@Nullable String responseType, @Nullable String paramsType, int line) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param fromDecl the top-level declaration containing the call site, {@code ""} at module level
|
||||
* @param expression the callee text as written ({@code agstammUiApi.saveBroker.post})
|
||||
* @param symbol the resolved symbol name, or {@code null} when the checker could not resolve it
|
||||
* @param file the module name whose declaration owns the callee, or {@code null}
|
||||
* @param decl the top-level declaration in {@code file} owning the callee (a function, or
|
||||
* the class/interface a called member belongs to), {@code null} for a local
|
||||
* @param packageName the npm package ({@code lib} for the language library) for external callees
|
||||
* @param receiver on a member call, the type name of the innermost object ({@code AgstammControllerEndpoint})
|
||||
* @param member on a member call, the property chain ({@code saveBroker.post})
|
||||
* @param actionType item 194: {@code <slice>/<reducer>} when the callee is a slice action creator
|
||||
* (then {@code file}/{@code decl} name the slice const), a thunk's type prefix
|
||||
* when it is a thunk, else {@code null}
|
||||
* @param kind {@code call}, {@code new}, {@code tagged} or {@code jsx}
|
||||
*/
|
||||
public record CallFact(String fromDecl, String expression, @Nullable String symbol, @Nullable String file,
|
||||
@Nullable String decl, @Nullable String packageName, @Nullable String receiver,
|
||||
@Nullable String member, @Nullable String actionType, int line, String kind) {
|
||||
|
||||
/**
|
||||
* The item-192 shape without an action type.
|
||||
*/
|
||||
public CallFact(String fromDecl, String expression, @Nullable String symbol, @Nullable String file,
|
||||
@Nullable String decl, @Nullable String packageName, @Nullable String receiver,
|
||||
@Nullable String member, int line, String kind) {
|
||||
this(fromDecl, expression, symbol, file, decl, packageName, receiver, member, null, line, kind);
|
||||
}
|
||||
|
||||
public boolean resolvedInProject() {
|
||||
return file != null && decl != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Reads the sidecar's JSON facts document (contract version 4, see {@code sidecar/extract.mjs}) into
|
||||
* {@link TypeScriptFacts}. Tolerant of unknown fields so later phases (store, bindings, styles) can
|
||||
* extend the document without breaking this reader; strict about the version so a mismatched
|
||||
* sidecar fails loudly instead of yielding an empty graph.
|
||||
*/
|
||||
public final class TypeScriptFactsReader {
|
||||
|
||||
public static final int CONTRACT_VERSION = 4;
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private TypeScriptFactsReader() {
|
||||
}
|
||||
|
||||
public static TypeScriptFacts read(InputStream json) throws IOException {
|
||||
return read(MAPPER.readTree(json));
|
||||
}
|
||||
|
||||
public static TypeScriptFacts read(String json) throws IOException {
|
||||
return read(MAPPER.readTree(json));
|
||||
}
|
||||
|
||||
private static TypeScriptFacts read(JsonNode rootNode) throws IOException {
|
||||
int version = rootNode.path("version").asInt(-1);
|
||||
if (version != CONTRACT_VERSION) {
|
||||
throw new IOException("sidecar facts contract version " + version + ", expected " + CONTRACT_VERSION);
|
||||
}
|
||||
Map<String, FileFacts> byFile = new HashMap<>();
|
||||
JsonNode files = rootNode.path("files");
|
||||
for (Iterator<Map.Entry<String, JsonNode>> it = files.fields(); it.hasNext(); ) {
|
||||
Map.Entry<String, JsonNode> e = it.next();
|
||||
byFile.put(e.getKey(), fileFacts(e.getValue()));
|
||||
}
|
||||
return TypeScriptFacts.of(byFile);
|
||||
}
|
||||
|
||||
private static FileFacts fileFacts(JsonNode f) {
|
||||
List<ImportFact> imports = new ArrayList<>();
|
||||
for (JsonNode i : f.path("imports")) {
|
||||
imports.add(new ImportFact(text(i, "specifier"), nullable(i, "resolved"), nullable(i, "package"),
|
||||
i.path("line").asInt(), text(i, "names")));
|
||||
}
|
||||
List<DeclarationFact> declarations = new ArrayList<>();
|
||||
for (JsonNode d : f.path("declarations")) {
|
||||
declarations.add(new DeclarationFact(text(d, "name"), text(d, "kind"), d.path("exported").asBoolean(),
|
||||
d.path("startLine").asInt(), d.path("endLine").asInt(), members(d.path("members"))));
|
||||
}
|
||||
List<CallFact> calls = new ArrayList<>();
|
||||
for (JsonNode c : f.path("calls")) {
|
||||
calls.add(new CallFact(text(c, "fromDecl"), text(c, "expression"), nullable(c, "symbol"),
|
||||
nullable(c, "file"), nullable(c, "decl"), nullable(c, "package"), nullable(c, "receiver"),
|
||||
nullable(c, "member"), nullable(c, "actionType"), c.path("line").asInt(), text(c, "kind")));
|
||||
}
|
||||
List<EndpointFact> endpoints = new ArrayList<>();
|
||||
for (JsonNode e : f.path("endpoints")) {
|
||||
endpoints.add(new EndpointFact(text(e, "name"), nullable(e, "owner"), nullable(e, "member"), text(e, "httpMethod"),
|
||||
text(e, "generator"), nullable(e, "backend"), text(e, "url"), nullable(e, "requestType"),
|
||||
nullable(e, "responseType"), nullable(e, "paramsType"), e.path("line").asInt()));
|
||||
}
|
||||
List<SliceFact> slices = new ArrayList<>();
|
||||
for (JsonNode sl : f.path("slices")) {
|
||||
List<ReducerFact> reducers = new ArrayList<>();
|
||||
for (JsonNode r : sl.path("reducers")) {
|
||||
List<AccessFact> accesses = new ArrayList<>();
|
||||
for (JsonNode a : r.path("accesses")) {
|
||||
accesses.add(new AccessFact(text(a, "mode"), strings(a.path("path")), a.path("line").asInt()));
|
||||
}
|
||||
JsonNode t = r.get("trigger");
|
||||
TriggerFact trigger = t == null || t.isNull() ? null
|
||||
: new TriggerFact(text(t, "expression"), nullable(t, "actionType"), nullable(t, "file"), nullable(t, "decl"));
|
||||
reducers.add(new ReducerFact(text(r, "name"), text(r, "kind"), trigger, r.path("startLine").asInt(),
|
||||
r.path("endLine").asInt(), List.copyOf(accesses)));
|
||||
}
|
||||
slices.add(new SliceFact(text(sl, "name"), text(sl, "sliceName"), nullable(sl, "stateType"),
|
||||
sl.path("exported").asBoolean(), sl.path("startLine").asInt(), sl.path("endLine").asInt(),
|
||||
members(sl.path("fields")), List.copyOf(reducers)));
|
||||
}
|
||||
JsonNode st = f.get("store");
|
||||
StoreFact store = null;
|
||||
if (st != null && !st.isNull()) {
|
||||
List<StoreKeyFact> keys = new ArrayList<>();
|
||||
for (JsonNode k : st.path("keys")) {
|
||||
keys.add(new StoreKeyFact(text(k, "key"), nullable(k, "sliceFile"), nullable(k, "sliceName")));
|
||||
}
|
||||
store = new StoreFact(st.path("line").asInt(), List.copyOf(keys));
|
||||
}
|
||||
List<StateAccessFact> stateAccesses = new ArrayList<>();
|
||||
for (JsonNode a : f.path("stateAccesses")) {
|
||||
stateAccesses.add(new StateAccessFact(text(a, "fromDecl"), strings(a.path("path")), a.path("line").asInt(), text(a, "via")));
|
||||
}
|
||||
List<BindingFact> bindings = new ArrayList<>();
|
||||
for (JsonNode b : f.path("bindings")) {
|
||||
bindings.add(new BindingFact(text(b, "fromDecl"), text(b, "kind"), text(b, "rootDto"), text(b, "ownerDto"),
|
||||
text(b, "ownerFile"), text(b, "field"), text(b, "path"), b.path("partial").asBoolean(),
|
||||
nullable(b, "component"), nullable(b, "attribute"), b.path("line").asInt()));
|
||||
}
|
||||
List<ThemeTokenFact> themeTokens = new ArrayList<>();
|
||||
for (JsonNode t : f.path("themeTokens")) {
|
||||
themeTokens.add(new ThemeTokenFact(text(t, "token"), text(t, "kind"), nullable(t, "value"), nullable(t, "constant"), t.path("line").asInt(),
|
||||
Math.max(1, t.path("variants").asInt(1))));
|
||||
}
|
||||
List<StyleFact> styles = new ArrayList<>();
|
||||
for (JsonNode sb : f.path("styles")) {
|
||||
List<StyleTokenFact> tokens = new ArrayList<>();
|
||||
for (JsonNode t : sb.path("tokens")) {
|
||||
tokens.add(new StyleTokenFact(text(t, "token"), nullable(t, "property"), t.path("line").asInt()));
|
||||
}
|
||||
styles.add(new StyleFact(text(sb, "fromDecl"), text(sb, "styleKind"), nullable(sb, "element"), sb.path("line").asInt(),
|
||||
sb.path("col").asInt(), strings(sb.path("properties")), strings(sb.path("literals")), sb.path("dynamic").asBoolean(),
|
||||
sb.path("spread").asBoolean(), List.copyOf(tokens)));
|
||||
}
|
||||
List<TokenRefFact> tokenRefs = new ArrayList<>();
|
||||
for (JsonNode r : f.path("tokenRefs")) {
|
||||
tokenRefs.add(new TokenRefFact(text(r, "fromDecl"), text(r, "token"), text(r, "context"), r.path("line").asInt()));
|
||||
}
|
||||
return new FileFacts(List.copyOf(imports), List.copyOf(declarations), List.copyOf(calls), List.copyOf(endpoints),
|
||||
List.copyOf(slices), store, List.copyOf(stateAccesses), List.copyOf(bindings),
|
||||
List.copyOf(themeTokens), List.copyOf(styles), List.copyOf(tokenRefs));
|
||||
}
|
||||
|
||||
private static List<MemberFact> members(JsonNode array) {
|
||||
List<MemberFact> members = new ArrayList<>();
|
||||
for (JsonNode m : array) {
|
||||
members.add(new MemberFact(text(m, "name"), text(m, "type"), m.path("optional").asBoolean(), m.path("line").asInt()));
|
||||
}
|
||||
return List.copyOf(members);
|
||||
}
|
||||
|
||||
private static List<String> strings(JsonNode array) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (JsonNode n : array) {
|
||||
out.add(n.asText());
|
||||
}
|
||||
return List.copyOf(out);
|
||||
}
|
||||
|
||||
private static String text(JsonNode n, String field) {
|
||||
return n.path(field).asText("");
|
||||
}
|
||||
|
||||
private static @Nullable String nullable(JsonNode n, String field) {
|
||||
JsonNode v = n.get(field);
|
||||
return v == null || v.isNull() ? null : v.asText();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Naming rules for TypeScript modules (roadmap item 192). A module's graph identity is its
|
||||
* project-root-relative path <em>without the script extension</em>, e.g.
|
||||
* {@code pur-r-vstamm/src/store/slices/agstammSlice}; a CSS module keeps its extension
|
||||
* ({@code pur-ui/src/index.css}); {@code simpleName} is the file stem ({@code agstammSlice},
|
||||
* {@code index.css}); the first path segment is the npm {@code workspace}. Identities are
|
||||
* path-like on purpose: the placeholder rewiring step matches module placeholders by exact
|
||||
* {@code name}, so an import resolved to the same path joins the real module with no new Cypher.
|
||||
*
|
||||
* <p>Import specifiers resolve as the frontend's tsconfig does ({@code baseUrl: "src"} per workspace,
|
||||
* no {@code paths}, a vite alias {@code pur-ui-common -> ../pur-ui-common/src}):
|
||||
* <ul>
|
||||
* <li>{@code ./x}, {@code ../x} — relative to the importing file's directory</li>
|
||||
* <li>{@code <workspace>} or {@code <workspace>/...} — the workspace barrel ({@code <ws>/src/index})
|
||||
* or that path</li>
|
||||
* <li>{@code #/x} — the importing workspace's {@code src/x} (package.json {@code imports})</li>
|
||||
* <li>{@code @scope/pkg}, a name in {@code externalPackages}, {@code node:*} — an npm package,
|
||||
* recorded on the module, never a placeholder</li>
|
||||
* <li>any other bare specifier — {@code baseUrl}-relative: {@code <ws>/src/<specifier>}</li>
|
||||
* </ul>
|
||||
* Content-only resolution cannot tell {@code x} from {@code x/index}; the sidecar (Tier-2) resolves
|
||||
* against the file system and corrects such placeholders.
|
||||
*/
|
||||
final class TypeScriptModuleNames {
|
||||
|
||||
// .css is deliberately NOT stripped: `index.css` next to `index.ts` must not share the identity
|
||||
// `…/src/index` (the whole-root duplicate check would skip both, and a placeholder would be ambiguous).
|
||||
private static final Set<String> SOURCE_EXTENSIONS = Set.of(".tsx", ".ts", ".jsx", ".js", ".mjs");
|
||||
|
||||
private TypeScriptModuleNames() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code sourceFile} with its source extension removed and separators normalised to {@code /}.
|
||||
*/
|
||||
static String moduleName(String sourceFile) {
|
||||
String s = sourceFile.replace('\\', '/');
|
||||
for (String ext : SOURCE_EXTENSIONS) {
|
||||
if (s.endsWith(ext)) {
|
||||
return s.substring(0, s.length() - ext.length());
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static String simpleName(String moduleName) {
|
||||
int slash = moduleName.lastIndexOf('/');
|
||||
return slash >= 0 ? moduleName.substring(slash + 1) : moduleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the first path segment, or {@code ""} for a file at the project root.
|
||||
*/
|
||||
static String workspace(String moduleName) {
|
||||
int slash = moduleName.indexOf('/');
|
||||
return slash >= 0 ? moduleName.substring(0, slash) : "";
|
||||
}
|
||||
|
||||
static boolean isExternal(String specifier, Set<String> externalPackages) {
|
||||
if (specifier.startsWith("@") || specifier.startsWith("node:")) {
|
||||
return true;
|
||||
}
|
||||
int slash = specifier.indexOf('/');
|
||||
String head = slash >= 0 ? specifier.substring(0, slash) : specifier;
|
||||
return externalPackages.contains(head) || externalPackages.contains(specifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves {@code specifier} imported from {@code importer} (a module name) to the module name it
|
||||
* denotes, or {@code null} for an npm package (see the class comment for the rules).
|
||||
*/
|
||||
static @Nullable String resolveImport(String importer, String specifier, Set<String> workspaces,
|
||||
Set<String> externalPackages) {
|
||||
String spec = stripExtension(specifier);
|
||||
if (spec.startsWith("./") || spec.startsWith("../")) {
|
||||
return normalise(directoryOf(importer) + "/" + spec);
|
||||
}
|
||||
if (spec.startsWith("#/")) {
|
||||
return workspace(importer) + "/src/" + spec.substring(2);
|
||||
}
|
||||
int slash = spec.indexOf('/');
|
||||
String head = slash >= 0 ? spec.substring(0, slash) : spec;
|
||||
if (workspaces.contains(head)) {
|
||||
return slash < 0 ? head + "/src/index" : spec;
|
||||
}
|
||||
if (isExternal(spec, externalPackages)) {
|
||||
return null;
|
||||
}
|
||||
return workspace(importer) + "/src/" + spec;
|
||||
}
|
||||
|
||||
private static String stripExtension(String specifier) {
|
||||
for (String ext : SOURCE_EXTENSIONS) {
|
||||
if (specifier.endsWith(ext)) {
|
||||
return specifier.substring(0, specifier.length() - ext.length());
|
||||
}
|
||||
}
|
||||
return specifier;
|
||||
}
|
||||
|
||||
private static String directoryOf(String moduleName) {
|
||||
int slash = moduleName.lastIndexOf('/');
|
||||
return slash >= 0 ? moduleName.substring(0, slash) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses {@code .} and {@code ..} segments; a {@code ..} above the root is dropped.
|
||||
*/
|
||||
private static String normalise(String path) {
|
||||
Deque<String> out = new ArrayDeque<>();
|
||||
for (String seg : path.split("/")) {
|
||||
if (seg.isEmpty() || seg.equals(".")) {
|
||||
continue;
|
||||
}
|
||||
if (seg.equals("..")) {
|
||||
out.pollLast();
|
||||
} else {
|
||||
out.addLast(seg);
|
||||
}
|
||||
}
|
||||
return String.join("/", out);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser;
|
||||
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner.Placeholders;
|
||||
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner.Shell;
|
||||
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* {@link LanguageParser} for TypeScript/React (roadmap item 192). Without facts for a file the
|
||||
* result is the Tier-1 outline of {@link TypeScriptCoarseScanner}. With {@link TypeScriptFacts} from
|
||||
* the sidecar, the file's declarations, imports and calls come from the type checker instead:
|
||||
* <ul>
|
||||
* <li>declarations: {@code FUNCTION} for {@code function}/{@code component}/{@code hook}/{@code thunk}/
|
||||
* {@code styled}/{@code class}, {@code DATA_STRUCTURE} for {@code interface}/{@code type}/{@code enum};
|
||||
* a {@code slice} or {@code const} is data and gets no node here (item 194 models slices)</li>
|
||||
* <li>imports: resolved against the file system by the sidecar; an npm package lands in
|
||||
* {@code externalImports}; an unresolved specifier keeps the Tier-1 guess</li>
|
||||
* <li>item 193: every generated web-service call (legacy {@code XEndpoint.member}, hey-api const) is
|
||||
* a {@code FUNCTION} of {@code kind=endpoint} carrying {@code restPath}/{@code restBase}/{@code
|
||||
* httpMethod}/{@code outbound=true} — the properties the Java parser writes for a handler, so
|
||||
* {@code rest-endpoints} lists them — plus {@code backend}, {@code restUrl}, {@code queryParams},
|
||||
* {@code requestType}, {@code responseType}, {@code generator}; an interface's properties are
|
||||
* {@code FIELD}s named {@code <Interface>.<member>} under its {@code DATA_STRUCTURE}; a member call on an Endpoint instance is
|
||||
* retargeted to the endpoint {@code FUNCTION} ({@code calleeMethod = X.member})</li>
|
||||
* <li>calls: a callee owned by a top-level declaration of <em>this</em> file is a
|
||||
* {@code FUNCTION -CALLS-> FUNCTION} edge; one owned by another module is a
|
||||
* {@code MODULE -CALLS-> MODULE(placeholder)} edge carrying {@code callKind}, {@code callerFn}
|
||||
* and {@code calleeMethod} exactly as the Java parser writes them, so the function-level
|
||||
* callers/callees queries (item 52) and the placeholder rewiring apply unchanged. A member call
|
||||
* also carries {@code receiver} and {@code member} for item 193. Calls into npm packages and
|
||||
* the language library are not edges.</li>
|
||||
* <li>item 194 (Redux store): a {@code createSlice} is a {@code STORE_SLICE} named by the reducer key
|
||||
* it is mounted under ({@code TypeScriptFacts#storeKey}), with one store {@code FIELD} per top-level
|
||||
* state key ({@code <key>.<field>}, {@code store=true}) under it. Every reducer (case reducers,
|
||||
* extra-reducer cases, matchers) is a {@code FUNCTION} of {@code kind=reducer} named by the action
|
||||
* type it handles ({@code schluesseltabelle/updateX}, {@code schluesseltabelle/suche/fulfilled}),
|
||||
* with {@code READS}/{@code WRITES} edges to the store fields it touches ({@code path} = the full
|
||||
* sub-path). A thunk whose lifecycle action a case handles {@code CALLS} that case. Selector
|
||||
* arrows, wrapper-hook selectors and {@code getState()} chains in any file are {@code READS} from
|
||||
* the reading function to a store placeholder ({@code via} = the hook), resolved by name at
|
||||
* finalize. A dispatched action creator carries {@code actionType}, and its {@code calleeMethod}
|
||||
* is the reducer function, so callees of a component list what it dispatches.</li>
|
||||
* <li>item 195 (DTO field bindings): a use of a generated {@code Fields} path object
|
||||
* ({@code <SmartInput field={AgstammUseCaseField.broker.ebene}>}) is a {@code READS} (and, on an
|
||||
* input component, {@code WRITES}) edge from the binding function to the DTO {@code FIELD} the
|
||||
* leaf names ({@code Broker.ebene}) — same-file directly, otherwise a {@code binding=true}
|
||||
* placeholder resolved at finalize through {@code targetModule → owner → field}. The edge carries
|
||||
* {@code path}, {@code rootDto}, {@code kind}, {@code partial}, {@code component}, {@code attribute},
|
||||
* {@code via=binding}.</li>
|
||||
* <li>item 196 (styling): the file with {@code createTheme} gets a {@code DATA_STRUCTURE theme} with a
|
||||
* {@code FIELD theme.<token>} per token ({@code theme=true}, {@code value}); every {@code sx}/{@code style}/
|
||||
* {@code styled} block is a {@code STYLE} under its component ({@code properties}, {@code literals},
|
||||
* {@code element}); a theme token read is a {@code REFERENCES} edge ({@code via=theme}, {@code property}
|
||||
* or {@code context}) to the token, a placeholder when the theme lives in another file. A CSS file
|
||||
* gets a {@code STYLE} per rule from the Tier-1 scanner.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class TypeScriptParser implements LanguageParser {
|
||||
|
||||
private static final Set<String> FUNCTION_KINDS = Set.of("function", "component", "hook", "thunk", "styled", "class");
|
||||
private static final Set<String> DATA_STRUCTURE_KINDS = Set.of("interface", "type", "enum");
|
||||
/**
|
||||
* Item 195: components that write the bound field (everything else reads it).
|
||||
*/
|
||||
private static final java.util.regex.Pattern WRITING_COMPONENTS = java.util.regex.Pattern.compile(".*(Input|Dropzone|Editor)$");
|
||||
|
||||
private final TypeScriptCoarseScanner scanner;
|
||||
|
||||
public TypeScriptParser() {
|
||||
this(new TypeScriptCoarseScanner());
|
||||
}
|
||||
|
||||
public TypeScriptParser(Set<String> workspaces, Set<String> externalPackages) {
|
||||
this(new TypeScriptCoarseScanner(workspaces, externalPackages));
|
||||
}
|
||||
|
||||
private TypeScriptParser(TypeScriptCoarseScanner scanner) {
|
||||
this.scanner = scanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 194: the slice, its store fields, its reducer functions and their field accesses.
|
||||
*/
|
||||
private static void sliceNodes(TypeScriptProject project, AstNode module, SliceFact sl, Map<String, AstNode> declared,
|
||||
List<AstNode> nodes, List<AstEdge> edges) {
|
||||
String sourceFile = module.sourceFile();
|
||||
String key = project.facts().storeKey(module.name(), sl.sliceName());
|
||||
Map<String, String> sliceProps = new LinkedHashMap<>();
|
||||
sliceProps.put("store", "true");
|
||||
sliceProps.put("sliceName", sl.sliceName());
|
||||
sliceProps.put("storeKey", key);
|
||||
sliceProps.put("exported", Boolean.toString(sl.exported()));
|
||||
if (sl.stateType() != null) {
|
||||
sliceProps.put("stateType", sl.stateType());
|
||||
}
|
||||
AstNode slice = new AstNode(UUID.randomUUID(), NodeType.STORE_SLICE, key, sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
sl.startLine(), Math.max(sl.startLine(), sl.endLine()), sl.stateType(), null, sliceProps);
|
||||
nodes.add(slice);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), slice.id(), sl.startLine(), null));
|
||||
declared.put(sl.name(), slice);
|
||||
Map<String, AstNode> fields = new HashMap<>();
|
||||
for (MemberFact m : sl.fields()) {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("store", "true");
|
||||
props.put("slice", key);
|
||||
props.put("field", m.name());
|
||||
props.put("optional", Boolean.toString(m.optional()));
|
||||
AstNode field = new AstNode(UUID.randomUUID(), NodeType.FIELD, key + "." + m.name(), sourceFile,
|
||||
TypeScriptLineCounter.LANGUAGE, m.line(), m.line(), m.type(), null, props);
|
||||
nodes.add(field);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, slice.id(), field.id(), m.line(), null));
|
||||
fields.put(m.name(), field);
|
||||
}
|
||||
for (ReducerFact r : sl.reducers()) {
|
||||
if (declared.containsKey(r.name())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("kind", "reducer");
|
||||
props.put("reducerKind", r.kind());
|
||||
props.put("slice", key);
|
||||
props.put("exported", "false");
|
||||
TriggerFact trigger = r.trigger();
|
||||
if (trigger != null) {
|
||||
props.put("trigger", trigger.expression());
|
||||
if (trigger.actionType() != null) {
|
||||
props.put("actionType", trigger.actionType());
|
||||
}
|
||||
} else {
|
||||
props.put("actionType", r.name());
|
||||
}
|
||||
AstNode fn = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, r.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
r.startLine(), Math.max(r.startLine(), r.endLine()), null, null, props);
|
||||
nodes.add(fn);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), fn.id(), r.startLine(), null));
|
||||
declared.put(r.name(), fn);
|
||||
for (AccessFact a : r.accesses()) {
|
||||
AstNode target = a.path().isEmpty() ? slice : fields.get(a.path().get(0));
|
||||
if (target == null) {
|
||||
continue; // a key the state type does not declare (dynamic state) — nothing to attach to
|
||||
}
|
||||
EdgeType type = a.mode().equals("write") ? EdgeType.WRITES : EdgeType.READS;
|
||||
edges.add(accessEdge(type, fn, target, a.path(), a.line(), "reducer"));
|
||||
}
|
||||
// the thunk whose lifecycle action this case handles, when it lives in this file
|
||||
if (trigger != null && trigger.decl() != null && module.name().equals(trigger.file())) {
|
||||
AstNode thunk = declared.get(trigger.decl());
|
||||
if (thunk != null && thunk.type() == NodeType.FUNCTION && thunk != fn) {
|
||||
Map<String, String> callProps = new LinkedHashMap<>();
|
||||
callProps.put("callKind", CallKind.METHOD_CALL.name());
|
||||
callProps.put("callSyntax", "extraReducer");
|
||||
if (trigger.actionType() != null) {
|
||||
callProps.put("actionType", trigger.actionType());
|
||||
}
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, thunk.id(), fn.id(), r.startLine(),
|
||||
trigger.expression(), callProps));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AstEdge bindingEdge(EdgeType type, AstNode from, AstNode target, BindingFact b) {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("path", b.path());
|
||||
props.put("rootDto", b.rootDto());
|
||||
props.put("kind", b.kind());
|
||||
props.put("partial", Boolean.toString(b.partial()));
|
||||
if (b.component() != null) {
|
||||
props.put("component", b.component());
|
||||
}
|
||||
if (b.attribute() != null) {
|
||||
props.put("attribute", b.attribute());
|
||||
}
|
||||
props.put("via", "binding");
|
||||
return new AstEdge(UUID.randomUUID(), type, from.id(), target.id(), b.line(), null, props);
|
||||
}
|
||||
|
||||
private static AstEdge accessEdge(EdgeType type, AstNode from, AstNode target, List<String> path, int line, String via) {
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("path", String.join(".", path));
|
||||
props.put("via", via);
|
||||
return new AstEdge(UUID.randomUUID(), type, from.id(), target.id(), line, null, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether {@code calleeFile} declares a reducer FUNCTION named {@code actionType} (a slice action, not a thunk)
|
||||
*/
|
||||
private static boolean isReducerOf(TypeScriptProject project, String calleeFile, String actionType) {
|
||||
FileFacts target = project.facts().byModuleName(calleeFile);
|
||||
if (target == null) {
|
||||
return false;
|
||||
}
|
||||
for (SliceFact sl : target.slices()) {
|
||||
for (ReducerFact r : sl.reducers()) {
|
||||
if (r.name().equals(actionType)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: a member call on a legacy Endpoint instance ({@code agstammUiApi.saveBroker.post})
|
||||
* resolves, through the checker, to the {@code post} signature of the generic {@code PostMethod}
|
||||
* interface — true, but useless as a call target. When the receiver type owns an endpoint whose
|
||||
* member is the head of the property chain, the call is retargeted to that endpoint
|
||||
* {@code FUNCTION} ({@code AgstammControllerEndpoint.saveBroker}), so function-level callers and
|
||||
* the counterpart link land on the actual web-service call.
|
||||
*/
|
||||
private static String endpointTarget(TypeScriptProject project, String calleeFile, @Nullable String receiver,
|
||||
@Nullable String member, String fallback) {
|
||||
if (receiver == null || member == null) {
|
||||
return fallback;
|
||||
}
|
||||
FileFacts target = project.facts().byModuleName(calleeFile);
|
||||
if (target == null) {
|
||||
return fallback;
|
||||
}
|
||||
int dot = member.indexOf('.');
|
||||
String head = dot >= 0 ? member.substring(0, dot) : member;
|
||||
for (EndpointFact e : target.endpoints()) {
|
||||
if (receiver.equals(e.owner()) && head.equals(e.member())) {
|
||||
return e.name();
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static Map<String, String> endpointProps(EndpointFact e) {
|
||||
TypeScriptRestPaths paths = TypeScriptRestPaths.of(e.url());
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("kind", "endpoint");
|
||||
props.put("exported", "true");
|
||||
props.put("outbound", "true");
|
||||
props.put("httpMethod", e.httpMethod());
|
||||
props.put("restPath", paths.restPath());
|
||||
props.put("restBase", paths.restBase());
|
||||
props.put("restUrl", e.url());
|
||||
if (!paths.queryParams().isEmpty()) {
|
||||
props.put("queryParams", String.join(",", paths.queryParams()));
|
||||
}
|
||||
props.put("generator", e.generator());
|
||||
if (e.backend() != null) {
|
||||
props.put("backend", e.backend());
|
||||
}
|
||||
if (e.owner() != null) {
|
||||
props.put("owner", e.owner());
|
||||
}
|
||||
if (e.member() != null) {
|
||||
props.put("member", e.member());
|
||||
}
|
||||
if (e.requestType() != null) {
|
||||
props.put("requestType", TypeScriptRestPaths.unqualified(e.requestType()));
|
||||
}
|
||||
if (e.responseType() != null) {
|
||||
props.put("responseType", TypeScriptRestPaths.unqualified(e.responseType()));
|
||||
}
|
||||
if (e.paramsType() != null) {
|
||||
props.put("paramsType", TypeScriptRestPaths.unqualified(e.paramsType()));
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String language() {
|
||||
return TypeScriptLineCounter.LANGUAGE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParseResult parse(String sourceFile, String content) {
|
||||
return parse(sourceFile, content, TypeScriptFacts.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Facts with the scanner's default workspace/package sets (tests, ad-hoc use).
|
||||
*/
|
||||
public ParseResult parse(String sourceFile, String content, TypeScriptFacts facts) {
|
||||
return parse(sourceFile, content, scanner.defaults().withFacts(facts));
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-ingest entry point: the project's workspaces, packages and (on Tier-2) sidecar facts.
|
||||
*/
|
||||
public ParseResult parse(String sourceFile, String content, TypeScriptProject project) {
|
||||
FileFacts file = project.facts().get(sourceFile);
|
||||
if (file == null) {
|
||||
return scanner.scan(sourceFile, content, project);
|
||||
}
|
||||
Shell shell = scanner.shell(sourceFile, content);
|
||||
AstNode module = shell.module();
|
||||
List<AstNode> nodes = new ArrayList<>();
|
||||
List<AstEdge> edges = new ArrayList<>();
|
||||
nodes.add(module);
|
||||
if (shell.css()) {
|
||||
TypeScriptCoarseScanner.cssRules(module, content, nodes, edges);
|
||||
return new ParseResult(nodes, edges);
|
||||
}
|
||||
shell.props().put("ingestTier", "2");
|
||||
Placeholders placeholders = new Placeholders(nodes);
|
||||
|
||||
Map<String, AstNode> declared = new HashMap<>();
|
||||
Map<String, AstNode> dtoFields = new HashMap<>(); // "<Dto>.<field>" -> FIELD of this file (item 195)
|
||||
// Item 193: endpoints first — a hey-api const is both a declaration and an endpoint, and the
|
||||
// endpoint node (kind=endpoint, restPath, ...) is the one that must win.
|
||||
for (EndpointFact e : file.endpoints()) {
|
||||
if (declared.containsKey(e.name())) {
|
||||
continue;
|
||||
}
|
||||
AstNode node = new AstNode(UUID.randomUUID(), NodeType.FUNCTION, e.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
e.line(), e.line(), null, null, endpointProps(e));
|
||||
nodes.add(node);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), node.id(), e.line(), null));
|
||||
declared.put(e.name(), node);
|
||||
}
|
||||
for (DeclarationFact d : file.declarations()) {
|
||||
NodeType type = FUNCTION_KINDS.contains(d.kind()) ? NodeType.FUNCTION
|
||||
: DATA_STRUCTURE_KINDS.contains(d.kind()) ? NodeType.DATA_STRUCTURE : null;
|
||||
if (type == null || declared.containsKey(d.name())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("exported", Boolean.toString(d.exported()));
|
||||
if (type == NodeType.FUNCTION) {
|
||||
props.put("kind", d.kind());
|
||||
}
|
||||
AstNode node = new AstNode(UUID.randomUUID(), type, d.name(), sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
d.startLine(), Math.max(d.startLine(), d.endLine()), type == NodeType.DATA_STRUCTURE ? d.kind() : null,
|
||||
null, props);
|
||||
nodes.add(node);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), node.id(), d.startLine(), null));
|
||||
declared.put(d.name(), node);
|
||||
if (type == NodeType.DATA_STRUCTURE) {
|
||||
// Item 193: the DTO shape — one FIELD per property, so data-structures/{name}/fields
|
||||
// answers for a TypeScript interface and field counterparts can be matched by name.
|
||||
// Named <Interface>.<member>: the node identity is (type, name, sourceFile), and one generated
|
||||
// file declares hundreds of interfaces — a bare `vid` would be ONE node under six interfaces
|
||||
// (found by item 195). `field` keeps the bare member name, `owner` the interface.
|
||||
for (MemberFact m : d.members()) {
|
||||
Map<String, String> fieldProps = new LinkedHashMap<>();
|
||||
fieldProps.put("optional", Boolean.toString(m.optional()));
|
||||
fieldProps.put("field", m.name());
|
||||
fieldProps.put("owner", d.name());
|
||||
AstNode field = new AstNode(UUID.randomUUID(), NodeType.FIELD, d.name() + "." + m.name(), sourceFile,
|
||||
TypeScriptLineCounter.LANGUAGE, m.line(), m.line(), m.type(), null, fieldProps);
|
||||
nodes.add(field);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, node.id(), field.id(), m.line(), null));
|
||||
dtoFields.put(d.name() + "." + m.name(), field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (SliceFact sl : file.slices()) {
|
||||
sliceNodes(project, module, sl, declared, nodes, edges);
|
||||
}
|
||||
for (StateAccessFact a : file.stateAccesses()) {
|
||||
if (a.path().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
AstNode fromNode = declared.get(a.fromDecl());
|
||||
AstNode reader = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
|
||||
AstNode target = a.path().size() == 1
|
||||
? placeholders.store(NodeType.STORE_SLICE, a.path().get(0))
|
||||
: placeholders.store(NodeType.FIELD, a.path().get(0) + "." + a.path().get(1));
|
||||
edges.add(accessEdge(EdgeType.READS, reader, target, a.path().subList(1, a.path().size()), a.line(), a.via()));
|
||||
}
|
||||
|
||||
// Item 196: the theme (one DATA_STRUCTURE with a FIELD per token), the style blocks and the token reads.
|
||||
Map<String, AstNode> tokenNodes = new HashMap<>();
|
||||
if (!file.themeTokens().isEmpty()) {
|
||||
Map<String, String> themeProps = new LinkedHashMap<>();
|
||||
themeProps.put("kind", "theme");
|
||||
themeProps.put("exported", "true");
|
||||
int first = file.themeTokens().get(0).line();
|
||||
AstNode theme = new AstNode(UUID.randomUUID(), NodeType.DATA_STRUCTURE, "theme", sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
first, first, "theme", null, themeProps);
|
||||
nodes.add(theme);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, module.id(), theme.id(), first, null));
|
||||
for (ThemeTokenFact t : file.themeTokens()) {
|
||||
if (tokenNodes.containsKey(t.token())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("theme", "true");
|
||||
props.put("token", t.token());
|
||||
props.put("tokenKind", t.kind());
|
||||
if (t.constant() != null) {
|
||||
props.put("constant", t.constant());
|
||||
}
|
||||
if (t.variants() > 1) {
|
||||
props.put("variants", Integer.toString(t.variants()));
|
||||
}
|
||||
AstNode token = new AstNode(UUID.randomUUID(), NodeType.FIELD, "theme." + t.token(), sourceFile,
|
||||
TypeScriptLineCounter.LANGUAGE, t.line(), t.line(), t.value(), t.value(), props);
|
||||
nodes.add(token);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, theme.id(), token.id(), t.line(), null));
|
||||
tokenNodes.put(t.token(), token);
|
||||
}
|
||||
}
|
||||
Set<String> styleNames = new HashSet<>();
|
||||
for (StyleFact st : file.styles()) {
|
||||
AstNode owner = declared.get(st.fromDecl());
|
||||
AstNode parent = owner != null && owner.type() == NodeType.FUNCTION ? owner : module;
|
||||
String base = owner != null ? st.fromDecl() : TypeScriptModuleNames.simpleName(module.name());
|
||||
String name = base + "." + st.styleKind() + "@" + st.line() + ":" + st.col();
|
||||
if (!styleNames.add(name)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("styleKind", st.styleKind());
|
||||
if (st.element() != null) {
|
||||
props.put("element", st.element());
|
||||
}
|
||||
props.put("properties", String.join(",", st.properties()));
|
||||
if (!st.literals().isEmpty()) {
|
||||
props.put("literals", String.join(",", st.literals()));
|
||||
}
|
||||
props.put("dynamic", Boolean.toString(st.dynamic()));
|
||||
props.put("spread", Boolean.toString(st.spread()));
|
||||
AstNode style = new AstNode(UUID.randomUUID(), NodeType.STYLE, name, sourceFile, TypeScriptLineCounter.LANGUAGE,
|
||||
st.line(), st.line(), st.element(), null, props);
|
||||
nodes.add(style);
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CONTAINS, parent.id(), style.id(), st.line(), null));
|
||||
// Item 199: edges merge on (lineNo), so two reads of one token on one line (`color: PRIMARY,
|
||||
// borderColor: PRIMARY`) become one edge whose `property` lists every key they feed.
|
||||
Map<String, List<StyleTokenFact>> perSite = new LinkedHashMap<>();
|
||||
for (StyleTokenFact t : st.tokens()) {
|
||||
perSite.computeIfAbsent(t.token() + "@" + t.line(), k -> new ArrayList<>()).add(t);
|
||||
}
|
||||
for (List<StyleTokenFact> site : perSite.values()) {
|
||||
StyleTokenFact t = site.get(0);
|
||||
AstNode target = tokenNodes.containsKey(t.token()) ? tokenNodes.get(t.token()) : placeholders.theme(t.token());
|
||||
Map<String, String> eprops = new LinkedHashMap<>();
|
||||
eprops.put("via", "theme");
|
||||
String property = site.stream().map(StyleTokenFact::property).filter(Objects::nonNull).distinct()
|
||||
.collect(Collectors.joining(","));
|
||||
if (!property.isEmpty()) {
|
||||
eprops.put("property", property);
|
||||
}
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, style.id(), target.id(), t.line(), t.token(), eprops));
|
||||
}
|
||||
}
|
||||
for (TokenRefFact r : file.tokenRefs()) {
|
||||
AstNode fromNode = declared.get(r.fromDecl());
|
||||
AstNode from = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
|
||||
AstNode target = tokenNodes.containsKey(r.token()) ? tokenNodes.get(r.token()) : placeholders.theme(r.token());
|
||||
Map<String, String> eprops = new LinkedHashMap<>();
|
||||
eprops.put("via", "theme");
|
||||
eprops.put("context", r.context());
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.REFERENCES, from.id(), target.id(), r.line(), r.token(), eprops));
|
||||
}
|
||||
|
||||
for (BindingFact b : file.bindings()) {
|
||||
AstNode fromNode = declared.get(b.fromDecl());
|
||||
AstNode binder = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
|
||||
AstNode target = module.name().equals(b.ownerFile()) ? dtoFields.get(b.ownerDto() + "." + b.field())
|
||||
: placeholders.binding(b.ownerDto(), b.field(), b.ownerFile());
|
||||
if (target == null) {
|
||||
continue; // the leaf is not a declared property of the owner interface
|
||||
}
|
||||
edges.add(bindingEdge(EdgeType.READS, binder, target, b));
|
||||
if (b.component() != null && WRITING_COMPONENTS.matcher(b.component()).matches()) {
|
||||
edges.add(bindingEdge(EdgeType.WRITES, binder, target, b));
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> external = new TreeSet<>();
|
||||
for (ImportFact i : file.imports()) {
|
||||
String target = i.resolved();
|
||||
if (target == null && i.packageName() == null) {
|
||||
// The checker resolved neither a file nor a package. A bare specifier that is not a
|
||||
// workspace is then a transitive dependency the project does not list (`immer`,
|
||||
// `redux`), not a baseUrl-relative module — the Tier-1 guess would mint a placeholder
|
||||
// that never resolves. Relative, `#/` and workspace specifiers keep the Tier-1 rule.
|
||||
String spec = i.specifier();
|
||||
int slash = spec.indexOf('/');
|
||||
String head = slash >= 0 ? spec.substring(0, slash) : spec;
|
||||
boolean bare = !spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#");
|
||||
target = bare && !project.workspaces().contains(head) ? null
|
||||
: TypeScriptCoarseScanner.resolveImport(project, module.name(), spec);
|
||||
}
|
||||
if (target == null) {
|
||||
external.add(i.packageName() != null ? i.packageName() : i.specifier());
|
||||
continue;
|
||||
}
|
||||
edges.add(TypeScriptCoarseScanner.importEdge(module, placeholders.module(target), i.specifier(), i.names(), i.line()));
|
||||
}
|
||||
shell.setExternalImports(external);
|
||||
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (CallFact c : file.calls()) {
|
||||
if (!c.resolvedInProject()) {
|
||||
continue;
|
||||
}
|
||||
String calleeFile = Objects.requireNonNull(c.file());
|
||||
String calleeDecl = Objects.requireNonNull(c.decl());
|
||||
AstNode fromNode = declared.get(c.fromDecl());
|
||||
// the calling FUNCTION, or the module itself for a call at module level / inside a const
|
||||
AstNode caller = fromNode != null && fromNode.type() == NodeType.FUNCTION ? fromNode : module;
|
||||
boolean callerIsFunction = caller != module;
|
||||
Map<String, String> props = new LinkedHashMap<>();
|
||||
props.put("callKind", (c.kind().equals("new") ? CallKind.CONSTRUCTOR : CallKind.METHOD_CALL).name());
|
||||
props.put("callSyntax", c.kind());
|
||||
if (c.receiver() != null) {
|
||||
props.put("receiver", c.receiver());
|
||||
}
|
||||
if (c.member() != null) {
|
||||
props.put("member", c.member());
|
||||
}
|
||||
String actionType = c.actionType();
|
||||
if (actionType != null) {
|
||||
props.put("actionType", actionType);
|
||||
}
|
||||
if (module.name().equals(calleeFile)) {
|
||||
// a dispatched slice action targets its reducer FUNCTION (named by the action type)
|
||||
AstNode callee = actionType != null && declared.containsKey(actionType) ? declared.get(actionType) : declared.get(calleeDecl);
|
||||
if (callee == null || callee.type() != NodeType.FUNCTION) {
|
||||
continue;
|
||||
}
|
||||
if (seen.add("L|" + caller.id() + "|" + callee.id() + "|" + c.line())) {
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, caller.id(), callee.id(), c.line(),
|
||||
c.expression(), props));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (callerIsFunction) {
|
||||
props.put("callerFn", caller.name());
|
||||
}
|
||||
props.put("calleeMethod", actionType != null && isReducerOf(project, calleeFile, actionType) ? actionType
|
||||
: endpointTarget(project, calleeFile, c.receiver(), c.member(), calleeDecl));
|
||||
if (seen.add("X|" + calleeFile + "|" + calleeDecl + "|" + c.fromDecl() + "|" + c.line())) {
|
||||
edges.add(new AstEdge(UUID.randomUUID(), EdgeType.CALLS, module.id(), placeholders.module(calleeFile).id(),
|
||||
c.line(), c.expression(), props));
|
||||
}
|
||||
}
|
||||
return new ParseResult(nodes, edges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Per-ingest context for a TypeScript project (roadmap item 192), the TypeScript counterpart of the
|
||||
* Natural {@code CopycodeLibrary}: built once per ingest from the root {@code package.json} and
|
||||
* handed into every per-file scan/parse call.
|
||||
*
|
||||
* @param workspaces npm workspace directory names under the root ({@code pur-ui-common}, …);
|
||||
* a bare import of one resolves to that workspace's barrel
|
||||
* @param externalPackages npm dependency names from every {@code package.json} found; imports of
|
||||
* them are recorded on the module, never turned into placeholders
|
||||
* @param facts Tier-2 facts from the sidecar, or {@link TypeScriptFacts#NONE} on a Tier-1 pass
|
||||
*/
|
||||
public record TypeScriptProject(Set<String> workspaces, Set<String> externalPackages, TypeScriptFacts facts) {
|
||||
|
||||
public static final TypeScriptProject NONE =
|
||||
new TypeScriptProject(Set.of(), TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES, TypeScriptFacts.NONE);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public TypeScriptProject {
|
||||
workspaces = Set.copyOf(workspaces);
|
||||
externalPackages = Set.copyOf(externalPackages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads {@code root/package.json}: its {@code workspaces} (plain directory names, or
|
||||
* {@code dir/*} globs expanded one level) and the union of {@code dependencies} and
|
||||
* {@code devDependencies} of the root and of every workspace, plus every installed package under
|
||||
* {@code root/node_modules} (transitive dependencies are imported too). A root without a
|
||||
* {@code package.json} yields no workspaces and the built-in package list, so a plain tree of
|
||||
* {@code .ts} files still scans.
|
||||
*/
|
||||
public static TypeScriptProject scan(Path root) throws IOException {
|
||||
Set<String> workspaces = new LinkedHashSet<>();
|
||||
Set<String> packages = new TreeSet<>(TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
|
||||
JsonNode rootJson = readPackageJson(root);
|
||||
if (rootJson != null) {
|
||||
addDependencies(rootJson, packages);
|
||||
for (JsonNode ws : rootJson.path("workspaces")) {
|
||||
String pattern = ws.asText();
|
||||
if (pattern.endsWith("/*")) {
|
||||
Path dir = root.resolve(pattern.substring(0, pattern.length() - 2));
|
||||
if (Files.isDirectory(dir)) {
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(dir, Files::isDirectory)) {
|
||||
for (Path child : children) {
|
||||
workspaces.add(root.relativize(child).toString().replace('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!pattern.isBlank()) {
|
||||
workspaces.add(pattern.replace('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> workspacePackageNames = new LinkedHashSet<>();
|
||||
for (String ws : workspaces) {
|
||||
JsonNode wsJson = readPackageJson(root.resolve(ws));
|
||||
if (wsJson != null) {
|
||||
addDependencies(wsJson, packages);
|
||||
String name = wsJson.path("name").asText("");
|
||||
if (!name.isBlank()) {
|
||||
workspacePackageNames.add(name);
|
||||
}
|
||||
}
|
||||
int slash = ws.lastIndexOf('/');
|
||||
workspacePackageNames.add(slash >= 0 ? ws.substring(slash + 1) : ws);
|
||||
}
|
||||
// Installed packages: package.json lists direct dependencies only, but code imports transitive
|
||||
// ones too (`immer`, `redux` behind @reduxjs/toolkit). Without this the Tier-1 pass at project
|
||||
// creation minted a placeholder `<ws>/src/redux` per such import (verified on purfe).
|
||||
Path nodeModules = root.resolve("node_modules");
|
||||
if (Files.isDirectory(nodeModules)) {
|
||||
try (DirectoryStream<Path> entries = Files.newDirectoryStream(nodeModules, Files::isDirectory)) {
|
||||
for (Path entry : entries) {
|
||||
String name = entry.getFileName().toString();
|
||||
if (name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith("@")) {
|
||||
try (DirectoryStream<Path> scoped = Files.newDirectoryStream(entry, Files::isDirectory)) {
|
||||
for (Path child : scoped) {
|
||||
packages.add(name + "/" + child.getFileName());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
packages.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// a workspace (by its package name or its directory name) is never an npm package from the
|
||||
// resolver's point of view, even when another workspace lists it as a dependency
|
||||
packages.removeAll(workspaces);
|
||||
packages.removeAll(workspacePackageNames);
|
||||
return new TypeScriptProject(workspaces, packages, TypeScriptFacts.NONE);
|
||||
}
|
||||
|
||||
private static @Nullable JsonNode readPackageJson(Path dir) throws IOException {
|
||||
Path file = dir.resolve("package.json");
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return null;
|
||||
}
|
||||
return MAPPER.readTree(Files.readString(file));
|
||||
}
|
||||
|
||||
private static void addDependencies(JsonNode packageJson, Set<String> into) {
|
||||
for (String section : new String[]{"dependencies", "devDependencies", "peerDependencies"}) {
|
||||
for (Iterator<String> it = packageJson.path(section).fieldNames(); it.hasNext(); ) {
|
||||
into.add(it.next());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public TypeScriptProject withFacts(TypeScriptFacts newFacts) {
|
||||
return new TypeScriptProject(workspaces, externalPackages, newFacts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Item 193: normalises a frontend URL template into the shape the backend's {@code rest-endpoints}
|
||||
* rows use, so the two sides can be matched.
|
||||
*
|
||||
* <p>The backend persists a handler's path <em>without</em> its application base
|
||||
* ({@code quarkus.rest.path=/pur-r-vbuch/v1}, {@code @ApplicationPath("pur-r-vstamm/v1")}), while
|
||||
* the frontend composes that base in: the legacy generator through {@code buildPurRVstammURL},
|
||||
* hey-api by embedding it in every URL literal. A leading {@code /<name>/v<digits>} segment pair is
|
||||
* therefore split off as {@code restBase}; the remainder, with the query string removed and no
|
||||
* trailing slash, is {@code restPath}. {@code {param}} placeholders stay as written — the matcher
|
||||
* compares them shape-wise.
|
||||
*
|
||||
* @param restBase the application base that was split off ({@code /pur-r-vstamm/v1}), or {@code ""}
|
||||
* @param restPath the path relative to the base, always starting with {@code /}
|
||||
* @param queryParams the query-string parameter names in order
|
||||
*/
|
||||
public record TypeScriptRestPaths(String restBase, String restPath, List<String> queryParams) {
|
||||
|
||||
private static final Pattern APP_BASE = Pattern.compile("^/([^/]+)/v\\d+(?=/|$)");
|
||||
|
||||
public static TypeScriptRestPaths of(String url) {
|
||||
String path = url;
|
||||
List<String> query = new ArrayList<>();
|
||||
int q = url.indexOf('?');
|
||||
if (q >= 0) {
|
||||
path = url.substring(0, q);
|
||||
for (String pair : url.substring(q + 1).split("&")) {
|
||||
if (pair.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int eq = pair.indexOf('=');
|
||||
query.add(eq >= 0 ? pair.substring(0, eq) : pair);
|
||||
}
|
||||
}
|
||||
path = ("/" + path).replaceAll("/{2,}", "/");
|
||||
if (path.length() > 1 && path.endsWith("/")) {
|
||||
path = path.substring(0, path.length() - 1);
|
||||
}
|
||||
String base = "";
|
||||
Matcher m = APP_BASE.matcher(path);
|
||||
if (m.find()) {
|
||||
base = m.group();
|
||||
path = path.substring(base.length());
|
||||
if (path.isEmpty()) {
|
||||
path = "/";
|
||||
}
|
||||
}
|
||||
return new TypeScriptRestPaths(base, path, List.copyOf(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the generated namespace qualifiers ({@code COMMON.SvcResult<API.X>} → {@code SvcResult<X>}).
|
||||
*/
|
||||
public static String unqualified(String type) {
|
||||
return type.replaceAll("\\b[A-Z][A-Z0-9_]*\\.(?=[A-Za-z_])", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Runs the Node sidecar ({@code sidecar/extract.mjs}) for one workspace and reads its facts
|
||||
* (roadmap item 192). One short-lived process per call: {@code node --max-old-space-size=<mb>
|
||||
* extract.mjs --root <root> --workspace <ws> [--files ...]}, stdout is the JSON document, stderr is
|
||||
* kept for the error message, and the process is killed on timeout. Nothing is written to disk by
|
||||
* the sidecar (the project root is mounted read-only in the container). Measured on the pur
|
||||
* frontend: 3–5 s and ~0.5 GB heap per workspace, so workspaces are run one after another.
|
||||
*/
|
||||
public final class TypeScriptSidecar {
|
||||
|
||||
private final Path node;
|
||||
private final Path script;
|
||||
private final int maxHeapMb;
|
||||
private final Duration timeout;
|
||||
|
||||
/**
|
||||
* @param node the {@code node} binary
|
||||
* @param script {@code extract.mjs}; its sibling {@code node_modules/typescript} must be installed
|
||||
* @param maxHeapMb V8 old-space cap (validated: 1024 leaves headroom in the 5 GB container)
|
||||
* @param timeout per-workspace wall clock before the process is killed
|
||||
*/
|
||||
public TypeScriptSidecar(Path node, Path script, int maxHeapMb, Duration timeout) {
|
||||
this.node = node;
|
||||
this.script = script;
|
||||
this.maxHeapMb = maxHeapMb;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
private static void deleteQuietly(Path p) {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException ignored) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the sidecar can run here: node binary, script and its typescript dependency present.
|
||||
*/
|
||||
public boolean available() {
|
||||
return Files.isExecutable(node) && Files.isRegularFile(script)
|
||||
&& Files.isDirectory(script.resolveSibling("node_modules").resolve("typescript"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root project root (absolute)
|
||||
* @param workspace directory under {@code root} to load as one program
|
||||
* @param files root-relative files to emit facts for, or {@code null} for all of the workspace
|
||||
*/
|
||||
public TypeScriptFacts extract(Path root, String workspace, @Nullable Collection<String> files) {
|
||||
List<String> cmd = new ArrayList<>(List.of(node.toString(), "--max-old-space-size=" + maxHeapMb,
|
||||
script.toString(), "--root", root.toString(), "--workspace", workspace));
|
||||
if (files != null && !files.isEmpty()) {
|
||||
cmd.add("--files");
|
||||
cmd.add(String.join(",", files));
|
||||
}
|
||||
Path scriptDir = script.toAbsolutePath().getParent();
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
if (scriptDir != null) {
|
||||
pb.directory(scriptDir.toFile());
|
||||
}
|
||||
Path stderr;
|
||||
try {
|
||||
stderr = Files.createTempFile("ac-ts-sidecar", ".err");
|
||||
} catch (IOException e) {
|
||||
throw new SidecarException("cannot create stderr capture file", e);
|
||||
}
|
||||
pb.redirectError(stderr.toFile());
|
||||
Process process;
|
||||
try {
|
||||
process = pb.start();
|
||||
} catch (IOException e) {
|
||||
deleteQuietly(stderr);
|
||||
throw new SidecarException("cannot start " + String.join(" ", cmd), e);
|
||||
}
|
||||
try {
|
||||
// Read stdout on this thread while the process runs: the document is large and a pipe
|
||||
// that is not drained would block the sidecar before the exit code is ever seen.
|
||||
byte[] out;
|
||||
try (InputStream in = process.getInputStream()) {
|
||||
out = in.readAllBytes();
|
||||
}
|
||||
if (!process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new SidecarException("sidecar for workspace '" + workspace + "' exceeded " + timeout);
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
throw new SidecarException("sidecar for workspace '" + workspace + "' exited " + process.exitValue()
|
||||
+ ": " + Files.readString(stderr, StandardCharsets.UTF_8).strip());
|
||||
}
|
||||
return TypeScriptFactsReader.read(new String(out, StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new SidecarException("sidecar for workspace '" + workspace + "' produced unreadable facts", e);
|
||||
} catch (InterruptedException e) {
|
||||
process.destroyForcibly();
|
||||
Thread.currentThread().interrupt();
|
||||
throw new SidecarException("interrupted while waiting for the sidecar", e);
|
||||
} finally {
|
||||
if (process.isAlive()) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
deleteQuietly(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidecar could not run or did not produce facts; the ingest reports it and falls back to Tier-1.
|
||||
*/
|
||||
public static final class SidecarException extends RuntimeException {
|
||||
public SidecarException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SidecarException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tier-1 {@link TypeScriptCoarseScanner} (item 192) on the fixture files: module shell with metrics,
|
||||
* function/component/hook/thunk/class shells with line ranges, interface/type/enum data structures,
|
||||
* import placeholders named by resolved path, npm packages recorded and not placeholdered, and no
|
||||
* dangling edges.
|
||||
*/
|
||||
class TypeScriptCoarseScannerTest {
|
||||
|
||||
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
|
||||
private final TypeScriptCoarseScanner scanner =
|
||||
new TypeScriptCoarseScanner(WS, TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
|
||||
|
||||
private static AstNode one(ParseResult r, NodeType type, String name) {
|
||||
return r.nodes().stream().filter(n -> n.type() == type && n.name().equals(name)).findFirst()
|
||||
.orElseThrow(() -> new AssertionError("missing " + type + " " + name + " in "
|
||||
+ r.nodes().stream().map(n -> n.type() + ":" + n.name()).toList()));
|
||||
}
|
||||
|
||||
private static String prop(AstNode n, String key) {
|
||||
return Objects.requireNonNull(Objects.requireNonNull(n.properties()).get(key), key);
|
||||
}
|
||||
|
||||
private static AstNode byId(ParseResult r, UUID id) {
|
||||
return r.nodes().stream().filter(n -> n.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private static AstNode module(ParseResult r) {
|
||||
return r.nodes().stream().filter(n -> n.type() == NodeType.MODULE && !n.sourceFile().isEmpty()).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void language() {
|
||||
assertEquals("typescript", scanner.language());
|
||||
assertEquals("typescript", new TypeScriptParser().language());
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleShellCarriesIdentityAndMetrics() {
|
||||
String src = Fixtures.read(Fixtures.SLICE);
|
||||
AstNode m = module(scanner.scan(Fixtures.SLICE, src));
|
||||
assertEquals("pur-r-vstamm/src/store/slices/agstammSlice", m.name());
|
||||
assertEquals("agstammSlice", prop(m, "simpleName"));
|
||||
assertEquals("pur-r-vstamm", prop(m, "workspace"));
|
||||
assertEquals("ts", prop(m, "moduleKind"));
|
||||
assertEquals("false", prop(m, "generated"));
|
||||
assertEquals(SourceHash.of(src), prop(m, SourceHash.PROPERTY));
|
||||
assertEquals("63", prop(m, "loc"));
|
||||
assertEquals("@reduxjs/toolkit", prop(m, "externalImports"));
|
||||
assertEquals(1, m.startLine());
|
||||
assertEquals(63, m.endLine());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sliceDeclarations() {
|
||||
ParseResult r = scanner.scan(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
|
||||
AstNode load = one(r, NodeType.FUNCTION, "loadBrokerFromServer");
|
||||
assertEquals("thunk", prop(load, "kind"));
|
||||
assertEquals("true", prop(load, "exported"));
|
||||
assertEquals(41, load.startLine());
|
||||
assertEquals(44, load.endLine());
|
||||
assertEquals("thunk", prop(one(r, NodeType.FUNCTION, "saveBrokerToServer"), "kind"));
|
||||
AstNode sel = one(r, NodeType.FUNCTION, "selectAgstamm");
|
||||
assertEquals("function", prop(sel, "kind"));
|
||||
assertEquals(51, sel.endLine(), "an arrow without a block ends at its own line");
|
||||
AstNode helper = one(r, NodeType.FUNCTION, "statusOf");
|
||||
assertEquals("false", prop(helper, "exported"));
|
||||
assertEquals(58, helper.startLine());
|
||||
assertEquals(60, helper.endLine());
|
||||
AstNode state = one(r, NodeType.DATA_STRUCTURE, "AgstammState");
|
||||
assertEquals("interface", state.dataType());
|
||||
assertEquals(8, state.startLine());
|
||||
assertEquals(11, state.endLine());
|
||||
assertTrue(r.nodes().stream().noneMatch(n -> n.name().equals("agstammSlice") && n.type() == NodeType.FUNCTION),
|
||||
"createSlice(...) is data, not a callable shell (item 194 models it)");
|
||||
assertTrue(r.nodes().stream().noneMatch(n -> n.name().equals("initialState")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importsBecomePlaceholdersNamedByResolvedPath() {
|
||||
ParseResult r = scanner.scan(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
|
||||
AstNode m = module(r);
|
||||
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES).toList();
|
||||
Set<String> targets = refs.stream().map(e -> byId(r, e.targetId()).name()).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("pur-r-vstamm/src/generated/agstamm-interfaces",
|
||||
"pur-r-vstamm/src/generated/agstamm-endpoints",
|
||||
"pur-r-vstamm/src/generated/client/sdk.gen",
|
||||
"pur-ui-common/src/index",
|
||||
"pur-r-vstamm/src/store/rv-redux-types"), targets);
|
||||
assertTrue(refs.stream().allMatch(e -> e.sourceId().equals(m.id())));
|
||||
AstEdge common = refs.stream().filter(e -> byId(r, e.targetId()).name().equals("pur-ui-common/src/index")).findFirst().orElseThrow();
|
||||
assertEquals("{createAppAsyncThunk, RequestStatus, SvcResult}", common.value());
|
||||
assertEquals("pur-ui-common", Objects.requireNonNull(common.properties()).get("specifier"));
|
||||
assertEquals(4, common.lineNo());
|
||||
assertTrue(r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).allMatch(n -> n.type() == NodeType.MODULE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageComponentsHooksAndStyled() {
|
||||
ParseResult r = scanner.scan(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE));
|
||||
AstNode m = module(r);
|
||||
assertEquals("tsx", prop(m, "moduleKind"));
|
||||
assertEquals("@mui/material,@mui/material/styles,react", prop(m, "externalImports"));
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
assertEquals("component", prop(page, "kind"));
|
||||
assertEquals(23, page.startLine());
|
||||
assertEquals(42, page.endLine());
|
||||
assertEquals("hook", prop(one(r, NodeType.FUNCTION, "useVermnr"), "kind"));
|
||||
AstNode panel = one(r, NodeType.FUNCTION, "Panel");
|
||||
assertEquals("styled", prop(panel, "kind"));
|
||||
assertEquals("false", prop(panel, "exported"));
|
||||
Set<String> targets = r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).map(AstNode::name).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("pur-ui-common/src/index", "pur-r-vstamm/src/store/hooks/useAgstamm",
|
||||
"pur-r-vstamm/src/generated/agstamm-interfaces", "pur-r-vstamm/src/store/rv-redux-types",
|
||||
"pur-r-vstamm/src/store/store", "pur-r-vstamm/src/store/slices/agstammSlice",
|
||||
"pur-r-vstamm/src/components/Agstamm/HistorieDrawer"), targets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedEndpointsFile() {
|
||||
ParseResult r = scanner.scan(Fixtures.ENDPOINTS, Fixtures.read(Fixtures.ENDPOINTS));
|
||||
AstNode m = module(r);
|
||||
assertEquals("true", prop(m, "generated"));
|
||||
assertEquals("typescript-generator", prop(m, "generator"));
|
||||
AstNode cls = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint");
|
||||
assertEquals("class", prop(cls, "kind"));
|
||||
assertEquals(21, cls.startLine());
|
||||
assertEquals(33, cls.endLine(), "template literals with braces inside must not break brace matching");
|
||||
assertEquals("type", one(r, NodeType.DATA_STRUCTURE, "AgstammAction").dataType());
|
||||
assertEquals("enum", one(r, NodeType.DATA_STRUCTURE, "AgstammKind").dataType());
|
||||
AstNode endpoint = one(r, NodeType.DATA_STRUCTURE, "Endpoint");
|
||||
assertEquals("false", prop(endpoint, "exported"));
|
||||
Set<String> targets = r.nodes().stream().filter(n -> n.sourceFile().isEmpty()).map(AstNode::name).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("pur-ui-common/src/index", "pur-r-vstamm/src/generated/agstamm-interfaces"), targets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cssRulesSurviveAtStatementsStringsNestingAndMinification() {
|
||||
// item 199 (review of 196): the four ways a real stylesheet broke the rule scanner
|
||||
String css = """
|
||||
@import url('https://fonts.googleapis.com/css?family=Open+Sans');
|
||||
@charset "UTF-8";
|
||||
a::after { content: "{"; color: #fff }
|
||||
b::before { content: '}' }
|
||||
@media (max-width: 600px) { a:hover { color: red; margin: 4px } }
|
||||
.x{color:#000}.x{color:#111}p{margin:0}
|
||||
""";
|
||||
ParseResult r = scanner.scan("pur-ui/app.css", css);
|
||||
List<String> names = r.nodes().stream().filter(n -> n.type() == NodeType.STYLE).map(AstNode::name).toList();
|
||||
assertEquals(List.of("a::after@3", "b::before@4", "@media (max-width: 600px)@5", ".x@6", ".x@6:17", "p@6"), names);
|
||||
assertEquals("content,color", prop(one(r, NodeType.STYLE, "a::after@3"), "properties"));
|
||||
assertEquals("#fff", prop(one(r, NodeType.STYLE, "a::after@3"), "literals"));
|
||||
AstNode media = one(r, NodeType.STYLE, "@media (max-width: 600px)@5");
|
||||
assertEquals("color,margin", prop(media, "properties"), "the nested `a:hover {` head is a selector, not a declaration");
|
||||
assertEquals("4px", prop(media, "literals"));
|
||||
assertEquals("#111", prop(one(r, NodeType.STYLE, ".x@6:17"), "literals"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cssIsAModuleShellOnly() {
|
||||
ParseResult r = scanner.scan(Fixtures.CSS, Fixtures.read(Fixtures.CSS));
|
||||
assertEquals(3, r.nodes().size(), "the module and one STYLE per rule (item 196)");
|
||||
AstNode m = r.nodes().get(0);
|
||||
AstNode font = one(r, NodeType.STYLE, "@font-face@2");
|
||||
assertEquals("css", prop(font, "styleKind"));
|
||||
assertEquals("font-family,src", prop(font, "properties"));
|
||||
assertNull(Objects.requireNonNull(font.properties()).get("literals"), "a url() is not a colour/length literal");
|
||||
assertEquals(2, font.startLine());
|
||||
assertEquals(5, font.endLine());
|
||||
AstNode body = one(r, NodeType.STYLE, "body@7");
|
||||
assertEquals("margin", prop(body, "properties"));
|
||||
assertEquals(2, r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(m.id())).count());
|
||||
assertEquals("css", m.language());
|
||||
assertEquals("pur-ui/index.css", m.name());
|
||||
assertEquals("index.css", prop(m, "simpleName"));
|
||||
assertEquals("css", prop(m, "moduleKind"));
|
||||
assertEquals("7", prop(m, "sloc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyEdgeConnectsTwoEmittedNodes() {
|
||||
for (String f : List.of(Fixtures.SLICE, Fixtures.PAGE, Fixtures.ENDPOINTS)) {
|
||||
ParseResult r = scanner.scan(f, Fixtures.read(f));
|
||||
Set<UUID> ids = r.nodes().stream().map(AstNode::id).collect(Collectors.toSet());
|
||||
for (AstEdge e : r.edges()) {
|
||||
assertTrue(ids.contains(e.sourceId()) && ids.contains(e.targetId()), "dangling edge in " + f);
|
||||
}
|
||||
long contains = r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS).count();
|
||||
long children = r.nodes().stream().filter(n -> n.type() != NodeType.MODULE).count();
|
||||
assertEquals(children, contains, "one CONTAINS per declaration in " + f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsertypescript.TypeScriptFacts.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The sidecar facts contract (item 192): {@code facts-pur-r-vstamm.json} is what
|
||||
* {@code extract.mjs} printed for the fixture workspace, checked in so the Java half is tested
|
||||
* without Node. Regenerate with
|
||||
* {@code node sidecar/extract.mjs --root src/test/resources/fixtures/typescript --workspace pur-r-vstamm}
|
||||
* whenever a fixture or the sidecar changes.
|
||||
*/
|
||||
class TypeScriptFactsReaderTest {
|
||||
|
||||
static TypeScriptFacts facts() throws IOException {
|
||||
return TypeScriptFactsReader.read(Fixtures.read(Fixtures.FACTS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsEveryFixtureFileOfTheWorkspace() throws IOException {
|
||||
TypeScriptFacts f = facts();
|
||||
assertEquals(13, f.byFile().size());
|
||||
assertSame(f.get(Fixtures.SLICE), f.byModuleName("pur-r-vstamm/src/store/slices/agstammSlice"));
|
||||
assertTrue(f.has(Fixtures.SLICE));
|
||||
assertTrue(f.has(Fixtures.PAGE));
|
||||
assertFalse(f.has(Fixtures.CSS), "css is not part of the TypeScript program");
|
||||
}
|
||||
|
||||
@Test
|
||||
void declarationsWithCheckerPositions() throws IOException {
|
||||
FileFacts slice = Objects.requireNonNull(facts().get(Fixtures.SLICE));
|
||||
List<String> kinds = slice.declarations().stream().map(d -> d.name() + ":" + d.kind()).toList();
|
||||
assertEquals(List.of("AgstammState:interface", "initialState:const", "agstammSlice:slice", "agstammUiApi:const",
|
||||
"loadBrokerFromServer:thunk", "saveBrokerToServer:thunk", "selectAgstamm:function", "loadHistorie:thunk",
|
||||
"statusOf:function"), kinds);
|
||||
DeclarationFact state = slice.declarations().get(0);
|
||||
assertEquals(List.of("requestStatus:RequestStatus:false", "agstammUseCaseSvcResult:SvcResult<AgstammUseCase>:true"),
|
||||
state.members().stream().map(m -> m.name() + ":" + m.type() + ":" + m.optional()).toList());
|
||||
DeclarationFact load = slice.declarations().get(4);
|
||||
assertTrue(load.exported());
|
||||
assertEquals(41, load.startLine());
|
||||
assertEquals(44, load.endLine());
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointsOfBothGenerators() throws IOException {
|
||||
FileFacts legacy = Objects.requireNonNull(facts().get(Fixtures.ENDPOINTS));
|
||||
assertEquals(2, legacy.endpoints().size());
|
||||
EndpointFact save = legacy.endpoints().get(1);
|
||||
assertEquals("AgstammControllerEndpoint.saveBroker", save.name());
|
||||
assertEquals("AgstammControllerEndpoint", save.owner());
|
||||
assertEquals("saveBroker", save.member());
|
||||
assertEquals("POST", save.httpMethod());
|
||||
assertEquals("typescript-generator", save.generator());
|
||||
assertEquals("pur", save.backend());
|
||||
assertEquals("/agstamm/ui/", save.url());
|
||||
assertEquals("API.AgstammUseCase", save.requestType());
|
||||
assertEquals("COMMON.SvcResult<API.AgstammUseCase>", save.responseType());
|
||||
EndpointFact search = legacy.endpoints().get(0);
|
||||
assertEquals("GET", search.httpMethod());
|
||||
assertEquals("/agstamm/ui/search?vermnr={vermnr}", search.url());
|
||||
assertEquals("{ vermnr: number }", search.paramsType());
|
||||
assertNull(search.requestType());
|
||||
FileFacts hey = Objects.requireNonNull(facts().get("pur-r-vstamm/src/generated/client/sdk.gen.ts"));
|
||||
assertEquals(1, hey.endpoints().size());
|
||||
EndpointFact h = hey.endpoints().get(0);
|
||||
assertEquals("getPurRVstammV1AgstammUiHistorieByVermnr", h.name());
|
||||
assertNull(h.owner());
|
||||
assertEquals("hey-api", h.generator());
|
||||
assertEquals("GET", h.httpMethod());
|
||||
assertEquals("/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}", h.url());
|
||||
assertEquals("GetPurRVstammV1AgstammUiHistorieByVermnrData", h.requestType());
|
||||
assertEquals("GetPurRVstammV1AgstammUiHistorieByVermnrResponses", h.responseType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importsResolvedAgainstTheFileSystem() throws IOException {
|
||||
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
|
||||
ImportFact hook = page.imports().stream().filter(i -> i.specifier().equals("store/hooks/useAgstamm")).findFirst().orElseThrow();
|
||||
assertEquals("pur-r-vstamm/src/store/hooks/useAgstamm", hook.resolved());
|
||||
assertNull(hook.packageName());
|
||||
assertEquals("{useAgstamm}", hook.names());
|
||||
ImportFact react = page.imports().stream().filter(i -> i.specifier().equals("react")).findFirst().orElseThrow();
|
||||
assertNull(react.resolved(), "no node_modules in the fixture tree: unresolved, both null");
|
||||
assertNull(react.packageName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void callsCarryOwnerReceiverAndMember() throws IOException {
|
||||
FileFacts slice = Objects.requireNonNull(facts().get(Fixtures.SLICE));
|
||||
CallFact post = slice.calls().stream().filter(c -> c.expression().equals("agstammUiApi.saveBroker.post")).findFirst().orElseThrow();
|
||||
assertEquals("saveBrokerToServer", post.fromDecl());
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-endpoints", post.file());
|
||||
assertEquals("PostMethod", post.decl(), "the interface owning the called member");
|
||||
assertEquals("AgstammControllerEndpoint", post.receiver());
|
||||
assertEquals("saveBroker.post", post.member());
|
||||
assertTrue(post.resolvedInProject());
|
||||
CallFact ctor = slice.calls().stream().filter(c -> c.kind().equals("new")).findFirst().orElseThrow();
|
||||
assertEquals("AgstammControllerEndpoint", ctor.decl());
|
||||
CallFact lib = slice.calls().stream().filter(c -> c.expression().equals("String")).findFirst().orElseThrow();
|
||||
assertEquals("lib", lib.packageName());
|
||||
assertFalse(lib.resolvedInProject());
|
||||
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
|
||||
CallFact local = page.calls().stream().filter(c -> c.expression().equals("dispatchLoadBrokerFromServer")).findFirst().orElseThrow();
|
||||
assertNull(local.decl(), "a local binding has no owning top-level declaration");
|
||||
CallFact unresolved = page.calls().stream().filter(c -> c.expression().equals("useEffect")).findFirst().orElseThrow();
|
||||
assertNull(unresolved.symbol(), "the checker's unknown symbol is reported as null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnotherContractVersion() {
|
||||
IOException e = assertThrows(IOException.class, () -> TypeScriptFactsReader.read("{\"version\": 5, \"files\": {}}"));
|
||||
assertTrue(String.valueOf(e.getMessage()).contains("version 5"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeFactsOfItem194() throws IOException {
|
||||
TypeScriptFacts f = facts();
|
||||
StoreFact store = Objects.requireNonNull(Objects.requireNonNull(f.get("pur-r-vstamm/src/store/store.ts")).store());
|
||||
assertEquals(1, store.keys().size());
|
||||
assertEquals("broker", store.keys().get(0).key());
|
||||
assertEquals("pur-r-vstamm/src/store/slices/agstammSlice", store.keys().get(0).sliceFile(), "traced through the default export");
|
||||
assertEquals("agstamm", store.keys().get(0).sliceName());
|
||||
assertEquals("broker", f.storeKey("pur-r-vstamm/src/store/slices/agstammSlice", "agstamm"), "the key differs from the slice name on purpose");
|
||||
assertEquals("other", f.storeKey("nowhere", "other"), "unmounted: the slice name itself");
|
||||
|
||||
FileFacts sliceFile = Objects.requireNonNull(f.get(Fixtures.SLICE));
|
||||
assertNull(sliceFile.store());
|
||||
assertEquals(1, sliceFile.slices().size());
|
||||
SliceFact slice = sliceFile.slices().get(0);
|
||||
assertEquals("agstammSlice", slice.name());
|
||||
assertEquals("agstamm", slice.sliceName());
|
||||
assertEquals("AgstammState", slice.stateType());
|
||||
assertEquals(List.of("requestStatus:RequestStatus:false", "agstammUseCaseSvcResult:SvcResult<AgstammUseCase>:true"),
|
||||
slice.fields().stream().map(m -> m.name() + ":" + m.type() + ":" + m.optional()).toList(), "from the checker's type of initialState");
|
||||
assertEquals(List.of("agstamm/updateAgstammUseCaseSvcResult:reducer", "agstamm/resetIfIdle:reducer",
|
||||
"agstamm/loadBroker/fulfilled:case", "agstamm/matcher:(action) => action.type.endsWith('/rejected'):matcher"),
|
||||
slice.reducers().stream().map(r -> r.name() + ":" + r.kind()).toList());
|
||||
ReducerFact reset = slice.reducers().get(1);
|
||||
assertEquals(List.of("read:requestStatus:23", "write:agstammUseCaseSvcResult:24"),
|
||||
reset.accesses().stream().map(a -> a.mode() + ":" + String.join(".", a.path()) + ":" + a.line()).toList());
|
||||
ReducerFact fulfilled = slice.reducers().get(2);
|
||||
assertEquals("loadBrokerFromServer.fulfilled", Objects.requireNonNull(fulfilled.trigger()).expression());
|
||||
assertEquals("agstamm/loadBroker/fulfilled", Objects.requireNonNull(fulfilled.trigger()).actionType());
|
||||
assertEquals("loadBrokerFromServer", Objects.requireNonNull(fulfilled.trigger()).decl());
|
||||
assertNull(Objects.requireNonNull(slice.reducers().get(3).trigger()).actionType(), "a predicate matcher has no action type");
|
||||
|
||||
FileFacts page = Objects.requireNonNull(f.get(Fixtures.PAGE));
|
||||
assertEquals(List.of("AgstammPage:broker.requestStatus:useAppSelector", "AgstammPage:broker.agstammUseCaseSvcResult:useAppSelector",
|
||||
"AgstammPage:broker.requestStatus:getState"),
|
||||
page.stateAccesses().stream().map(a -> a.fromDecl() + ":" + String.join(".", a.path()) + ":" + a.via()).toList(),
|
||||
"a selector arrow, a destructured selector result, a getState() chain");
|
||||
CallFact dispatch = page.calls().stream().filter(c -> c.expression().equals("resetIfIdle")).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/resetIfIdle", dispatch.actionType());
|
||||
assertEquals("agstammSlice", dispatch.decl(), "the action creator is owned by the slice const");
|
||||
assertEquals(Fixtures.SLICE.replace(".ts", ""), dispatch.file());
|
||||
CallFact thunk = Objects.requireNonNull(f.get("pur-r-vstamm/src/store/hooks/useAgstamm.ts")).calls().stream()
|
||||
.filter(c -> c.expression().equals("loadBrokerFromServer")).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/loadBroker", thunk.actionType(), "a thunk carries its type prefix");
|
||||
StateAccessFact wrapped = Objects.requireNonNull(f.get("pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx")).stateAccesses().get(0);
|
||||
assertEquals(List.of("broker", "agstammUseCaseSvcResult", "result", "purMode"), wrapped.path(), "wrapper base path + the selector's own path");
|
||||
assertEquals("useAgstammSelector", wrapped.via());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingFactsOfItem195() throws IOException {
|
||||
FileFacts page = Objects.requireNonNull(facts().get(Fixtures.PAGE));
|
||||
assertEquals(1, page.bindings().size());
|
||||
TypeScriptFacts.BindingFact b = page.bindings().get(0);
|
||||
assertEquals("AgstammPage", b.fromDecl());
|
||||
assertEquals("field", b.kind());
|
||||
assertEquals("AgstammUseCase", b.rootDto());
|
||||
assertEquals("Broker", b.ownerDto(), "the DTO declaring the leaf, from the previous hop's TSelf");
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces", b.ownerFile());
|
||||
assertEquals("ebene", b.field());
|
||||
assertEquals("broker.ebene", b.path());
|
||||
assertFalse(b.partial());
|
||||
assertEquals("SmartInput", b.component());
|
||||
assertEquals("field", b.attribute());
|
||||
assertEquals(38, b.line());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stylingFactsOfItem196() throws IOException {
|
||||
TypeScriptFacts f = facts();
|
||||
FileFacts theme = Objects.requireNonNull(f.get("pur-r-vstamm/src/rvTheme.ts"));
|
||||
assertEquals(List.of("palette.primary.main:path:#005CA9:PRIMARY", "palette.primary.dark:path:#0054A2:PRIMARY_DARK",
|
||||
"palette.primary.contrastText:path:#FFFFFF:null", "palette.background.paper:path:#FFFFFF:WHITE",
|
||||
"shape.borderRadius:path:4:null", "typography.fontFamily:path:OpenSans:null", "typography.h1.fontWeight:path:300:null",
|
||||
"palette.mode:path:dark:null", "PRIMARY:constant:#005CA9:null", "WHITE:constant:#FFFFFF:null"),
|
||||
theme.themeTokens().stream().map(t -> t.token() + ":" + t.kind() + ":" + t.value() + ":" + t.constant()).toList(),
|
||||
"createTheme leaves with values folded through constants, then the exported constants");
|
||||
assertEquals(2, theme.themeTokens().stream().filter(t -> t.token().equals("palette.primary.main")).findFirst().orElseThrow().variants(),
|
||||
"item 199: the dark theme in the same file declares it too");
|
||||
assertEquals(1, theme.themeTokens().stream().filter(t -> t.token().equals("palette.mode")).findFirst().orElseThrow().variants());
|
||||
assertTrue(theme.tokenRefs().isEmpty(), "the theme file's own constant uses are not references");
|
||||
FileFacts drawer = Objects.requireNonNull(f.get("pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx"));
|
||||
assertEquals(1, drawer.styles().size());
|
||||
TypeScriptFacts.StyleFact sx = drawer.styles().get(0);
|
||||
assertEquals("sx", sx.styleKind());
|
||||
assertEquals("Box", sx.element());
|
||||
assertEquals("HistorieDrawer", sx.fromDecl());
|
||||
assertEquals(List.of("color", "borderColor", "height", "&:hover.background", "mt"), sx.properties(), "nested selectors flattened");
|
||||
assertEquals(List.of("17px"), sx.literals(), "mt: 2 is theme-relative, not a literal");
|
||||
assertFalse(sx.dynamic());
|
||||
assertEquals(List.of("PRIMARY:color", "PRIMARY:borderColor", "palette.background.paper:&:hover.background"),
|
||||
sx.tokens().stream().map(t -> t.token() + ":" + t.property()).toList(), "a theme constant and a theme chain, each with the key it feeds");
|
||||
assertEquals(1, drawer.tokenRefs().size());
|
||||
assertEquals("palette.primary.main", drawer.tokenRefs().get(0).token());
|
||||
assertEquals("borderColor", drawer.tokenRefs().get(0).context(), "a token used as a plain prop, outside any style block");
|
||||
FileFacts page = Objects.requireNonNull(f.get(Fixtures.PAGE));
|
||||
assertEquals(List.of("Panel:styled:Box", "AgstammPage:sx:Panel", "AgstammPage:style:Typography"),
|
||||
page.styles().stream().map(st -> st.fromDecl() + ":" + st.styleKind() + ":" + st.element()).toList());
|
||||
assertEquals("spacing", page.styles().get(0).tokens().get(0).token(), "theme.spacing(2) ends at the call");
|
||||
assertEquals("palette.primary.dark", page.styles().get(2).tokens().get(0).token(), "an imported theme object is a theme root too");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* Import resolution rules of {@link TypeScriptModuleNames} (item 192) against the frontend's tsconfig conventions.
|
||||
*/
|
||||
class TypeScriptModuleNamesTest {
|
||||
|
||||
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
|
||||
private static final Set<String> EXT = TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES;
|
||||
private static final String IMPORTER = "pur-r-vstamm/src/store/slices/agstammSlice";
|
||||
|
||||
@Test
|
||||
void identityIsPathWithoutExtension() {
|
||||
assertEquals(IMPORTER, TypeScriptModuleNames.moduleName("pur-r-vstamm/src/store/slices/agstammSlice.ts"));
|
||||
assertEquals("agstammSlice", TypeScriptModuleNames.simpleName(IMPORTER));
|
||||
assertEquals("pur-r-vstamm", TypeScriptModuleNames.workspace(IMPORTER));
|
||||
assertEquals("pur-ui/index.css", TypeScriptModuleNames.moduleName("pur-ui\\index.css"), "css keeps its extension");
|
||||
assertEquals("pur-ui/src/index.css", TypeScriptModuleNames.resolveImport("pur-ui/src/main", "./index.css", WS, EXT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativeImportsResolveAgainstTheImportersDirectory() {
|
||||
assertEquals("pur-r-vstamm/src/store/rv-redux-types",
|
||||
TypeScriptModuleNames.resolveImport(IMPORTER, "../rv-redux-types", WS, EXT));
|
||||
assertEquals("pur-r-vstamm/src/store/slices/HistorieDrawer",
|
||||
TypeScriptModuleNames.resolveImport(IMPORTER, "./HistorieDrawer.tsx", WS, EXT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void baseUrlImportsResolveUnderTheWorkspaceSrc() {
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces",
|
||||
TypeScriptModuleNames.resolveImport(IMPORTER, "generated/agstamm-interfaces", WS, EXT));
|
||||
assertEquals("pur-r-vstamm/src/hooks/x",
|
||||
TypeScriptModuleNames.resolveImport(IMPORTER, "#/hooks/x", WS, EXT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workspaceImportsResolveToBarrelOrPath() {
|
||||
assertEquals("pur-ui-common/src/index", TypeScriptModuleNames.resolveImport(IMPORTER, "pur-ui-common", WS, EXT));
|
||||
assertEquals("pur-ui-common/src/generated/api-interfaces",
|
||||
TypeScriptModuleNames.resolveImport(IMPORTER, "pur-ui-common/src/generated/api-interfaces", WS, EXT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void npmPackagesAreExternal() {
|
||||
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "@reduxjs/toolkit", WS, EXT));
|
||||
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "react", WS, EXT));
|
||||
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "lodash/get", WS, EXT));
|
||||
assertNull(TypeScriptModuleNames.resolveImport(IMPORTER, "node:path", WS, EXT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.AstEdge;
|
||||
import com.agenticcode.parsercore.ast.model.AstNode;
|
||||
import com.agenticcode.parsercore.ast.model.EdgeType;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tier-2 merge of {@link TypeScriptParser} (item 192): facts override the outline; calls become graph edges shaped like the Java parser's.
|
||||
*/
|
||||
class TypeScriptParserTest {
|
||||
|
||||
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
|
||||
private final TypeScriptParser parser = new TypeScriptParser(WS, TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
|
||||
|
||||
private static AstNode byId(ParseResult r, UUID id) {
|
||||
return r.nodes().stream().filter(n -> n.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private static AstNode one(ParseResult r, NodeType type, String name) {
|
||||
return r.nodes().stream().filter(n -> n.type() == type && n.name().equals(name) && !n.sourceFile().isEmpty())
|
||||
.findFirst().orElseThrow(() -> new AssertionError("missing " + type + " " + name));
|
||||
}
|
||||
|
||||
private static String prop(AstEdge e, String key) {
|
||||
return Objects.requireNonNull(Objects.requireNonNull(e.properties()).get(key), key);
|
||||
}
|
||||
|
||||
private static List<AstEdge> access(ParseResult r, EdgeType type, AstNode from) {
|
||||
return r.edges().stream().filter(e -> e.type() == type && e.sourceId().equals(from.id())).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutFactsItIsTheOutline() {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
|
||||
assertTrue(r.edges().stream().noneMatch(e -> e.type() == EdgeType.CALLS));
|
||||
assertNull(Objects.requireNonNull(r.nodes().get(0).properties()).get("ingestTier"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void factsReplaceDeclarationsAndMarkTier2() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode module = r.nodes().get(0);
|
||||
assertEquals("2", Objects.requireNonNull(module.properties()).get("ingestTier"));
|
||||
assertEquals("63", Objects.requireNonNull(module.properties()).get("loc"), "shell metrics stay");
|
||||
Set<String> names = r.nodes().stream().filter(n -> n.type() != NodeType.MODULE && n.type() != NodeType.STYLE && !n.sourceFile().isEmpty())
|
||||
.map(n -> n.type() + ":" + n.name()).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("DATA_STRUCTURE:AgstammState", "FIELD:AgstammState.requestStatus", "FIELD:AgstammState.agstammUseCaseSvcResult",
|
||||
"FUNCTION:loadBrokerFromServer", "FUNCTION:saveBrokerToServer", "FUNCTION:loadHistorie",
|
||||
"FUNCTION:selectAgstamm", "FUNCTION:statusOf",
|
||||
// item 194: the slice under its store key, its state keys, its reducers by action type
|
||||
"STORE_SLICE:broker", "FIELD:broker.requestStatus", "FIELD:broker.agstammUseCaseSvcResult",
|
||||
"FUNCTION:agstamm/updateAgstammUseCaseSvcResult", "FUNCTION:agstamm/resetIfIdle", "FUNCTION:agstamm/loadBroker/fulfilled",
|
||||
"FUNCTION:agstamm/matcher:(action) => action.type.endsWith('/rejected')"), names, "plain consts are not nodes");
|
||||
assertEquals("thunk", Objects.requireNonNull(one(r, NodeType.FUNCTION, "loadBrokerFromServer").properties()).get("kind"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossFileCallsAreModuleToPlaceholderEdgesLikeJava() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode module = r.nodes().get(0);
|
||||
List<AstEdge> calls = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id())).toList();
|
||||
assertEquals(4, calls.size(), "new Endpoint(), searchBroker.get, saveBroker.post, hey-api fn; lib/unresolved calls are no edges");
|
||||
assertEquals(5, r.edges().stream().filter(e -> e.type() == EdgeType.CALLS).count(), "+ the same-file thunk -> case edge (item 194)");
|
||||
AstEdge post = calls.stream().filter(e -> "agstammUiApi.saveBroker.post".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-endpoints", byId(r, post.targetId()).name());
|
||||
assertEquals("METHOD_CALL", prop(post, "callKind"));
|
||||
assertEquals("saveBrokerToServer", prop(post, "callerFn"));
|
||||
assertEquals("AgstammControllerEndpoint.saveBroker", prop(post, "calleeMethod"),
|
||||
"item 193: retargeted from the generic PostMethod.post signature to the endpoint function");
|
||||
AstEdge hey = calls.stream().filter(e -> "getPurRVstammV1AgstammUiHistorieByVermnr".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("loadHistorie", prop(hey, "callerFn"));
|
||||
assertEquals("getPurRVstammV1AgstammUiHistorieByVermnr", prop(hey, "calleeMethod"));
|
||||
assertEquals("AgstammControllerEndpoint", prop(post, "receiver"));
|
||||
assertEquals("saveBroker.post", prop(post, "member"));
|
||||
assertEquals(48, post.lineNo());
|
||||
AstEdge ctor = calls.stream().filter(e -> "AgstammControllerEndpoint".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("CONSTRUCTOR", prop(ctor, "callKind"));
|
||||
assertNull(Objects.requireNonNull(ctor.properties()).get("callerFn"), "module-level const: no calling function");
|
||||
assertEquals("AgstammControllerEndpoint", prop(ctor, "calleeMethod"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameFileCallsAreFunctionToFunctionAndJsxCountsAsACall() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
List<AstEdge> fromPage = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(page.id())).toList();
|
||||
Set<String> targets = fromPage.stream().map(e -> byId(r, e.targetId()).name()).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("useVermnr", "Panel"), targets, "local top-level callees; the local variable call is no edge");
|
||||
AstEdge panel = fromPage.stream().filter(e -> byId(r, e.targetId()).name().equals("Panel")).findFirst().orElseThrow();
|
||||
assertEquals("jsx", prop(panel, "callSyntax"));
|
||||
AstNode module = r.nodes().get(0);
|
||||
Set<String> cross = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id()))
|
||||
.map(e -> byId(r, e.targetId()).name() + "#" + prop(e, "calleeMethod") + "<-" + prop(e, "callerFn")).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("pur-r-vstamm/src/store/hooks/useAgstamm#useAgstamm<-AgstammPage",
|
||||
"pur-r-vstamm/src/components/Agstamm/HistorieDrawer#HistorieDrawer<-AgstammPage",
|
||||
"pur-r-vstamm/src/store/rv-redux-types#useAppSelector<-AgstammPage",
|
||||
// item 194: a dispatched action creator targets the reducer FUNCTION, not the slice const
|
||||
"pur-r-vstamm/src/store/slices/agstammSlice#agstamm/resetIfIdle<-AgstammPage"), cross);
|
||||
AstEdge dispatch = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "agstamm/resetIfIdle".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/resetIfIdle", prop(dispatch, "actionType"));
|
||||
assertEquals("@mui/material,@mui/material/styles,react", Objects.requireNonNull(module.properties()).get("externalImports"),
|
||||
"no node_modules in the fixture tree: the sidecar resolves none of these, so the Tier-1 rule decides — "
|
||||
+ "@-scoped and known packages are external, pur-ui-common is a workspace and became a placeholder");
|
||||
assertTrue(r.nodes().stream().anyMatch(n -> n.sourceFile().isEmpty() && n.name().equals("pur-ui-common/src/index")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointFunctionsCarryTheJavaHandlerProperties() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.ENDPOINTS, Fixtures.read(Fixtures.ENDPOINTS), TypeScriptFactsReaderTest.facts());
|
||||
AstNode save = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.saveBroker");
|
||||
java.util.Map<String, String> p = Objects.requireNonNull(save.properties());
|
||||
assertEquals("endpoint", p.get("kind"));
|
||||
assertEquals("true", p.get("outbound"));
|
||||
assertEquals("POST", p.get("httpMethod"));
|
||||
assertEquals("/agstamm/ui", p.get("restPath"));
|
||||
assertEquals("", p.get("restBase"));
|
||||
assertEquals("/agstamm/ui/", p.get("restUrl"));
|
||||
assertEquals("pur", p.get("backend"));
|
||||
assertEquals("AgstammUseCase", p.get("requestType"), "namespace qualifiers stripped");
|
||||
assertEquals("SvcResult<AgstammUseCase>", p.get("responseType"));
|
||||
assertEquals("typescript-generator", p.get("generator"));
|
||||
AstNode search = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.searchBroker");
|
||||
assertEquals("/agstamm/ui/search", Objects.requireNonNull(search.properties()).get("restPath"));
|
||||
assertEquals("vermnr", Objects.requireNonNull(search.properties()).get("queryParams"));
|
||||
assertTrue(r.nodes().stream().anyMatch(n -> n.type() == NodeType.FUNCTION && n.name().equals("AgstammControllerEndpoint")
|
||||
&& "class".equals(Objects.requireNonNull(n.properties()).get("kind"))), "the class shell stays");
|
||||
String heyFile = "pur-r-vstamm/src/generated/client/sdk.gen.ts";
|
||||
ParseResult h = parser.parse(heyFile, Fixtures.read(heyFile), TypeScriptFactsReaderTest.facts());
|
||||
AstNode fn = one(h, NodeType.FUNCTION, "getPurRVstammV1AgstammUiHistorieByVermnr");
|
||||
assertEquals(1, h.nodes().stream().filter(n -> n.name().equals(fn.name())).count(), "endpoint node wins over the const declaration");
|
||||
assertEquals("endpoint", Objects.requireNonNull(fn.properties()).get("kind"));
|
||||
assertEquals("/pur-r-vstamm/v1", Objects.requireNonNull(fn.properties()).get("restBase"));
|
||||
assertEquals("/agstamm/ui/historie/{vermnr}", Objects.requireNonNull(fn.properties()).get("restPath"));
|
||||
assertEquals("hey-api", Objects.requireNonNull(fn.properties()).get("generator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interfaceMembersBecomeFields() throws IOException {
|
||||
String f = "pur-r-vstamm/src/generated/agstamm-interfaces.ts";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode useCase = one(r, NodeType.DATA_STRUCTURE, "AgstammUseCase");
|
||||
List<AstNode> fields = r.edges().stream()
|
||||
.filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(useCase.id()))
|
||||
.map(e -> byId(r, e.targetId())).toList();
|
||||
assertEquals(List.of("AgstammUseCase.broker:Broker", "AgstammUseCase.brokerName:string"),
|
||||
fields.stream().map(n -> n.name() + ":" + n.dataType()).toList(), "qualified: one node per interface member, not per name");
|
||||
assertTrue(fields.stream().allMatch(n -> n.type() == NodeType.FIELD));
|
||||
assertEquals("broker", Objects.requireNonNull(fields.get(0).properties()).get("field"));
|
||||
assertEquals("AgstammUseCase", Objects.requireNonNull(fields.get(0).properties()).get("owner"));
|
||||
}
|
||||
|
||||
// ----- item 194: the Redux store -----
|
||||
|
||||
@Test
|
||||
void everyEdgeConnectsTwoEmittedNodes() throws IOException {
|
||||
TypeScriptFacts facts = TypeScriptFactsReaderTest.facts();
|
||||
for (String f : facts.byFile().keySet()) {
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), facts);
|
||||
Set<UUID> ids = r.nodes().stream().map(AstNode::id).collect(Collectors.toSet());
|
||||
for (AstEdge e : r.edges()) {
|
||||
assertTrue(ids.contains(e.sourceId()) && ids.contains(e.targetId()), "dangling edge in " + f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sliceBecomesStoreSliceWithFieldsAndReducerFunctions() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode slice = one(r, NodeType.STORE_SLICE, "broker");
|
||||
assertEquals("agstamm", Objects.requireNonNull(slice.properties()).get("sliceName"));
|
||||
assertEquals("broker", Objects.requireNonNull(slice.properties()).get("storeKey"), "named by the key store.ts mounts it under");
|
||||
assertEquals("true", Objects.requireNonNull(slice.properties()).get("store"));
|
||||
assertEquals("AgstammState", slice.dataType());
|
||||
assertEquals(15, slice.startLine());
|
||||
assertEquals(37, slice.endLine());
|
||||
AstNode module = r.nodes().get(0);
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(module.id()) && e.targetId().equals(slice.id())));
|
||||
AstNode status = one(r, NodeType.FIELD, "broker.requestStatus");
|
||||
AstNode result = one(r, NodeType.FIELD, "broker.agstammUseCaseSvcResult");
|
||||
assertEquals("RequestStatus", status.dataType());
|
||||
assertEquals("requestStatus", Objects.requireNonNull(status.properties()).get("field"));
|
||||
assertEquals("broker", Objects.requireNonNull(status.properties()).get("slice"));
|
||||
assertEquals("true", Objects.requireNonNull(result.properties()).get("optional"));
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(slice.id()) && e.targetId().equals(status.id())));
|
||||
|
||||
AstNode reset = one(r, NodeType.FUNCTION, "agstamm/resetIfIdle");
|
||||
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("kind"));
|
||||
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("reducerKind"));
|
||||
assertEquals("broker", Objects.requireNonNull(reset.properties()).get("slice"));
|
||||
assertEquals("agstamm/resetIfIdle", Objects.requireNonNull(reset.properties()).get("actionType"));
|
||||
assertEquals(22, reset.startLine());
|
||||
assertEquals(26, reset.endLine());
|
||||
List<AstEdge> reads = access(r, EdgeType.READS, reset);
|
||||
assertEquals(1, reads.size());
|
||||
assertEquals(status.id(), reads.get(0).targetId());
|
||||
assertEquals("requestStatus", prop(reads.get(0), "path"));
|
||||
assertEquals("reducer", prop(reads.get(0), "via"));
|
||||
assertEquals(23, reads.get(0).lineNo());
|
||||
List<AstEdge> writes = access(r, EdgeType.WRITES, reset);
|
||||
assertEquals(1, writes.size());
|
||||
assertEquals(result.id(), writes.get(0).targetId());
|
||||
assertEquals(24, writes.get(0).lineNo());
|
||||
|
||||
AstNode fulfilled = one(r, NodeType.FUNCTION, "agstamm/loadBroker/fulfilled");
|
||||
assertEquals("case", Objects.requireNonNull(fulfilled.properties()).get("reducerKind"));
|
||||
assertEquals("loadBrokerFromServer.fulfilled", Objects.requireNonNull(fulfilled.properties()).get("trigger"));
|
||||
assertEquals(2, access(r, EdgeType.WRITES, fulfilled).size());
|
||||
AstNode thunk = one(r, NodeType.FUNCTION, "loadBrokerFromServer");
|
||||
AstEdge fires = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(thunk.id()) && e.targetId().equals(fulfilled.id()))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("the thunk fires its fulfilled case"));
|
||||
assertEquals("extraReducer", prop(fires, "callSyntax"));
|
||||
assertEquals("agstamm/loadBroker/fulfilled", prop(fires, "actionType"));
|
||||
AstNode matcher = one(r, NodeType.FUNCTION, "agstamm/matcher:(action) => action.type.endsWith('/rejected')");
|
||||
assertEquals("matcher", Objects.requireNonNull(matcher.properties()).get("reducerKind"));
|
||||
assertNull(Objects.requireNonNull(matcher.properties()).get("actionType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumersReadStorePlaceholdersByKeyAndField() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
List<String> reads = access(r, EdgeType.READS, page).stream()
|
||||
.filter(e -> !"binding".equals(Objects.requireNonNull(e.properties()).get("via"))) // item 195 reads are tested separately
|
||||
.map(e -> byId(r, e.targetId()).type() + ":" + byId(r, e.targetId()).name() + "[" + prop(e, "path") + "]@" + e.lineNo() + " via " + prop(e, "via"))
|
||||
.sorted().toList();
|
||||
assertEquals(List.of("FIELD:broker.agstammUseCaseSvcResult[agstammUseCaseSvcResult]@27 via useAppSelector",
|
||||
"FIELD:broker.requestStatus[requestStatus]@26 via useAppSelector",
|
||||
"FIELD:broker.requestStatus[requestStatus]@30 via getState"), reads);
|
||||
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).findFirst().orElseThrow();
|
||||
assertEquals("", ph.sourceFile(), "a placeholder: the slice lives in another file");
|
||||
assertEquals(NodeType.FIELD, ph.type());
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("store"), "what the store resolver matches on");
|
||||
assertEquals(1, r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).count(), "one placeholder per name");
|
||||
|
||||
String drawer = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
|
||||
ParseResult d = parser.parse(drawer, Fixtures.read(drawer), TypeScriptFactsReaderTest.facts());
|
||||
AstEdge wrapped = access(d, EdgeType.READS, one(d, NodeType.FUNCTION, "HistorieDrawer")).get(0);
|
||||
assertEquals("broker.agstammUseCaseSvcResult", byId(d, wrapped.targetId()).name());
|
||||
assertEquals("agstammUseCaseSvcResult.result.purMode", prop(wrapped, "path"), "the wrapper hook's base path plus the selector's own");
|
||||
assertEquals("useAgstammSelector", prop(wrapped, "via"));
|
||||
|
||||
String hook = "pur-r-vstamm/src/store/hooks/useAgstamm.ts";
|
||||
ParseResult h = parser.parse(hook, Fixtures.read(hook), TypeScriptFactsReaderTest.facts());
|
||||
assertEquals(1, access(h, EdgeType.READS, one(h, NodeType.FUNCTION, "useAgstammSelector")).size(), "the wrapper's own inner selector is a read too");
|
||||
AstEdge thunkCall = h.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "loadBrokerFromServer".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/loadBroker", prop(thunkCall, "actionType"), "a thunk keeps its FUNCTION as target and carries the type prefix");
|
||||
}
|
||||
|
||||
// ----- item 195: DTO field bindings -----
|
||||
|
||||
@Test
|
||||
void aBoundFieldIsReadAndWrittenThroughAPlaceholderResolvedByOwner() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("Broker.ebene")).findFirst().orElseThrow();
|
||||
assertEquals("", ph.sourceFile());
|
||||
assertEquals(NodeType.FIELD, ph.type());
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("binding"));
|
||||
assertEquals("Broker", Objects.requireNonNull(ph.properties()).get("owner"));
|
||||
assertEquals("ebene", Objects.requireNonNull(ph.properties()).get("field"));
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces", Objects.requireNonNull(ph.properties()).get("targetModule"));
|
||||
for (EdgeType type : List.of(EdgeType.READS, EdgeType.WRITES)) {
|
||||
AstEdge e = r.edges().stream().filter(x -> x.type() == type && x.sourceId().equals(page.id()) && x.targetId().equals(ph.id()))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("SmartInput binds " + type));
|
||||
assertEquals("broker.ebene", prop(e, "path"));
|
||||
assertEquals("AgstammUseCase", prop(e, "rootDto"));
|
||||
assertEquals("SmartInput", prop(e, "component"));
|
||||
assertEquals("binding", prop(e, "via"));
|
||||
assertEquals("false", prop(e, "partial"));
|
||||
assertEquals(38, e.lineNo());
|
||||
}
|
||||
}
|
||||
|
||||
// ----- item 196: styling -----
|
||||
|
||||
@Test
|
||||
void themeTokensAreFieldsOfATheme() throws IOException {
|
||||
String f = "pur-r-vstamm/src/rvTheme.ts";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode theme = one(r, NodeType.DATA_STRUCTURE, "theme");
|
||||
assertEquals("theme", Objects.requireNonNull(theme.properties()).get("kind"));
|
||||
AstNode dark = one(r, NodeType.FIELD, "theme.palette.primary.dark");
|
||||
assertEquals("#0054A2", dark.dataType());
|
||||
assertEquals("true", Objects.requireNonNull(dark.properties()).get("theme"));
|
||||
assertEquals("palette.primary.dark", Objects.requireNonNull(dark.properties()).get("token"));
|
||||
assertEquals("path", Objects.requireNonNull(dark.properties()).get("tokenKind"));
|
||||
assertEquals("PRIMARY_DARK", Objects.requireNonNull(dark.properties()).get("constant"));
|
||||
assertEquals("constant", Objects.requireNonNull(one(r, NodeType.FIELD, "theme.PRIMARY").properties()).get("tokenKind"));
|
||||
// item 199: the dark theme in the same file adds palette.mode, shares primary.main/dark (first value wins, variants=2)
|
||||
assertEquals(10, r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(theme.id())).count());
|
||||
assertEquals("2", Objects.requireNonNull(dark.properties()).get("variants"));
|
||||
assertNull(Objects.requireNonNull(one(r, NodeType.FIELD, "theme.palette.mode").properties()).get("variants"));
|
||||
assertEquals("dark", one(r, NodeType.FIELD, "theme.palette.mode").dataType());
|
||||
assertTrue(r.nodes().stream().noneMatch(n -> n.sourceFile().isEmpty() && n.name().startsWith("theme.")), "no self-placeholders");
|
||||
}
|
||||
|
||||
@Test
|
||||
void styleBlocksAreStylesWithTokenReferences() throws IOException {
|
||||
String f = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode drawer = one(r, NodeType.FUNCTION, "HistorieDrawer");
|
||||
AstNode sx = one(r, NodeType.STYLE, "HistorieDrawer.sx@9:14");
|
||||
assertEquals("sx", Objects.requireNonNull(sx.properties()).get("styleKind"));
|
||||
assertEquals("Box", Objects.requireNonNull(sx.properties()).get("element"));
|
||||
assertEquals("color,borderColor,height,&:hover.background,mt", Objects.requireNonNull(sx.properties()).get("properties"));
|
||||
assertEquals("17px", Objects.requireNonNull(sx.properties()).get("literals"));
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(drawer.id()) && e.targetId().equals(sx.id())));
|
||||
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(sx.id())).toList();
|
||||
// the fixture puts color and borderColor on separate lines (IDE reformat), so two edges; the
|
||||
// item-199 same-line grouping is pinned by StylesIT, whose inline fixture no formatter touches
|
||||
assertEquals(List.of("theme.PRIMARY[color]", "theme.PRIMARY[borderColor]", "theme.palette.background.paper[&:hover.background]"),
|
||||
refs.stream().map(e -> byId(r, e.targetId()).name() + "[" + prop(e, "property") + "]").toList());
|
||||
AstNode ph = byId(r, refs.get(0).targetId());
|
||||
assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder");
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("theme"));
|
||||
AstEdge prop = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(drawer.id())).findFirst().orElseThrow();
|
||||
assertEquals("theme.palette.primary.main", byId(r, prop.targetId()).name());
|
||||
assertEquals("borderColor", prop(prop, "context"));
|
||||
assertEquals("theme", prop(prop, "via"));
|
||||
ParseResult page = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode styled = one(page, NodeType.STYLE, "Panel.styled@14:15");
|
||||
AstNode panel = one(page, NodeType.FUNCTION, "Panel");
|
||||
assertTrue(page.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(panel.id()) && e.targetId().equals(styled.id())),
|
||||
"a styled block hangs under its styled FUNCTION");
|
||||
assertEquals("padding", Objects.requireNonNull(styled.properties()).get("properties"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Item 193: frontend URL templates normalised to the backend's base-less path shape.
|
||||
*/
|
||||
class TypeScriptRestPathsTest {
|
||||
|
||||
@Test
|
||||
void legacyTemplateWithQueryString() {
|
||||
TypeScriptRestPaths p = TypeScriptRestPaths.of("/agstamm/ui/search?vermnr={vermnr}&page={page}");
|
||||
assertEquals("", p.restBase());
|
||||
assertEquals("/agstamm/ui/search", p.restPath());
|
||||
assertEquals(List.of("vermnr", "page"), p.queryParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
void trailingSlashAndDoubleSlashesAreNormalised() {
|
||||
assertEquals("/agstamm/ui", TypeScriptRestPaths.of("/agstamm/ui/").restPath());
|
||||
assertEquals("/agstamm/ui/historie/{vermnr}", TypeScriptRestPaths.of("/agstamm/ui/historie//{vermnr}").restPath());
|
||||
assertEquals("/", TypeScriptRestPaths.of("/").restPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationBaseIsSplitOff() {
|
||||
TypeScriptRestPaths hey = TypeScriptRestPaths.of("/pur-r-vbuch/v1/account-overview/{agent-code}");
|
||||
assertEquals("/pur-r-vbuch/v1", hey.restBase());
|
||||
assertEquals("/account-overview/{agent-code}", hey.restPath());
|
||||
TypeScriptRestPaths bare = TypeScriptRestPaths.of("/pur-r-vstamm/v1");
|
||||
assertEquals("/pur-r-vstamm/v1", bare.restBase());
|
||||
assertEquals("/", bare.restPath());
|
||||
assertEquals("", TypeScriptRestPaths.of("/v1/x").restBase(), "a base needs a name segment before the version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedNamespaceQualifiersAreStripped() {
|
||||
assertEquals("SvcResult<AgstammUseCase>", TypeScriptRestPaths.unqualified("COMMON.SvcResult<API.AgstammUseCase>"));
|
||||
assertEquals("{ vermnr: number }", TypeScriptRestPaths.unqualified("{ vermnr: number }"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Runs the real sidecar when {@code node} and {@code sidecar/node_modules/typescript} are present
|
||||
* (skipped otherwise), and checks that what it prints for the fixture workspace equals the checked-in
|
||||
* contract document — the guard that keeps {@code facts-pur-r-vstamm.json} honest.
|
||||
*/
|
||||
class TypeScriptSidecarTest {
|
||||
|
||||
private static Path nodeBinary() {
|
||||
String pathEnv = System.getenv("PATH");
|
||||
if (pathEnv != null) {
|
||||
for (String dir : pathEnv.split(java.io.File.pathSeparator)) {
|
||||
Path candidate = Path.of(dir, "node");
|
||||
if (Files.isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Path.of("/usr/bin/node");
|
||||
}
|
||||
|
||||
private static TypeScriptSidecar sidecar() {
|
||||
return new TypeScriptSidecar(nodeBinary(), Path.of("sidecar/extract.mjs").toAbsolutePath(), 512, Duration.ofMinutes(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sidecarOutputMatchesTheCheckedInContractDocument() throws IOException {
|
||||
TypeScriptSidecar sidecar = sidecar();
|
||||
assumeTrue(sidecar.available(), "node + sidecar/node_modules/typescript not installed here");
|
||||
TypeScriptFacts live = sidecar.extract(Fixtures.ROOT, "pur-r-vstamm", null);
|
||||
TypeScriptFacts committed = TypeScriptFactsReaderTest.facts();
|
||||
assertEquals(committed.byFile().keySet(), live.byFile().keySet());
|
||||
for (String f : committed.byFile().keySet()) {
|
||||
assertEquals(committed.byFile().get(f), live.byFile().get(f), "facts drifted for " + f
|
||||
+ " — regenerate facts-pur-r-vstamm.json (see TypeScriptFactsReaderTest)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileFilterNarrowsTheDocument() {
|
||||
TypeScriptSidecar sidecar = sidecar();
|
||||
assumeTrue(sidecar.available());
|
||||
TypeScriptFacts f = sidecar.extract(Fixtures.ROOT, "pur-r-vstamm", List.of(Fixtures.PAGE));
|
||||
assertEquals(java.util.Set.of(Fixtures.PAGE), f.byFile().keySet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureSurfacesStderr() {
|
||||
TypeScriptSidecar sidecar = sidecar();
|
||||
assumeTrue(sidecar.available());
|
||||
TypeScriptSidecar.SidecarException e = assertThrows(TypeScriptSidecar.SidecarException.class,
|
||||
() -> sidecar.extract(Fixtures.ROOT, "no-such-workspace", null));
|
||||
assertTrue(String.valueOf(e.getMessage()).contains("no-such-workspace"), String.valueOf(e.getMessage()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableWhenTheScriptIsMissing() {
|
||||
assertFalse(new TypeScriptSidecar(nodeBinary(), Path.of("/nowhere/extract.mjs"), 512, Duration.ofSeconds(1)).available());
|
||||
try (Stream<Path> s = Stream.empty()) {
|
||||
assertNotNull(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {Box, Typography} from '@mui/material'
|
||||
import {styled} from '@mui/material/styles'
|
||||
import {SmartInput, theme} from 'pur-ui-common'
|
||||
import {useAgstamm} from 'store/hooks/useAgstamm'
|
||||
import {useAppSelector} from 'store/rv-redux-types'
|
||||
import {store} from 'store/store'
|
||||
import {resetIfIdle} from 'store/slices/agstammSlice'
|
||||
import {AgstammUseCaseField} from 'generated/agstamm-interfaces'
|
||||
import {HistorieDrawer} from './HistorieDrawer'
|
||||
|
||||
/* block comment
|
||||
spanning lines */
|
||||
const Panel = styled(Box)(({theme}) => ({
|
||||
padding: theme.spacing(2),
|
||||
}))
|
||||
|
||||
export const useVermnr = (): number => {
|
||||
const url = 'http://example/agstamm/ui' // not a comment
|
||||
return Number(url.length)
|
||||
}
|
||||
|
||||
export function AgstammPage() {
|
||||
const {agstammUseCaseSvcResult, dispatchLoadBrokerFromServer} = useAgstamm()
|
||||
const vermnr = useVermnr()
|
||||
const requestStatus = useAppSelector((state) => state.broker.requestStatus)
|
||||
const {agstammUseCaseSvcResult: fromStore} = useAppSelector((state) => state.broker)
|
||||
useEffect(() => {
|
||||
dispatchLoadBrokerFromServer(vermnr)
|
||||
if (store.getState().broker.requestStatus === 'idle') {
|
||||
store.dispatch(resetIfIdle())
|
||||
}
|
||||
}, [vermnr])
|
||||
return (
|
||||
<Panel sx={{mt: 1}}>
|
||||
{/* JSX comment */}
|
||||
<Typography style={{color: theme.palette.primary.dark}}>Agstamm</Typography>
|
||||
<SmartInput field={AgstammUseCaseField.broker.ebene} svcResult={agstammUseCaseSvcResult}/>
|
||||
<HistorieDrawer/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AgstammPage)
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useAgstammSelector} from 'store/hooks/useAgstamm'
|
||||
import {PRIMARY} from 'rvTheme'
|
||||
import {Box, useTheme} from '@mui/material'
|
||||
|
||||
export function HistorieDrawer() {
|
||||
const purMode = useAgstammSelector((useCase) => useCase?.result?.purMode)
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Box sx={{
|
||||
color: PRIMARY,
|
||||
borderColor: PRIMARY,
|
||||
height: '17px',
|
||||
'&:hover': {background: theme.palette.background.paper},
|
||||
mt: 2
|
||||
}} borderColor={theme.palette.primary.main}>
|
||||
Historie {purMode}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Generated by EndpointGenerator in Pur-Devtools
|
||||
*/
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
import * as COMMON from 'pur-ui-common';
|
||||
import * as API from './agstamm-interfaces';
|
||||
|
||||
interface Endpoint {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface GetMethodWithParameters<R, P> {
|
||||
get: (params: P) => Promise<R>;
|
||||
}
|
||||
|
||||
interface PostMethod<R, B> {
|
||||
post: (body: B) => Promise<R>;
|
||||
}
|
||||
|
||||
export class AgstammControllerEndpoint implements Endpoint {
|
||||
baseUrl: string = '/agstamm/ui/';
|
||||
public searchBroker: GetMethodWithParameters<COMMON.SvcResult<API.AgstammUseCase>, { vermnr: number }> = {
|
||||
get: (params) => {
|
||||
return COMMON.executeGetRequest(COMMON.buildPurURL(`${this.baseUrl}search?vermnr=${params.vermnr}`));
|
||||
},
|
||||
};
|
||||
public saveBroker: PostMethod<COMMON.SvcResult<API.AgstammUseCase>, API.AgstammUseCase> = {
|
||||
post: (body) => {
|
||||
return COMMON.executePostRequest(COMMON.buildPurURL(`${this.baseUrl}`), body);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AgstammAction = 'ADD' | 'UPDATE';
|
||||
|
||||
export enum AgstammKind { A, B }
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Generated by EndpointGenerator in Pur-Devtools
|
||||
*/
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
export interface Broker {
|
||||
vermnr: number;
|
||||
ebene: string;
|
||||
}
|
||||
|
||||
export interface AgstammUseCase {
|
||||
broker: Broker;
|
||||
brokerName: string;
|
||||
}
|
||||
|
||||
export class Fields<TRoot, TSelf> {
|
||||
$$parent?: Fields<TRoot, any>;
|
||||
$$name: string = '';
|
||||
|
||||
get(): string {
|
||||
return this.$$parent?.get() ? this.$$parent.get() + '.' + this.$$name : this.$$name;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrokerFields<TRoot, TSelf> extends Fields<TRoot, TSelf> {
|
||||
vermnr: Fields<TRoot, number> = new Fields();
|
||||
ebene: Fields<TRoot, string> = new Fields();
|
||||
}
|
||||
|
||||
export class AgstammUseCaseFields<TRoot, TSelf> extends Fields<TRoot, TSelf> {
|
||||
broker: BrokerFields<TRoot, Broker> = new BrokerFields();
|
||||
brokerName: Fields<TRoot, string> = new Fields();
|
||||
}
|
||||
|
||||
export const AgstammUseCaseField: AgstammUseCaseFields<AgstammUseCase, never> = new AgstammUseCaseFields();
|
||||
@@ -0,0 +1 @@
|
||||
export const client = {get: <R, E, T>(opts: { url: string }): Promise<R> => Promise.reject(opts.url) as Promise<R>};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type TDataShape = { path?: Record<string, unknown> };
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = TData & {
|
||||
throwOnError?: ThrowOnError
|
||||
};
|
||||
export type RequestResult<TData, TError, ThrowOnError extends boolean> = Promise<{
|
||||
data: TData;
|
||||
error: TError;
|
||||
throwOnError: ThrowOnError
|
||||
}>;
|
||||
@@ -0,0 +1,22 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type {Options as Options2, RequestResult, TDataShape} from './client';
|
||||
import {client} from './client.gen';
|
||||
import type {
|
||||
GetPurRVstammV1AgstammUiHistorieByVermnrData,
|
||||
GetPurRVstammV1AgstammUiHistorieByVermnrResponses
|
||||
} from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> =
|
||||
Options2<TData, ThrowOnError>
|
||||
& { client?: unknown };
|
||||
|
||||
/**
|
||||
* Historie eines Vermittlers
|
||||
*/
|
||||
export const getPurRVstammV1AgstammUiHistorieByVermnr = <ThrowOnError extends boolean = false>(options: Options<GetPurRVstammV1AgstammUiHistorieByVermnrData, ThrowOnError>): RequestResult<GetPurRVstammV1AgstammUiHistorieByVermnrResponses, unknown, ThrowOnError> => {
|
||||
return (options.client ?? client).get<GetPurRVstammV1AgstammUiHistorieByVermnrResponses, unknown, ThrowOnError>({
|
||||
url: '/pur-r-vstamm/v1/agstamm/ui/historie/{vermnr}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type GetPurRVstammV1AgstammUiHistorieByVermnrData = { path: { vermnr: number } };
|
||||
export type GetPurRVstammV1AgstammUiHistorieByVermnrResponses = { 200: { result?: { eintraege: string[] } } };
|
||||
@@ -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'},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import {loadBrokerFromServer, selectAgstamm} from 'store/slices/agstammSlice'
|
||||
import {useAppSelector} from 'store/rv-redux-types'
|
||||
import type {SvcResult} from 'pur-ui-common'
|
||||
import type {AgstammUseCase} from 'generated/agstamm-interfaces'
|
||||
|
||||
export const useAgstamm = () => {
|
||||
const agstammUseCaseSvcResult = selectAgstamm({
|
||||
broker: {
|
||||
agstammUseCaseSvcResult: undefined,
|
||||
requestStatus: 'idle'
|
||||
}
|
||||
} as any)
|
||||
const dispatchLoadBrokerFromServer = (vermnr: number) => loadBrokerFromServer(vermnr)
|
||||
return {agstammUseCaseSvcResult, dispatchLoadBrokerFromServer}
|
||||
}
|
||||
|
||||
export const useAgstammSelector = <R>(selector: (useCase: SvcResult<AgstammUseCase> | undefined) => R): R => {
|
||||
return useAppSelector((state) => selector(state.broker.agstammUseCaseSvcResult))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {useSelector} from 'react-redux'
|
||||
import type {RootState} from 'store/store'
|
||||
|
||||
export interface RvRootState {
|
||||
broker: { agstammUseCaseSvcResult?: unknown; requestStatus: string }
|
||||
}
|
||||
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||
@@ -0,0 +1,63 @@
|
||||
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
|
||||
import {AgstammUseCase} from 'generated/agstamm-interfaces'
|
||||
import {AgstammControllerEndpoint} from 'generated/agstamm-endpoints'
|
||||
import {createAppAsyncThunk, RequestStatus, SvcResult} from 'pur-ui-common'
|
||||
import type {RvRootState} from '../rv-redux-types'
|
||||
import {getPurRVstammV1AgstammUiHistorieByVermnr} from 'generated/client/sdk.gen'
|
||||
|
||||
export interface AgstammState {
|
||||
requestStatus: RequestStatus
|
||||
agstammUseCaseSvcResult?: SvcResult<AgstammUseCase>
|
||||
}
|
||||
|
||||
const initialState: AgstammState = {requestStatus: 'idle'}
|
||||
|
||||
const agstammSlice = createSlice({
|
||||
name: 'agstamm',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateAgstammUseCaseSvcResult(state, action: PayloadAction<SvcResult<AgstammUseCase> | undefined>) {
|
||||
state.agstammUseCaseSvcResult = action.payload // a "//" inside a comment is fine
|
||||
},
|
||||
resetIfIdle(state) {
|
||||
if (state.requestStatus === 'idle') {
|
||||
state.agstammUseCaseSvcResult = undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loadBrokerFromServer.fulfilled, (state, action) => {
|
||||
state.requestStatus = 'ok'
|
||||
state.agstammUseCaseSvcResult = action.payload
|
||||
})
|
||||
builder.addMatcher((action) => action.type.endsWith('/rejected'), (state) => {
|
||||
state.requestStatus = 'error'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const agstammUiApi = new AgstammControllerEndpoint()
|
||||
|
||||
export const loadBrokerFromServer = createAppAsyncThunk<SvcResult<AgstammUseCase>, number>(
|
||||
`agstamm/loadBroker`,
|
||||
(vermnr) => agstammUiApi.searchBroker.get({vermnr: String(vermnr)}),
|
||||
)
|
||||
|
||||
export const saveBrokerToServer = createAppAsyncThunk<SvcResult<AgstammUseCase>, AgstammUseCase>(
|
||||
'agstamm/saveBroker',
|
||||
(useCase: AgstammUseCase) => agstammUiApi.saveBroker.post(useCase),
|
||||
)
|
||||
|
||||
export const selectAgstamm = (state: RvRootState) => state.broker.agstammUseCaseSvcResult
|
||||
|
||||
export const loadHistorie = createAppAsyncThunk<unknown, number>(
|
||||
'agstamm/loadHistorie',
|
||||
async (vermnr) => (await getPurRVstammV1AgstammUiHistorieByVermnr({path: {vermnr}})).data,
|
||||
)
|
||||
|
||||
function statusOf(state: AgstammState): RequestStatus {
|
||||
return state.requestStatus
|
||||
}
|
||||
|
||||
export const {updateAgstammUseCaseSvcResult, resetIfIdle} = agstammSlice.actions
|
||||
export default agstammSlice.reducer
|
||||
@@ -0,0 +1,10 @@
|
||||
import {configureStore} from '@reduxjs/toolkit'
|
||||
import agstammReducer from 'store/slices/agstammSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
broker: agstammReducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
@@ -0,0 +1,9 @@
|
||||
/* fonts */
|
||||
@font-face {
|
||||
font-family: 'OpenSans';
|
||||
src: url('/fonts/OpenSans.woff2') format('woff2'); /* not a comment: */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -17,6 +17,12 @@ services:
|
||||
# generic placeholder resolution exceeded on K3, failing the refresh at step 2 of 38 with
|
||||
# "allocation of an extra 2.0 MiB would use more than the limit 1.4 GiB". The parse and persist
|
||||
# phases were unaffected; it is one enrichment transaction that outgrew the cap.
|
||||
# 2G -> 4G (2026-09-23): a deep ingest of upms2 (6311 Natural files) failed at batch 1601-1800
|
||||
# with "allocation of an extra 34.2 MiB would use more than the limit 1.4 GiB ...
|
||||
# dbms.memory.transaction.total.max threshold reached". That limit defaults to 70% of the heap,
|
||||
# and one 200-file persist transaction now writes ~64k nodes / ~159k relationships (item 160
|
||||
# positional nodes, copycode copies), which no longer fits in 1.4 GiB. 4G lifts the ceiling to
|
||||
# 2.8 GiB. The idle-heap concern above still holds: G1PeriodicGCInterval returns it.
|
||||
NEO4J_server_memory_heap_max__size: "4G"
|
||||
NEO4J_server_memory_heap_initial__size: "512M"
|
||||
# 512M -> 1G -> 3G -> 2G. The store has grown to 2.4 GB (1.2 GB of it range indexes), so 1G
|
||||
@@ -46,6 +52,7 @@ services:
|
||||
# 6g -> 8g, following the heap: Neo4j's rule of thumb is heap + page cache + ~1G, so 4G + 2G + 1G
|
||||
# with a gigabyte of reserve. An OOM-kill mid-refresh leaves the graph half-updated, which is far
|
||||
# worse than a spare GB. Do not raise the heap or the page cache without raising this too.
|
||||
# raising this too. 6g -> 8g with the heap raise to 4G (same rule: 4G heap + 2G cache + reserve).
|
||||
mem_limit: 8g
|
||||
volumes:
|
||||
- neo4j-data:/data
|
||||
@@ -58,8 +65,10 @@ services:
|
||||
|
||||
ac-code-server:
|
||||
build:
|
||||
context: ./ac-code-server
|
||||
dockerfile: src/main/docker/Dockerfile.jvm
|
||||
# Repository root, not ./ac-code-server: the image also carries the TypeScript sidecar from
|
||||
# ac-parser-typescript/sidecar (item 192). The root .dockerignore limits what is sent.
|
||||
context: .
|
||||
dockerfile: ac-code-server/src/main/docker/Dockerfile.jvm
|
||||
container_name: agenticcode-server
|
||||
ports:
|
||||
- "8787:8787"
|
||||
@@ -82,9 +91,12 @@ services:
|
||||
-Xmx2500m
|
||||
-XX:G1PeriodicGCInterval=300000
|
||||
-Djava.util.logging.manager=org.jboss.logmanager.LogManager
|
||||
# Heap 2G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
|
||||
# heap cap, i.e. ~1.9 GB non-heap, so the ceiling stays at 4g even though the heap shrank.
|
||||
mem_limit: 4g
|
||||
# Heap 2.5G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
|
||||
# heap cap, i.e. ~1.9 GB non-heap. Item 192 adds the TypeScript sidecar, a Node process the JVM
|
||||
# runs per npm workspace during a deep pass: measured ~0.75 GB RSS per workspace, capped at
|
||||
# --max-old-space-size=1024 and run before the JVM's own persist/enrichment peak — so 4g would
|
||||
# have been tight only if the two peaks coincided; 5g leaves room for that case.
|
||||
mem_limit: 5g
|
||||
volumes:
|
||||
# Mounted at the same absolute host path so project roots registered via
|
||||
# the API (which store absolute host paths) resolve inside the container too.
|
||||
@@ -97,6 +109,9 @@ services:
|
||||
- /home/ingo/deve/K3:/home/ingo/deve/K3:ro
|
||||
- /home/ingo/deve/uniqa/uniqa-upms-app:/home/ingo/deve/uniqa/uniqa-upms-app:ro
|
||||
- /home/ingo/deve/uniqa/pur-sources/backend:/home/ingo/deve/uniqa/pur-sources/backend:ro
|
||||
# Item 192: the pur frontend (project `purfe`, language typescript). Read-only like the others;
|
||||
# the sidecar runs with noEmit and reads the frontend's own node_modules for library typings.
|
||||
- /home/ingo/deve/uniqa/pur-sources/frontend:/home/ingo/deve/uniqa/pur-sources/frontend:ro
|
||||
- /home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:ro
|
||||
depends_on:
|
||||
neo4j:
|
||||
|
||||
10
pom.xml
10
pom.xml
@@ -10,7 +10,9 @@
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>AgenticCode</name>
|
||||
<description>Parsing, storing, and agentically querying source code (Natural/Software AG and Java)</description>
|
||||
<description>Parsing, storing, and agentically querying source code (Natural/Software AG, Java and
|
||||
TypeScript/React)
|
||||
</description>
|
||||
|
||||
<modules>
|
||||
<module>ac-mvn-plugins</module>
|
||||
@@ -19,6 +21,7 @@
|
||||
<module>ac-parser-natural</module>
|
||||
<module>ac-parser-java</module>
|
||||
<module>ac-parser-natjav</module>
|
||||
<module>ac-parser-typescript</module>
|
||||
<module>ac-neo4j-store</module>
|
||||
<module>ac-code-server</module>
|
||||
<module>ac-cli</module>
|
||||
@@ -80,6 +83,11 @@
|
||||
<artifactId>ac-parser-natjav</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-neo4j-store</artifactId>
|
||||
|
||||
@@ -230,8 +230,44 @@ GET /modules/{name}/functions?includeInherited=&kind={abstract|final|overridable
|
||||
GET /modules/{name}/functions/{fn}/overrides → one hook's subclass overrides
|
||||
GET /modules/{name}/functions/overrides → bulk: every abstract method's overrides (adds `method`)
|
||||
GET /modules/{name}/dispatch-table → [{ guardField, guardValue, assignedField, assignedValue, lineNo }]
|
||||
GET /rest-endpoints?module= → [{ httpMethod, path, module, handler, outbound }] (outbound=true: a call this project MAKES — a TypeScript generated client, item 193)
|
||||
GET /counterparts?module=&kind=rest|dto|field&unmatched=
|
||||
→ [{ kind, name, module, sourceFile, startLine, httpMethod, path, counterpartProject, counterpartName, counterpartModule, counterpartSourceFile, counterpartStartLine }]
|
||||
GET /store?slice= → [{ slice, sliceName, module, sourceFile, stateType, fields: [{ name, type, optional, reads, writes }], reducers, reads, writes }] (item 194: the Redux store, one row per slice = reducer key)
|
||||
GET /store/{slice}/accesses?field=&mode=reads|writes&module=
|
||||
→ [{ mode, slice, field, path, function, functionType, functionKind, module, sourceFile, lineNo, via }] (reducers write, components/hooks read; via = useAppSelector | wrapper hook | getState | reducer)
|
||||
GET /bindings?dto=&field=&mode=reads|writes&module=&partial=
|
||||
→ [{ mode, dto, field, path, rootDto, kind, partial, component, attribute, function, functionType, functionKind, module, sourceFile, lineNo, counterpartProject, counterpartModule, counterpartField }] (item 195: DTO field bindings via generated Fields path objects; counterpart* = the backend field)
|
||||
GET /theme?unused= → [{ token, kind, value, constant, declared, module, lineNo, uses }] (item 196: MUI theme tokens; declared=false = read but declared nowhere; uses = project references only)
|
||||
GET /theme/{token}/usages → [{ function, functionKind, module, sourceFile, lineNo, styleKind, element, property, context }]
|
||||
GET /styles?module=&kind=sx|style|styled|css&withLiterals=
|
||||
→ [{ name, styleKind, element, selector, function, module, sourceFile, lineNo, properties, literals, dynamic, tokens }] (item 196: style inventory; withLiterals=true = blocks that hard-code colours/lengths)
|
||||
```
|
||||
|
||||
`counterparts` (item 193) needs the project setting `counterparts: [<other project>]`: a frontend's
|
||||
web-service calls are linked to the backend handlers serving them (verb + path shape), its generated
|
||||
DTOs to the Java classes of the same simple name, their fields by name. `unmatched=true` lists what
|
||||
has no twin — the planning question. Paged like the search endpoints.
|
||||
|
||||
`store` (item 194, TypeScript projects): a slice is named by its reducer key (`state.<slice>`), its
|
||||
state keys are `FIELD`s named `<slice>.<field>` — so `variables/<slice>.<field>/reads|writes` works
|
||||
too — and every reducer is a `FUNCTION` of `kind=reducer` named by the action type it handles
|
||||
(`schluesseltabelle/suche/fulfilled`). Consumer reads through selectors, wrapper hooks and
|
||||
`getState()` are resolved onto the slice's own fields across files; `path` is the full sub-path read.
|
||||
|
||||
`bindings` (item 195, TypeScript projects): `<SmartInput field={AgstammUseCaseField.broker.ebene}>`
|
||||
is a WRITES (input components) or READS binding of the generated interface field `Broker.ebene`
|
||||
(`dto` = the declaring interface, `rootDto` = where the path starts, `path` = `broker.ebene`);
|
||||
`counterpart*` is the Java field it mirrors, so "which page edits Java Broker.ebene" is
|
||||
`bindings?dto=Broker&field=ebene&mode=writes` on the frontend project. `partial=true` = rooted at a
|
||||
prop, only the tail of the path is known; `kind=prefix` = a sub-object handed on, not a scalar leaf.
|
||||
|
||||
`theme` / `styles` (item 196, TypeScript projects): every `sx`/`style`/`styled` block is a `STYLE`
|
||||
node under its component with its CSS keys, hard-coded literals and the theme tokens it reads; the
|
||||
theme's tokens are `FIELD`s `theme.<path>` with values. "Where is token X used" =
|
||||
`theme/<token>/usages`; "what bypasses the theme" = `styles?withLiterals=true`; `unused=true` on
|
||||
`theme` means no project reference, not dead (MUI consumes tokens itself).
|
||||
|
||||
`kind` is null for Natural subroutines and Java constructors; `kind=` filters by Java modifier
|
||||
(source-derived). `includeInherited=true` adds ancestor methods, each tagged `declaredIn`.
|
||||
`viaCopycode=true` ⇒ Natural subroutine from an INCLUDEd copycode, so its lines are in `sourceFile`
|
||||
|
||||
@@ -83,11 +83,12 @@ reengineered"* — which it answered for services reengineered months earlier.
|
||||
|
||||
Three routes now reach comment text. Pick one before concluding "not present":
|
||||
|
||||
| Question | Call |
|
||||
|---|---|
|
||||
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
|
||||
| "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` |
|
||||
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk |
|
||||
| Question | Call |
|
||||
|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| "What does this module's header/change log say?" | `GET /modules/{name}/comments` (`ac comments <module>`) — blocks with the declaration each documents |
|
||||
| "Where is this query / SQL / JSON text defined?" (Java) | `GET /search/value?value=…&contains=true` — since item 139 a `static final String` built from text blocks, literals, same-class constants and `+` carries its full text as `value`; a `"…".formatted(...)` constant carries its template (with `%s`) and `valueKind: template`. Anything built from a method call or another class's constant stays unresolved (no value) |
|
||||
| "Does this string appear anywhere, code **or** comment?" | `GET /search/value?value=…&includeComments=true` (`ac search-value --include-comments`) — comment hits carry `kind: "COMMENT"` |
|
||||
| "…and in text the parsers do not model at all, or in a module that is not deeply ingested?" | `GET /search/source?regex=…` — raw grep over the files on disk |
|
||||
|
||||
```
|
||||
GET /pur/search/value?value=WPARTX0S&contains=true → [] (code only)
|
||||
@@ -175,6 +176,16 @@ or `/context` when you want an overview rather than an enumeration.
|
||||
as they stand, and a `limit` that ever acquired a default would reintroduce exactly the silent
|
||||
truncation item 131 removed.)*
|
||||
|
||||
## `callers` / `callees` say when they are cut (item 181)
|
||||
|
||||
`callers`, `callees` and `functions/{fn}/callers` now send `X-AC-Total-Count` and `X-AC-Truncated`
|
||||
like the search endpoints, and their body carries `total` and `truncated` next to `sourceFiles` /
|
||||
`items` (also for `fields=name`; `ac callers` / `ac callees` print the usual "truncated" warning).
|
||||
The default page is still 50: `upms/modules/DPARTFN0/callees` answers 50 of 58 with
|
||||
`X-AC-Truncated: true` — before, the 8 missing callees (among them the module that writes the
|
||||
partner) looked like an inconsistency with `digest`. **Read `truncated` before concluding "X does not
|
||||
call Y"**; ask with `limit=1000` or narrow with `scope=external`.
|
||||
|
||||
## Truncation is now visible on the search endpoints (item 131)
|
||||
|
||||
`search/identifier`, `search/value`, `search/annotation`, `search/references` and
|
||||
@@ -314,6 +325,17 @@ already ran are committed, so queries keep answering, just from a half-updated g
|
||||
self-heal (a killed process clears nothing) and does not distinguish "running right now" from "died an
|
||||
hour ago" — both mean the same thing to a caller. A completed refresh clears it.
|
||||
|
||||
## Renaming a project (item 202)
|
||||
|
||||
`POST /api/projects/{p}/rename` with `{"newName": "…"}` (CLI `ac project rename <old> <new>`)
|
||||
moves the project key on every node and override and in other projects' `counterparts` lists, then
|
||||
the shell; the old name answers `404` afterwards and nothing needs re-ingesting. Refusals:
|
||||
`400 INVALID_REQUEST` (blank or unchanged), `404`, `409 PROJECT_EXISTS`. The node rewrite is batched;
|
||||
an interrupted rename is finished by running it again (97 s for the 939k-node `upms`). Use it to
|
||||
keep a reference graph next to a fresh ingest (`ac project rename upms upms_alt`, then create `upms`
|
||||
again and compare). A full `DELETE` of a project now also removes its manual overrides; `recreate`
|
||||
keeps them.
|
||||
|
||||
## Is this project's graph any good? (item 126)
|
||||
|
||||
`GET /api/projects` and `GET /api/projects/{p}` (CLI `ac project list` / `ac project show <p>`)
|
||||
@@ -498,7 +520,10 @@ pinned target replaces it. `DELETE .../dynamic-calls/overrides?originFile=&lineN
|
||||
resets one site (omit both = all), clearing the manual edges and un-hiding the placeholder inline (no
|
||||
refresh). A target that is not a real `MODULE` is rejected `400 UNKNOWN_TARGET`. **Bug B fix:** the
|
||||
`callees` items now carry `unresolved` (mirroring what `graph` already exposed), so an unresolved
|
||||
dynamic target is machine-distinguishable from a resolved one without inspecting `sourceFile`.
|
||||
dynamic target is machine-distinguishable from a resolved one without inspecting `sourceFile`. Since item 200 the apply
|
||||
and the reset also rebuild the calling
|
||||
modules' derived `CALLS_MODULE` edges in the same transaction, so `reaches` and `field-flow` honour
|
||||
a pinned target without a refresh, like `callees` and `call-tree` already did.
|
||||
|
||||
**Dispatch guards: read `guards` — it is the only complete condition (item 72).** A `dispatch-table` row's
|
||||
`guardField`/`guardValue`/`guardValues` describe the **innermost** `DECIDE` only. Natural nests
|
||||
@@ -1240,10 +1265,16 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
|
||||
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
|
||||
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
|
||||
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
|
||||
| `GET /counterparts?module=&kind=&unmatched=` (`ac counterparts`) | **Item 193:** this project's web-service calls / generated DTOs / fields with their twin in the counterpart project; `unmatched=true` = what nothing serves or mirrors yet |
|
||||
| `GET /store?slice=` (`ac store`) | **Item 194:** the frontend Redux store — one row per slice (reducer key, RTK `sliceName`, `stateType`, the top-level state keys with type/optional/read/write counts, reducer count, total access sites) |
|
||||
| `GET /store/{slice}/accesses?field=&mode=reads\|writes&module=` (`ac store-accesses`) | **Item 194:** who reads/writes a slice — reducers (`functionKind=reducer`, `via=reducer`) and the components/hooks/thunks selecting from it (`via` = `useAppSelector`, a wrapper hook, `getState`), with the full sub-`path` and line. Store fields also answer `variables/<slice>.<field>/reads\|writes` |
|
||||
| `GET /bindings?dto=&field=&mode=reads\|writes&module=&partial=` (`ac bindings`) | **Item 195:** which component reads/writes which DTO field through the generated `Fields` path objects (`<SmartInput field={X.broker.ebene}>`), with the field's backend counterpart — `--dto Broker --field ebene --mode writes` = which page edits Java `Broker.ebene`. `data-structures/{dto}/fields` carries `boundReads`/`boundWrites` |
|
||||
| `GET /theme?unused=` · `GET /theme/{token}/usages` (`ac theme`, `ac theme-usages`) | **Item 196:** the MUI theme's tokens (createTheme leaves + theme constants, value, `uses`; `declared=false` = read by the code but declared by no theme) and where one token is read (style block + CSS `property`, or plain `context`) |
|
||||
| `GET /styles?module=&kind=sx\|style\|styled\|css&withLiterals=` (`ac styles`) | **Item 196:** the style inventory — every `sx`/`style`/`styled` block and CSS rule with CSS keys, hard-coded `literals` and the theme `tokens` it reads; `withLiterals=true` = what bypasses the theme |
|
||||
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. **All three see code only by default — an empty result is not evidence that the string is absent.** `search/value` takes **`includeComments=true`** (CLI `--include-comments`, item 141) to search comment blocks as well; those hits come back as `kind: "COMMENT"`, so a comment is never read as code. It is opt-in because a comment hit is different evidence from a literal, and folding it in silently would move every existing completeness count (item 131). `search/identifier` and `search/annotation` never match comments at all — use `includeComments`, `/modules/{name}/comments` or `/search/source` before concluding "not present" (see "Comments: reachable, but never by default" above). `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). **Item 125:** a Java **type declaration** is matched by its **short name** as well as by the fully-qualified identity the graph stores (item 117) — `name=PartnerUpdateLogic` finds `com.example.PartnerUpdateLogic`; before this it answered `[]`, which reads as "no such name". Every match carries `simpleName` and `moduleKind` (`CLASS`/`INTERFACE`/`ENUM`/`RECORD`, `PROGRAM`/`SUBPROGRAM` for Natural), both `null` for non-`MODULE` hits — so "is this name a type or a method?" needs no second call. `contains=true` (CLI `--contains`) switches to a case-insensitive **substring** match, as on `/search/value`; it was previously accepted and silently dropped. It matches the FQN too, so a package fragment also hits — filter with `type=MODULE`/`moduleKind` if that is noise. `contains` without a `name` is `400 MISSING_NAME` (a substring search for nothing is a full node dump). The substring scan is **unindexed**: it is bounded to `offset+limit` rows, so keep a `limit` on large projects. Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type --contains` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
|
||||
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
|
||||
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
|
||||
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
|
||||
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
|
||||
|
||||
Full endpoint list, request params, and response field details:
|
||||
`x-docs/agent-api-system-prompt.md`.
|
||||
@@ -1287,6 +1318,349 @@ What it is **not**: a substitute for `mvn test`. The integration tests pin seman
|
||||
"the deployed thing is not obviously broken". A green run is not a quality gate. All assertions are
|
||||
invariants, never fixed counts — counts move with every refresh.
|
||||
|
||||
## TypeScript / React projects (item 192)
|
||||
|
||||
A project may declare `language: typescript` (`ac project create purfe --root … --language typescript`).
|
||||
Its `.ts`/`.tsx` files (not `.d.ts`) and plain `.css` files are ingested; `node_modules` and `dist`
|
||||
are excluded by default. **Only a `typescript` project ingests TypeScript** — a Java project with a
|
||||
bundled web UI (`ac` has `ac-ui/`) never parses it. Java and Natural files stay language-agnostic.
|
||||
|
||||
**Identities are paths.** A TypeScript `MODULE` is named by its root-relative path without the script
|
||||
extension — `pur-r-vstamm/src/store/slices/agstammSlice` — with `simpleName` = the file stem
|
||||
(`agstammSlice`), `workspace` = the first path segment, `moduleKind` = `ts`/`tsx`/`css`, and
|
||||
`generated=true` + `generator` (`typescript-generator` for the Java-side EndpointGenerator output
|
||||
under `generated/`, `hey-api` for `@hey-api/openapi-ts`). A CSS module keeps its extension
|
||||
(`pur-ui/src/index.css`). Every `/modules/{name}/…` endpoint accepts either form (item 117), so
|
||||
`ac context agstammSlice` works — until two workspaces have a file with the same stem, then use the
|
||||
path.
|
||||
|
||||
**Two tiers, like Java/Natural.** Project creation and `refresh` without `--deep` run the **Tier-1
|
||||
regex outline** in Java: module shell (`sourceHash`, `loc`/`sloc`), one `FUNCTION` per top-level
|
||||
function / arrow / class (`kind` = `function` | `component` | `hook` | `thunk` | `styled` | `class`,
|
||||
`exported`), one `DATA_STRUCTURE` per `interface`/`type`/`enum` (`dataType` says which), and a
|
||||
`REFERENCES` edge per import to the module it resolves to (`value` = the import clause, `specifier`
|
||||
= as written). npm packages are not placeholders; they are listed on the module as
|
||||
`externalImports`. **A deep pass** (`refresh --deep`, `ingest` by name) runs the **Node sidecar**
|
||||
(`ac-parser-typescript/sidecar/extract.mjs`, TypeScript compiler API, one whole-program run per npm
|
||||
workspace, 3–5 s and ~0.5 GB each on the pur frontend) and replaces the outline with the checker's
|
||||
view: exact positions, imports resolved against the file system, and **calls**:
|
||||
|
||||
- a callee owned by a top-level declaration of the same file → `FUNCTION -CALLS-> FUNCTION`;
|
||||
- a callee in another module → `MODULE -CALLS-> MODULE` carrying `callKind` (`METHOD_CALL`, or
|
||||
`CONSTRUCTOR` for `new`), `callerFn` (the calling function, absent at module level), `calleeMethod`
|
||||
(the owning top-level declaration in the target), `callSyntax` (`call`/`new`/`tagged`/`jsx` — a JSX
|
||||
element `<HistorieDrawer/>` is a call), and on a member call `receiver` (type of the innermost
|
||||
object, e.g. `AgstammControllerEndpoint`) and `member` (`saveBroker.post`);
|
||||
- calls into npm packages and the language library are **not** edges.
|
||||
|
||||
These are the exact properties the Java parser writes, so `callers`/`callees`/`call-tree`,
|
||||
`functions/{fn}/callers` (item 52) and the placeholder rewiring work unchanged. A module that got its
|
||||
Tier-2 pass carries `ingestTier=2`.
|
||||
|
||||
**Sidecar failure is visible, not silent.** If `node`, the script or its `node_modules/typescript`
|
||||
are missing, or a workspace run fails or times out, the refresh still completes at Tier-1 for those
|
||||
files and the response lists a failure with the pseudo-path `sidecar` or `sidecar:<workspace>`.
|
||||
Config: `agenticcode.typescript.node`, `.sidecar-script`, `.max-heap-mb` (1024), `.timeout-seconds`
|
||||
(600); the image carries node and the sidecar (`Dockerfile.jvm`), dev mode expects
|
||||
`npm ci` run once in `ac-parser-typescript/sidecar/`. The project root is read-only in the
|
||||
container; the sidecar reads the project's own `node_modules` for library typings and writes nothing.
|
||||
|
||||
**Scope of the pur frontend project.** The registered project covers the `pur-ui` and
|
||||
`pur-ui-common` workspaces only; `pur-r-vstamm` and `pur-r-vbuch` are excluded via `excludeDirs`,
|
||||
and the sidecar does not load an excluded workspace. Both workspaces call the `pur` backend through
|
||||
the legacy generated client (`generated/endpoints.ts`, backend `pur`); the hey-api client shape is
|
||||
recognised too but is not in scope.
|
||||
|
||||
**Resolution notes (verified on `purfe`, 2026-09-22).** An import of a workspace consumed through
|
||||
its package.json `exports` resolves into its build output (`pur-ui-common/dist/x.d.ts`); the sidecar
|
||||
maps that to the source twin (`pur-ui-common/src/x.ts`) so the edge lands on a real module. A bare
|
||||
specifier the checker resolves to neither a file nor a package (`immer`, `redux` — transitive
|
||||
dependencies the project does not list) is recorded as an external import, not a placeholder.
|
||||
|
||||
Transitive packages are known from `root/node_modules` (directory names), so Tier-1 treats them
|
||||
as external too.
|
||||
|
||||
**Stale parsed edges are reaped on a deep refresh (item 198).** Every edge the parser emits is
|
||||
stamped with the run's `ingestGen` at merge time; after a deep re-parse of a file, the edges from
|
||||
that file's nodes whose stamp is older than the run's (the fresh parse did not re-emit them) are
|
||||
deleted before the node sweep, for every language and edge type. An import or call the new parse
|
||||
names differently (a renamed class, a `dist`→`src` mapping fix) therefore no longer keeps its old
|
||||
placeholder alive next to the fresh edge, and a placeholder left edgeless falls to the usual
|
||||
placeholder sweep. Tier-1 (`changedOnly` or non-deep) refreshes do not reap, because a Tier-1 pass
|
||||
emits fewer edges than a deep one. Edges persisted before the stamp was introduced carry no
|
||||
generation and are never reaped; the first deep refresh after upgrading stamps them, the next one
|
||||
reaps — so a project never needs recreating after a parser change any more, two deep refreshes do.
|
||||
|
||||
**A module node never lives in a copycode (item 201).** A program whose body is a single `INCLUDE`
|
||||
used to get a second `MODULE` node named after it with the `.cpy` as `sourceFile` (`ZDTSTBP6` in
|
||||
`upms`), so `modules` and `search/identifier` listed the name twice. Fixed in the parser; a graph
|
||||
ingested before the fix keeps the stray node until the project is recreated, or you remove it by hand:
|
||||
`MATCH (m:MODULE {project: $p}) WHERE m.sourceFile ENDS WITH '.cpy' AND m.ownerModule = '' DETACH DELETE m`
|
||||
(Natural copycodes are never modules of their own, so the match is exact).
|
||||
|
||||
**Known limits of 192** (the later items fill them): no field bindings (195), no styles (196);
|
||||
the store is item 194 below. A `changedOnly` refresh re-runs the sidecar over the
|
||||
whole workspace but re-persists only the changed files, so an unchanged file's facts can lag one
|
||||
refresh (same class of caveat as 46a). The by-name deep ingest resolves dependencies by file stem, so
|
||||
a dependency whose stem exists in several workspaces (`index`) is reported as a duplicate and skipped
|
||||
— use `refresh --deep` for the whole frontend.
|
||||
|
||||
## Web-service calls and the counterpart link (item 193)
|
||||
|
||||
**`rest-endpoints` lists the frontend's calls.** Every member of a generated Endpoint class
|
||||
(`AgstammControllerEndpoint.saveBroker`) and every hey-api sdk function is a `FUNCTION` of
|
||||
`kind=endpoint` carrying the same `restPath`/`httpMethod` the Java parser writes for a handler, plus
|
||||
`outbound=true` — so `GET /projects/purfe/rest-endpoints` answers with `outbound: true` rows
|
||||
(`handler` = `AgstammControllerEndpoint.saveBroker`, `path` = `/agstamm/ui`). Who calls it:
|
||||
`modules/{generated module}/callers` names the calling slices/components (module level), and the
|
||||
member call `api.saveBroker.post(...)` is retargeted from the generic `PostMethod.post` signature to
|
||||
the endpoint function, so the module-to-module `CALLS` edge carries `calleeMethod =
|
||||
AgstammControllerEndpoint.saveBroker` and `callerFn = <thunk>`. Since item 203 the synthetic
|
||||
class-hierarchy edges (`resolvedVia: INHERITANCE`, caller → each implementation of the called
|
||||
interface/base) exist once per originating call site with its real `lineNo`, `originFile`,
|
||||
`calleeMethod` and `callerFn` — before, one edge per pair took whichever call line the merge met first.
|
||||
So `callees` lists every real line for an implementation, and `functions/{impl-method}/callers` also
|
||||
names callers that go through the interface (`RepoImpl.save` ← `Service.store` via `Repo.save`).
|
||||
Since item 197
|
||||
`functions/{fn}/callers` joins these module-to-module edges back to the calling function, so
|
||||
`purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers`
|
||||
names the thunk in `generalAgreementSlice`, and on the Java side
|
||||
`pur/modules/…AgstammLogic/functions/handleMerge/callers` names `AgstammController.mergeBroker`
|
||||
(a REST controller method itself has no Java callers — it is the HTTP entry point). Extra properties on the node (
|
||||
`GET /nodes/{id}`): `restUrl` (as composed,
|
||||
with placeholders and query string), `restBase` (an application base such as `/pur-r-vbuch/v1`
|
||||
split off so paths compare with the backend's base-less `@Path`), `backend` (`pur`, `pur-r-vstamm`,
|
||||
`dynamic` — from the URL builder), `queryParams`, `requestType`, `responseType`, `paramsType`,
|
||||
`generator`, `owner`, `member`. A generated interface's properties are `FIELD`s under its
|
||||
`DATA_STRUCTURE`, **named `<Interface>.<member>`** (`Broker.ebene`; props `field` = the bare member,
|
||||
`owner` = the interface — since item 195: a node's identity is type + name + file, and one generated
|
||||
file declares hundreds of interfaces, so a bare `vid` used to be a single node under six interfaces),
|
||||
so `GET /data-structures/AgstammUseCase/fields` answers for the frontend too (bare member names;
|
||||
since item 195 — before, the query filtered `FIELD` out and returned `[]` for an interface), and
|
||||
`counterparts?kind=field` rows are named `Broker.ebene`.
|
||||
|
||||
**`COUNTERPART_OF`: the same thing in another project.** A project setting
|
||||
`counterparts: ["pur"]` (`ac project create purfe … --counterpart pur`, `ac project update purfe
|
||||
--counterpart pur`, `GET /projects/purfe` shows it) makes enrichment link, after every refresh of
|
||||
either side:
|
||||
|
||||
| this project | → counterpart | matched on |
|
||||
|-----------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| outbound endpoint `FUNCTION` | backend handler `FUNCTION` | `httpMethod` + path shape (every `{param}` segment compares as `{}`, class + method `@Path` composed as `rest-endpoints` does); several matches → the handler whose source lives under the frontend's `backend` name |
|
||||
| `DATA_STRUCTURE` in a `generated=true` module | Java `MODULE` with the same `simpleName` | unique name only; an ambiguous name stays unlinked |
|
||||
| its `FIELD`s | the class's `FIELD`s | name |
|
||||
|
||||
The edges are rebuilt from scratch on each run (never accumulated) and re-run for the frontend when
|
||||
the **backend** refreshes, because a refreshed handler node is deleted together with the edges
|
||||
pointing at it. Roadmap item 143 (Natural ↔ Java counterparts) will use the same edge.
|
||||
|
||||
`GET /projects/{p}/counterparts?module=&kind=rest|dto|field&unmatched=&countOnly=&limit=&offset=`
|
||||
(CLI `ac counterparts [--module] [--kind] [--unmatched] [--count-only]`) lists
|
||||
`{kind, name, module, sourceFile, startLine, httpMethod, path, counterpartProject, counterpartName,
|
||||
counterpartModule, counterpartSourceFile, counterpartStartLine}`; the `counterpart*` fields are null
|
||||
for an unlinked row, and **`unmatched=true` is the planning question**: which calls does nothing
|
||||
serve, which generated DTOs / fields have no backend twin. Paged with `X-AC-Total-Count` /
|
||||
`X-AC-Truncated` like the search endpoints; unknown `kind` → `400 KIND_UNSUPPORTED`; a project
|
||||
naming itself as counterpart → `400 COUNTERPART_SELF`.
|
||||
|
||||
## The Redux store (item 194)
|
||||
|
||||
**What is modelled.** Every `createSlice` / `createAppSlice` in a `typescript` project is a
|
||||
`STORE_SLICE` node **named by the reducer key it is mounted under** in the project's
|
||||
`configureStore` (`state.<key>`; `gruppenprovision` for `generalAgreementSlice`, whose RTK name is
|
||||
`generalAgreement`) — the key is what every selector path starts with, so it is the identity; the
|
||||
RTK name is kept as `sliceName`. The sidecar traces each `reducer: { key: xReducer }` entry through
|
||||
`xReducer = xSlice.reducer` (also `export default xSlice.reducer`) back to the slice, across
|
||||
workspaces; a slice no store mounts is named by its own `sliceName`. Under the slice, one **store
|
||||
`FIELD` per top-level state key**, named `<key>.<field>` (`schluesseltabelle.sucheStatus`, from the
|
||||
checker's type of `initialState`, so keys only the state type declares are present too) with
|
||||
`dataType` = the TS type, `optional`, `store=true`, `slice`, `field`. Every reducer is a **`FUNCTION`
|
||||
of `kind=reducer`** in the slice's module, named by the **action type it handles**:
|
||||
`schluesseltabelle/updateX` for a case reducer (`reducerKind=reducer`), `schluesseltabelle/suche/fulfilled`
|
||||
for `builder.addCase(sucheByServer.fulfilled, …)` (`reducerKind=case`, `trigger` = the expression),
|
||||
`schluesseltabelle/matcher:isSlicePending(sliceName)` for `addMatcher` (`reducerKind=matcher`). A
|
||||
thunk whose lifecycle action a case handles `CALLS` that case (`callSyntax=extraReducer`), when both
|
||||
live in the same file.
|
||||
|
||||
**Reads and writes.** Inside a reducer every `state.a.b` chain is a `READS`/`WRITES` edge from the
|
||||
reducer `FUNCTION` to the store `FIELD` of its first key (`state` alone → the `STORE_SLICE`), carrying
|
||||
`path` (the full sub-path as written, `keyTableUseCaseSvcResult.result.tableId`), `lineNo`,
|
||||
`via=reducer`. A write is an assignment target (compound assignments also read), `++`/`--`, `delete`,
|
||||
a mutating method on the chain (`push`, `splice`, `set`, `delete`, …), `Object.assign(state.x, …)`,
|
||||
or a `return { … }` (each key written) / `return other` (the whole slice). Outside reducers every
|
||||
store read is a `READS` edge from the reading function (component, hook, thunk; the module when at
|
||||
top level) to a **placeholder** `<key>.<field>` that the finalize step `resolve-store-placeholder`
|
||||
redirects onto the real field by name and then drops — so a read in `KeyTablePage.tsx` lands on the
|
||||
field declared in `keytableSlice.ts` without either file knowing the other. Recognised read forms:
|
||||
`useSelector`/`useAppSelector((state) => state.a.b)` (every chain rooted at the arrow's parameter;
|
||||
`const { x, y } = useAppSelector((s) => s.a)` reads `a.x` and `a.y`), **wrapper hooks** such as
|
||||
`useSchluesseltabelleSelector((useCase) => useCase?.result?.purMode)` — the sidecar finds the wrapper's
|
||||
inner `useAppSelector((state) => selector(state.a.b))`, so the read is `a.b.result.purMode` with
|
||||
`via=useSchluesseltabelleSelector` (the wrapper's own inner read is recorded too) — and
|
||||
`store.getState().a.b` / `const s = thunkAPI.getState(); s.a.b` chains (`via=getState`; 81 sites on
|
||||
`pur-ui`). A read of a key the store does not declare stays a placeholder and is listed by
|
||||
`search/identifier` with an empty `sourceFile`.
|
||||
|
||||
**Dispatch.** A call to a slice action creator (`dispatch(updateX(…))`, a binding of
|
||||
`xSlice.actions`) carries `actionType = <sliceName>/updateX` and is a cross-module `CALLS` edge to the
|
||||
slice module with `calleeMethod = <sliceName>/updateX` — the reducer `FUNCTION` — so module
|
||||
`callees` of a component list the slices it dispatches into; a thunk call keeps the thunk `FUNCTION`
|
||||
as target and carries the thunk's type prefix as `actionType`. (Function-level exposure of these
|
||||
cross-module edges is item 197.)
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/store?slice=` (CLI `ac store [--slice]`) → `[{slice, sliceName,
|
||||
module, sourceFile, startLine, endLine, stateType, fields: [{name, type, optional, reads, writes}],
|
||||
reducers, reads, writes}]`, all slices or one by reducer key / RTK name.
|
||||
`GET /projects/{p}/store/{slice}/accesses?field=&mode=reads|writes&module=&countOnly=&limit=&offset=`
|
||||
(CLI `ac store-accesses <slice> [--field] [--mode] [--module] [--count-only]`) → `[{mode, slice,
|
||||
field, path, function, functionType, functionKind, module, sourceFile, lineNo, via}]`, one row per
|
||||
access site, `field` null for a whole-slice access; paged like `counterparts`; unknown `mode` →
|
||||
`400 MODE_UNSUPPORTED`, unknown slice → `404 SLICE_NOT_FOUND`. Because store fields are `FIELD`
|
||||
nodes with a unique name, the generic `GET /variables/<key>.<field>/reads|writes` and
|
||||
`search/identifier?name=<key>.<field>` answer too.
|
||||
|
||||
**Limits.** Reads through `getState()` aliases are followed only inside the file that created the
|
||||
alias; a thunk→case `CALLS` edge is emitted only when thunk and slice share a file (the pur slices
|
||||
do); a case whose trigger cannot be folded to an action type (a predicate matcher) is named by its
|
||||
expression text; the sidecar reads the store of every workspace in scope — a key mounted only by a
|
||||
workspace outside the project (`excludeDirs`) falls back to the slice's own name (matches on `pur`).
|
||||
No `USES_TYPE` from a store field to the DTO it holds yet — the field's `dataType` says
|
||||
`SvcResult<KeyTableUseCase>`, the link is item 195. Only a **top-level** `createSlice` is a slice: a
|
||||
slice built inside a factory function (`pur-ui-common`'s `filetransferSlice`, created per instance
|
||||
and not mounted in the `pur-ui` store) is not modelled.
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 318, recreated + `refresh --deep`, 285 files, 0
|
||||
failures, 0 placeholders left):** 9 slices — `error`, `global`, `healthTables`, `metadata`
|
||||
(pur-ui-common) and `gruppenprovisionSuche`, `gruppenprovision` (RTK name `generalAgreement`),
|
||||
`schluesseltabelle`, `multilinguism`, `translationdata` (pur-ui) — 43 reducer functions, 216 read
|
||||
and 82 write sites. `schluesseltabelle` alone: 62 sites, 25 through `useSchluesseltabelleSelector`,
|
||||
19 through `getState()`, 5 through `useAppSelector`, 13 in reducers.
|
||||
|
||||
## DTO field bindings (item 195)
|
||||
|
||||
**What a binding is.** The generator emits, next to every DTO interface, a `Fields` class tree
|
||||
(`AgstammUseCaseField = new AgstammUseCaseFields<AgstammUseCase, never>()`, members
|
||||
`broker = new BrokerFields<TRoot, Broker>(this, "broker")`, list members as `keyTableList = (index?) =>
|
||||
new KeyTableDOFields(...)`). A path expression on it — `AgstammUseCaseField.broker.ebene` on a
|
||||
`<SmartInput field={…}>`, `<SmartOutput field={…}>`, a table's `fieldTermForRowData`, a column's
|
||||
`field:`, or a `Fields`-typed prop such as `useCaseFieldPrefix` — is a binding. The sidecar types
|
||||
every hop through the checker (`XFields<TRoot, TSelf>`): the **root DTO** is `TRoot`, the **owner**
|
||||
of the leaf is the `TSelf` of the hop before it, the leaf name is the field. Each binding becomes a
|
||||
`READS` edge (plus a `WRITES` edge when the component tag matches `Input$|Dropzone$|Editor$`) from
|
||||
the binding function (component/hook; the module at top level) to the **`FIELD` of the generated
|
||||
interface** that item 193 already creates (`Broker` → `ebene`), carrying `path` (dotted hops from the
|
||||
root, `brokerList[]` for a list hop), `rootDto`, `kind`, `partial`, `component`, `attribute`,
|
||||
`via=binding`, `lineNo`.
|
||||
|
||||
- `kind=field`: the leaf is a scalar (`ebene`); `kind=prefix`: a whole sub-object is handed on
|
||||
(`useCaseFieldPrefix={X.tab.translationData}`, `fieldTermForRowData={X.keyTableList()}`) —
|
||||
recorded as a read of the container field so nothing is silently dropped.
|
||||
- `partial=true`: the expression is rooted at a prop or local (`props.useCaseFieldPrefix.dataName`,
|
||||
`tabPrefix.x(idx).gausVal`), so the leaf and its owner are exact but the prefix of the path is
|
||||
unknown (only the tail is given). A carrier prop's own name is not part of the path.
|
||||
|
||||
Cross-file targets are placeholders `<Dto>.<field>` (`binding=true`, `owner`, `field`,
|
||||
`targetModule`) resolved by the finalize step `resolve-binding-placeholder` **exactly** — module →
|
||||
`DATA_STRUCTURE` → `FIELD` — and dropped afterwards; a leaf the interface does not declare stays a
|
||||
placeholder (listed by `search/identifier` with empty `sourceFile`). The item-74 stale-edge sweep
|
||||
covers binding targets on a deep re-ingest.
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/bindings?dto=&field=&mode=reads|writes&module=&partial=&countOnly=&limit=&offset=`
|
||||
(CLI `ac bindings [--dto] [--field] [--mode] [--module] [--partial] [--count-only]`) → one row per
|
||||
binding site: `{mode, dto, field, path, rootDto, kind, partial, component, attribute, function,
|
||||
functionType, functionKind, module, sourceFile, lineNo, counterpartProject, counterpartModule,
|
||||
counterpartField}`. The `counterpart*` columns are the field's `COUNTERPART_OF` twin (item 193), so
|
||||
**"which page edits Java `Broker.ebene`"** is `ac bindings --dto Broker --field ebene --mode writes
|
||||
-p purfe` and needs no query on the backend project. `dto` is the *declaring* interface (`Broker`),
|
||||
not the root (`AgstammUseCase`) — filter on `rootDto` client-side when you need the latter. Paged like
|
||||
`counterparts`; unknown `mode` → `400 MODE_UNSUPPORTED`. `GET /data-structures/{dto}/fields` now
|
||||
returns TypeScript interface fields (`type=FIELD`) and carries `boundReads`/`boundWrites` per field
|
||||
(0 for Natural/Java). The generic `variables/<Interface>.<field>/reads|writes` sees binding edges
|
||||
too. The leaf's owner is the interface that *declares* the member — `datStart` bound through
|
||||
`GeneralAgreementDO` lands on `AbstractHistorizedDO.datStart`.
|
||||
|
||||
**Limits.** String-form `field="…"` bindings and the lodash-path bindings of `pur-r-vbuch` (excluded
|
||||
workspace) are not modelled; a `partial` binding cannot say which list element or tab; no
|
||||
`USES_TYPE` from the component to the root DTO (the `rootDto` edge property answers that). A
|
||||
binding placeholder and a store placeholder share the `FIELD` type, so a DTO named exactly like a
|
||||
store key would merge their placeholders (`Dto.field` vs `key.field`) — not the case on `pur`.
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 322, recreated + `refresh --deep`, 285 files, 0
|
||||
failures):** 257 binding sites (196 reads, 61 writes; 226 scalar leaves, 31 prefixes; 84 partial)
|
||||
over 23 declaring DTOs, every one linked to its `pur` counterpart field; `SmartInput` 122,
|
||||
`SmartOutput` 64, tables 32, column definitions 36. `counterparts?kind=field`: 744 fields, exactly
|
||||
one twin each (six-fold fan-out before the qualified names), 112 unmatched.
|
||||
|
||||
## Styling: theme tokens and the style inventory (item 196)
|
||||
|
||||
**The theme.** The file with `createTheme({...})` (`pur-ui-common/src/theme.ts`) gets a
|
||||
`DATA_STRUCTURE theme` (`kind=theme`) with one `FIELD` per token: `theme.<path>` for every leaf of the
|
||||
literal (`palette.primary.dark`, `typography.h1.fontWeight`, `shape.borderRadius`, `sizes.*`; props
|
||||
`token`, `tokenKind=path`, `value` folded through constants — `#0054A2` — and `constant` when the
|
||||
leaf names one, `PRIMARY_DARK`) and `theme.<NAME>` for each exported string/number constant of that
|
||||
file (`tokenKind=constant`, `PRIMARY`). MUI `components.styleOverrides` are recorded as tokens too, not
|
||||
interpreted.
|
||||
|
||||
**Style blocks.** Every `sx={…}`, `style={…}` and `styled(X)(…)` block is a `STYLE` node under its
|
||||
component `FUNCTION` (a `styled` block under the `kind=styled` function), named
|
||||
`<function>.<sx|style|styled>@<line>:<col>`, with `styleKind`, `element` (the JSX tag or styled base:
|
||||
`Box`, `'div'`), `properties` (the CSS keys, nested selectors flattened: `&:hover.color`,
|
||||
`& .MuiPaper-root.background`), `literals` (hard-coded colours/lengths: `#005CA9`, `17px`, `-2%`,
|
||||
`calc(100% - 16px)` — `mt: 2` is theme-relative and no literal), `dynamic` (a value the sidecar could
|
||||
not classify, or a whole `sx={props.sx}`), `spread`. Plain `.css` files get one `STYLE` per rule from
|
||||
the Tier-1 scanner (`body@7`, `@font-face@2`; `styleKind=css`, `selector`, `properties`, `literals`).
|
||||
|
||||
**Token reads.** Inside a style block every chain on a theme value is a `REFERENCES` edge `STYLE →
|
||||
theme.<token>` with `property` (the CSS key it feeds) and `via=theme`; a theme value is anything typed
|
||||
`Theme` (`useTheme()`, a `({ theme }) =>` styled parameter, the theme object imported under any name)
|
||||
or named `theme`. `theme.spacing(2)` ends at `spacing`, `theme.palette.grey['200']` is
|
||||
`palette.grey.200`; a theme constant (`color: PRIMARY`) references `theme.PRIMARY`. A token read outside
|
||||
a style block (`borderColor={theme.palette.grey['200']}`, code) is the same edge from the enclosing
|
||||
`FUNCTION` with `context` = the JSX attribute or `code`. Cross-file targets are placeholders
|
||||
`theme.<token>` (`theme=true`) resolved by exact name at finalize when exactly one theme declares the
|
||||
token; **a token no theme declares keeps its placeholder on purpose** — `GET /theme` lists it with
|
||||
`declared=false` (MUI defaults such as `palette.grey.200`, `palette.common.white`, the `spacing`
|
||||
function, or a typo).
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/theme?unused=` (CLI `ac theme [--unused]`) → `[{token, kind, value,
|
||||
constant, declared, module, lineNo, uses}]`, declared tokens first. `uses` counts **project**
|
||||
references (style blocks and code); MUI's own consumption of a token is invisible, so `unused=true`
|
||||
means "no project code references it", never "safe to delete" (`palette.primary.main` colours every
|
||||
Button whether or not a component names it). `GET /projects/{p}/theme/{token}/usages` (`ac
|
||||
theme-usages palette.primary.dark`, token = dotted path or constant name) → `[{function,
|
||||
functionKind, module, sourceFile, lineNo, styleKind, element, property, context}]`; unknown token →
|
||||
`404 TOKEN_NOT_FOUND`.
|
||||
`GET /projects/{p}/styles?module=&kind=sx|style|styled|css&withLiterals=&countOnly=&limit=&offset=`
|
||||
(`ac styles [--module] [--kind] [--with-literals]`) → `[{name, styleKind, element, selector,
|
||||
function, module, sourceFile, lineNo, properties, literals, dynamic, tokens}]`, paged like
|
||||
`counterparts`; **`withLiterals=true` is the review question**: which blocks hard-code colours and
|
||||
lengths instead of using the theme. Unknown `kind` → `400 KIND_UNSUPPORTED`.
|
||||
|
||||
**Several themes (item 199).** Every `createTheme({...})` in a file is read; a token two themes of
|
||||
one file declare is one row with the first theme's value and `variants: 2`. A token declared by two
|
||||
theme *files* (light/dark) is one row per file, and a read of it resolves onto both, so each row
|
||||
counts the use and `?unused=true` stays honest; `theme/{token}/usages` and `styles[].tokens`
|
||||
report such a read once. Two reads of one token on one line of a style block (`color: PRIMARY,
|
||||
borderColor: PRIMARY`) are one usage whose `property` is the comma list of the keys they feed.
|
||||
CSS rules: a block-less `@import`/`@charset` line is not part of the next selector, braces inside
|
||||
string values do not open blocks, a nested rule head inside an at-rule body is not a declaration,
|
||||
and a selector repeated on one line (minified CSS) gets a `:col` suffix in its name.
|
||||
|
||||
**Limits.** `className` strings are not matched to CSS rules; Emotion `css` templates and MUI
|
||||
`styleOverrides` are not modelled; a token read from a component-level function (not a style
|
||||
block) that survives a re-parse keeps its resolved edge until the file's nodes are re-created
|
||||
(same class as item 198).
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 326, recreated + `refresh --deep`, 285 files, 0
|
||||
failures):** 129 declared tokens (111 paths, 18 constants), 93 of them with no project reference;
|
||||
12 undeclared tokens the code reads (`palette.common.white` 4, `palette.grey.200` 4, `spacing` 3,
|
||||
`palette.divider`, `palette.text.secondary`, `applyStyles`, `transitions.create`, …);
|
||||
`palette.primary.dark` is the most-used token (26 reads: 17 `style`, 3 `sx`, 1 `styled`, 5 as a
|
||||
plain prop such as `confirmColor`). Style inventory: 304 blocks (196 `sx`, 78 `style`, 27 `styled`,
|
||||
3 CSS rules), 109 with hard-coded literals (`100%` 29, `1px` 21, `12px` 14, `17px` 14, …), 75
|
||||
reading theme tokens, 41 dynamic. The only placeholders left in the project are the 12 undeclared
|
||||
theme tokens — by design.
|
||||
|
||||
## Missing capability?
|
||||
|
||||
If the API/CLI genuinely cannot answer a question (not just
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user