Typescript
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
|
||||
60
README.md
60
README.md
@@ -2,7 +2,8 @@
|
||||
|
||||
> AI-agent-optimized code analysis platform — parse, store, enrich, and query source code as a graph.
|
||||
|
||||
AgenticCode ingests source code (Natural/Software AG and Java), parses it into a unified AST, persists it in Neo4j,
|
||||
AgenticCode ingests source code (Natural/Software AG, Java and TypeScript/React), parses it into a unified AST, persists
|
||||
it in Neo4j,
|
||||
enriches it with semantic information (call graphs, DB accesses, data structures, dynamic-dispatch resolution), and
|
||||
exposes everything through an agent-ready **REST** API, a **CLI**, and a **web UI**.
|
||||
|
||||
@@ -172,15 +173,15 @@ JSON with `limit`/`offset` pagination where lists can be large.
|
||||
|
||||
### Projects & ingestion
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `GET /api/projects` | List all projects |
|
||||
| `POST /api/projects/{project}` | Create a project (body: `root`, `language`, optional `description`, `excludeDirs`, `generatedDir`, `userExitDir`) |
|
||||
| `PUT /api/projects/{project}` | Update a project (unset fields left unchanged) |
|
||||
| `DELETE /api/projects/{project}` | Delete a project and all its data |
|
||||
| `POST /api/projects/{project}/refresh[?deep=true]` | (Re)scan the whole root — call-graph pass, or full field-level with `deep=true` |
|
||||
| `POST /api/projects/{project}/refresh/{name}` | Deep-ingest one module + its dependency tree (`maxDepth`, `maxNodes`, `neighborhood`) |
|
||||
| `POST /api/projects/{project}/recreate` | Drop and rebuild the project's graph from disk |
|
||||
| Endpoint | Description |
|
||||
|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `GET /api/projects` | List all projects |
|
||||
| `POST /api/projects/{project}` | Create a project (body: `root`, `language`, optional `description`, `excludeDirs`, `generatedDir`, `userExitDir`, `counterparts`) |
|
||||
| `PUT /api/projects/{project}` | Update a project (unset fields left unchanged) |
|
||||
| `DELETE /api/projects/{project}` | Delete a project and all its data |
|
||||
| `POST /api/projects/{project}/refresh[?deep=true]` | (Re)scan the whole root — call-graph pass, or full field-level with `deep=true` |
|
||||
| `POST /api/projects/{project}/refresh/{name}` | Deep-ingest one module + its dependency tree (`maxDepth`, `maxNodes`, `neighborhood`) |
|
||||
| `POST /api/projects/{project}/recreate` | Drop and rebuild the project's graph from disk |
|
||||
|
||||
### Call graph & structure
|
||||
|
||||
@@ -200,15 +201,18 @@ JSON with `limit`/`offset` pagination where lists can be large.
|
||||
|
||||
### Data, DB access & payload
|
||||
|
||||
| Endpoint | Description |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------|
|
||||
| `GET .../modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
|
||||
| `GET .../modules/{name}/sql-statements` | SQL/ADABAS statements |
|
||||
| `GET .../modules/{name}/workfile-accesses` | Natural work-file reads/writes |
|
||||
| `GET .../modules/{name}/data-structures` | Data structures used by the module |
|
||||
| `GET .../modules/{name}/payload` | Parameter-data-area I/O contract |
|
||||
| `GET .../modules/{name}/columns` · `.../modules/{name}/dispatch-table` | Entity columns · DECIDE dispatch table |
|
||||
| `GET .../data-structures/{name}/fields` · `.../db-tables/{name}/columns` | Fields of a structure · columns of a table |
|
||||
| Endpoint | Description |
|
||||
|-----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `GET .../modules/{name}/db-accesses` | DB tables accessed and mode (READ/WRITE) |
|
||||
| `GET .../store?slice=` · `GET .../store/{slice}/accesses?field=&mode=` | Frontend Redux store: slices, state keys, who reads/writes them (item 194) |
|
||||
| `GET .../bindings?dto=&field=&mode=&module=` | DTO field bindings: which component reads/writes which backend field, with its Java counterpart (item 195) |
|
||||
| `GET .../theme?unused=` · `GET .../theme/{token}/usages` · `GET .../styles` | MUI theme tokens with use counts, where a token is read, and the sx/style/styled/CSS inventory with hard-coded literals (item 196) |
|
||||
| `GET .../modules/{name}/sql-statements` | SQL/ADABAS statements |
|
||||
| `GET .../modules/{name}/workfile-accesses` | Natural work-file reads/writes |
|
||||
| `GET .../modules/{name}/data-structures` | Data structures used by the module |
|
||||
| `GET .../modules/{name}/payload` | Parameter-data-area I/O contract |
|
||||
| `GET .../modules/{name}/columns` · `.../modules/{name}/dispatch-table` | Entity columns · DECIDE dispatch table |
|
||||
| `GET .../data-structures/{name}/fields` · `.../db-tables/{name}/columns` | Fields of a structure · columns of a table |
|
||||
|
||||
### Search & dataflow
|
||||
|
||||
@@ -390,15 +394,15 @@ ac dynamic-calls reset --file X.nat --line 403 -p upms
|
||||
|
||||
`ac --help` lists everything; `ac <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,13 +59,17 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@Option(names = {"-l", "--language"}, required = true,
|
||||
description = "Project source language (natural/java)")
|
||||
description = "Project source language (natural/java/typescript)")
|
||||
String language;
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; requires --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits; their LoC/SLoC annotate the generated twin (requires --generated-dir)")
|
||||
String userExitDir = "";
|
||||
@@ -75,12 +79,13 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
return printResponse(apiClient().postJson("/api/projects/" + encode(name),
|
||||
new ProjectRequest(description.isBlank() ? null : description, root, excludeDirs,
|
||||
language, generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir)));
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
@Command(name = "update", mixinStandardHelpOptions = true,
|
||||
description = "Update a project's description, root, exclude-dirs, language and/or generated/user-exit dirs (unset fields are left unchanged)")
|
||||
description = "Update a project's description, root, exclude-dirs, language, counterparts and/or generated/user-exit dirs (unset fields are left unchanged)")
|
||||
static final class UpdateCommand extends AbstractApiCommand {
|
||||
|
||||
@SuppressWarnings("NullAway.Init")
|
||||
@@ -97,13 +102,17 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
description = "Directory name to skip when scanning the root (case-insensitive); repeatable")
|
||||
List<String> excludeDirs = List.of();
|
||||
|
||||
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java)")
|
||||
@Option(names = {"-l", "--language"}, description = "Project source language (natural/java/typescript)")
|
||||
String language = "";
|
||||
|
||||
@Option(names = {"-g", "--generated-dir"},
|
||||
description = "Directory name of generated sources (item 47; pair with --user-exit-dir)")
|
||||
String generatedDir = "";
|
||||
|
||||
@Option(names = {"-c", "--counterpart"},
|
||||
description = "Project whose handlers/DTOs this project's web-service calls and generated DTOs are linked to (item 193); repeatable")
|
||||
List<String> counterparts = List.of();
|
||||
|
||||
@Option(names = {"-u", "--user-exit-dir"},
|
||||
description = "Directory name of hand-written user exits (item 47; pair with --generated-dir)")
|
||||
String userExitDir = "";
|
||||
@@ -116,7 +125,8 @@ final class ProjectCommand implements Callable<Integer> {
|
||||
excludeDirs.isEmpty() ? null : excludeDirs,
|
||||
language.isBlank() ? null : language,
|
||||
generatedDir.isBlank() ? null : generatedDir,
|
||||
userExitDir.isBlank() ? null : userExitDir)));
|
||||
userExitDir.isBlank() ? null : userExitDir,
|
||||
counterparts.isEmpty() ? null : counterparts)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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=294
|
||||
version=329
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-java</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-neo4j-store</artifactId>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -827,6 +827,191 @@ public class AnalysisResource {
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
private static @Nullable String blankToNull(@Nullable String s) {
|
||||
return s == null || s.isBlank() ? null : s.strip();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/counterparts")
|
||||
@Operation(summary = "Counterparts in another project (item 193)",
|
||||
description = "This project's outbound web-service calls, generated data structures and their fields, each "
|
||||
+ "with its COUNTERPART_OF twin in a counterpart project (project setting `counterparts`); "
|
||||
+ "`unmatched=true` lists only those without a twin - what is not served or mirrored yet.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Counterpart.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> counterparts(@PathParam("project") String project,
|
||||
@Parameter(description = "Narrow to one declaring module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "rest | dto | field; all when absent.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@Parameter(description = "Only rows without a counterpart.")
|
||||
@QueryParam("unmatched") @Nullable Boolean unmatched,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effKind = kind == null || kind.isBlank() ? null : kind.strip().toLowerCase(java.util.Locale.ROOT);
|
||||
if (effKind != null && !List.of("rest", "dto", "field").contains(effKind)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
|
||||
"Unknown kind '" + kind + "'; expected rest, dto or field"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.counterpartsPage(project, module, effKind,
|
||||
Boolean.TRUE.equals(unmatched), effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/theme")
|
||||
@Operation(summary = "The MUI theme's tokens with their project-side use counts (item 196)",
|
||||
description = "Every leaf of createTheme({..}) (`palette.primary.dark`, value folded through constants) and every "
|
||||
+ "exported string constant of the theme file, plus the tokens the code reads that no theme declares "
|
||||
+ "(`declared=false`: MUI defaults such as palette.grey.200, or typos). `uses` counts project references only; "
|
||||
+ "MUI's own use of a token is invisible, so `unused=true` means 'not referenced by project code', not 'dead'.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeToken.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> theme(@PathParam("project") String project,
|
||||
@Parameter(description = "Only tokens with no project reference.")
|
||||
@QueryParam("unused") @Nullable Boolean unused) {
|
||||
return withProject(project, () -> graphRepository.themeTokens(project, Boolean.TRUE.equals(unused)).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/theme/{token}/usages")
|
||||
@Operation(summary = "Where one theme token is read (item 196)",
|
||||
description = "Style blocks reading the token (with `styleKind`, `element` and the CSS `property` it feeds) and plain "
|
||||
+ "reads (`context` = the JSX attribute or `code`). `token` is the dotted path (`palette.primary.dark`) or a constant name (`PRIMARY`).")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ThemeUsage.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or token not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> themeUsages(@PathParam("project") String project, @PathParam("token") String token) {
|
||||
return withProject(project, () -> graphRepository.themeUsages(project, token).chain(rows -> {
|
||||
if (rows.isEmpty()) {
|
||||
return graphRepository.themeTokens(project, false).map(all -> all.stream().anyMatch(t -> t.token().equals(token))
|
||||
? ok(rows)
|
||||
: ProjectResource.error(Response.Status.NOT_FOUND, "TOKEN_NOT_FOUND",
|
||||
"No theme token '" + token + "' in project '" + project + "'"));
|
||||
}
|
||||
return Uni.createFrom().item(ok(rows));
|
||||
}));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/styles")
|
||||
@Operation(summary = "The style inventory: sx/style/styled blocks and CSS rules (item 196)",
|
||||
description = "One row per style block with its CSS keys (`properties`, nested selectors flattened), hard-coded "
|
||||
+ "colours/lengths (`literals`) and the theme tokens it reads. `withLiterals=true` = only blocks that bypass "
|
||||
+ "the theme; `kind` = sx | style | styled | css; `module` = one component module.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StyleBlock.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'kind'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> styles(@PathParam("project") String project,
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "sx | style | styled | css; all when absent.")
|
||||
@QueryParam("kind") @Nullable String kind,
|
||||
@Parameter(description = "Only blocks with hard-coded colour/length literals.")
|
||||
@QueryParam("withLiterals") @Nullable Boolean withLiterals,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String trimmedKind = blankToNull(kind);
|
||||
String effKind = trimmedKind == null ? null : trimmedKind.toLowerCase(java.util.Locale.ROOT);
|
||||
if (effKind != null && !List.of("sx", "style", "styled", "css").contains(effKind)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "KIND_UNSUPPORTED",
|
||||
"Unknown kind '" + kind + "'; expected sx, style, styled or css"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.stylesPage(project, blankToNull(module), effKind,
|
||||
Boolean.TRUE.equals(withLiterals), effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/bindings")
|
||||
@Operation(summary = "DTO field bindings: which component reads/writes which backend field (item 195)",
|
||||
description = "Every use of a generated `Fields` path object (`<SmartInput field={AgstammUseCaseField.broker.ebene}>`) "
|
||||
+ "as one row: the binding function, the DTO field it names (`dto.field`, full `path` from `rootDto`), "
|
||||
+ "READS or WRITES (input components write), and the field's COUNTERPART_OF twin in the backend project. "
|
||||
+ "`dto`/`field`/`module`/`mode`/`partial` narrow the list.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = Binding.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> bindings(@PathParam("project") String project,
|
||||
@Parameter(description = "Only fields of this DTO (the declaring interface, e.g. Broker).")
|
||||
@QueryParam("dto") @Nullable String dto,
|
||||
@Parameter(description = "Only this field name.")
|
||||
@QueryParam("field") @Nullable String field,
|
||||
@Parameter(description = "reads | writes; both when absent.")
|
||||
@QueryParam("mode") @Nullable String mode,
|
||||
@Parameter(description = "Only bindings in this module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@Parameter(description = "true: only partial paths (rooted at a prop/local); false: only full paths.")
|
||||
@QueryParam("partial") @Nullable Boolean partial,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
|
||||
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
|
||||
"Unknown mode '" + mode + "'; expected reads or writes"));
|
||||
}
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.bindingsPage(project, blankToNull(dto), blankToNull(field), effMode,
|
||||
blankToNull(module), partial, effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/store")
|
||||
@Operation(summary = "The frontend Redux store: its slices, state keys and access counts (item 194)",
|
||||
description = "One row per createSlice mounted in the project's store, named by its reducer key (`state.<slice>`), "
|
||||
+ "with the top-level state keys (type, optional, read/write site counts), the number of reducer functions "
|
||||
+ "and the total access sites. `slice` narrows to one slice by reducer key or RTK slice name.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreSlice.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> store(@PathParam("project") String project,
|
||||
@Parameter(description = "Reducer key or slice name; all slices when absent.")
|
||||
@QueryParam("slice") @Nullable String slice) {
|
||||
String effSlice = slice == null || slice.isBlank() ? null : slice.strip();
|
||||
return withProject(project, () -> graphRepository.storeSlices(project, effSlice).map(this::ok));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/store/{slice}/accesses")
|
||||
@Operation(summary = "Who reads and writes a store slice (item 194)",
|
||||
description = "Every access site of the slice's state: reducers (`functionKind=reducer`, the writers, `via=reducer`) "
|
||||
+ "and the components/hooks/thunks reading it through useAppSelector, a wrapper hook or getState() "
|
||||
+ "(`via` names the hook). `field` narrows to one state key, `mode` to READS or WRITES, `module` to one "
|
||||
+ "accessing module. `path` is the full sub-path as written; `field` is null for a whole-slice access.")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = StoreAccess.class)))
|
||||
@APIResponse(responseCode = "400", description = "Unknown 'mode'.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@APIResponse(responseCode = "404", description = "Project or slice not found.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public Uni<Response> storeAccesses(@PathParam("project") String project, @PathParam("slice") String slice,
|
||||
@Parameter(description = "One top-level state key; all when absent.")
|
||||
@QueryParam("field") @Nullable String field,
|
||||
@Parameter(description = "reads | writes; both when absent.")
|
||||
@QueryParam("mode") @Nullable String mode,
|
||||
@Parameter(description = "Only accesses from this module (identity or short name).")
|
||||
@QueryParam("module") @Nullable String module,
|
||||
@QueryParam("countOnly") @Nullable Boolean countOnly,
|
||||
@QueryParam("limit") @Nullable Integer limit,
|
||||
@QueryParam("offset") @Nullable Integer offset) {
|
||||
String effMode = mode == null || mode.isBlank() ? null : mode.strip().toUpperCase(java.util.Locale.ROOT);
|
||||
if (effMode != null && !List.of("READS", "WRITES").contains(effMode)) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.BAD_REQUEST, "MODE_UNSUPPORTED",
|
||||
"Unknown mode '" + mode + "'; expected reads or writes"));
|
||||
}
|
||||
String effField = field == null || field.isBlank() ? null : field.strip();
|
||||
int effLimit = isCountOnly(countOnly) ? 1 : uncappedLimit(limit);
|
||||
return withProject(project, () -> graphRepository.storeSlices(project, slice).chain(slices -> {
|
||||
if (slices.isEmpty()) {
|
||||
return Uni.createFrom().item(ProjectResource.error(Response.Status.NOT_FOUND, "SLICE_NOT_FOUND",
|
||||
"No store slice '" + slice + "' in project '" + project + "'"));
|
||||
}
|
||||
return graphRepository.storeAccessesPage(project, slice, effField, effMode, module, effLimit, effectiveOffset(offset))
|
||||
.map(page -> isCountOnly(countOnly) ? countOnly(page) : paged(page));
|
||||
}));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/search/references")
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = ReferenceSite.class)))
|
||||
|
||||
@@ -61,7 +61,7 @@ public class ProjectResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java");
|
||||
private static final List<String> SUPPORTED_LANGUAGES = List.of("natural", "java", "typescript");
|
||||
|
||||
private static @Nullable String normalize(@Nullable String value) {
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
@@ -136,8 +136,14 @@ public class ProjectResource {
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.createProject(project, request.description(), request.root(), request.excludeDirsOrEmpty(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts == null ? List.of() : counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> {
|
||||
scanTier1(project);
|
||||
@@ -185,8 +191,14 @@ public class ProjectResource {
|
||||
if (invalid != null) {
|
||||
return Uni.createFrom().item(invalid);
|
||||
}
|
||||
@Nullable List<String> counterparts = request.normalizedCounterparts();
|
||||
if (counterparts != null && counterparts.contains(project)) {
|
||||
return Uni.createFrom().item(error(Response.Status.BAD_REQUEST, "COUNTERPART_SELF",
|
||||
"A project cannot be its own counterpart"));
|
||||
}
|
||||
return graphRepository.updateProject(project, request.description(), request.root(), request.excludeDirs(),
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()))
|
||||
normalize(request.language()), normalize(request.generatedDir()), normalize(request.userExitDir()),
|
||||
counterparts)
|
||||
.map(result -> switch (result) {
|
||||
case SUCCESS -> Response.ok().build();
|
||||
case NOT_FOUND -> error(Response.Status.NOT_FOUND, "PROJECT_NOT_FOUND",
|
||||
@@ -229,14 +241,33 @@ public class ProjectResource {
|
||||
*/
|
||||
public record ProjectRequest(@Nullable String description, @Nullable String root,
|
||||
@Nullable List<String> excludeDirs, @Nullable String language,
|
||||
@Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
@Nullable String generatedDir, @Nullable String userExitDir,
|
||||
@Nullable List<String> counterparts) {
|
||||
|
||||
/**
|
||||
* Pre-item-193 shape (no counterparts).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir, @Nullable String userExitDir) {
|
||||
this(description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible constructor for callers that predate the item-47 fields (language /
|
||||
* generated / user-exit dir all absent).
|
||||
*/
|
||||
public ProjectRequest(@Nullable String description, @Nullable String root, @Nullable List<String> excludeDirs) {
|
||||
this(description, root, excludeDirs, null, null, null);
|
||||
this(description, root, excludeDirs, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart list, trimmed; {@code null} when absent (update: unchanged).
|
||||
*/
|
||||
@Nullable List<String> normalizedCounterparts() {
|
||||
if (counterparts == null) {
|
||||
return null;
|
||||
}
|
||||
return counterparts.stream().filter(c -> c != null && !c.isBlank()).map(String::strip).distinct().toList();
|
||||
}
|
||||
|
||||
List<String> excludeDirsOrEmpty() {
|
||||
|
||||
@@ -15,6 +15,11 @@ import com.agenticcode.parsernatural.CopycodeResolver;
|
||||
import com.agenticcode.parsernatural.NaturalCoarseScanner;
|
||||
import com.agenticcode.parsernatural.NaturalLineCounter;
|
||||
import com.agenticcode.parsernatural.NaturalParser;
|
||||
import com.agenticcode.parsertypescript.CssLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptCoarseScanner;
|
||||
import com.agenticcode.parsertypescript.TypeScriptLineCounter;
|
||||
import com.agenticcode.parsertypescript.TypeScriptParser;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import io.smallrye.mutiny.Uni;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
@@ -39,6 +44,12 @@ public class AstIngestService {
|
||||
private final NaturalCoarseScanner naturalScanner = new NaturalCoarseScanner();
|
||||
private final LineCounter javaLineCounter = new JavaLineCounter();
|
||||
private final LineCounter naturalLineCounter = new NaturalLineCounter();
|
||||
// Item 192: one parser for .ts/.tsx/.css; the per-ingest TypeScriptProject carries workspaces,
|
||||
// package names and (Tier-2) the sidecar facts, like CopycodeResolver does for Natural.
|
||||
private final TypeScriptParser typeScriptParser = new TypeScriptParser();
|
||||
private final TypeScriptCoarseScanner typeScriptScanner = new TypeScriptCoarseScanner();
|
||||
private final LineCounter typeScriptLineCounter = new TypeScriptLineCounter();
|
||||
private final LineCounter cssLineCounter = new CssLineCounter();
|
||||
|
||||
/**
|
||||
* Item 179 (DIAGNOSTIC): when false, {@link NodeType#COMMENT} nodes and their {@code DOCUMENTS}
|
||||
@@ -110,10 +121,21 @@ public class AstIngestService {
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
if (language == SourceFiles.Language.JAVA) {
|
||||
return javaParser.parse(sourceFile, content);
|
||||
}
|
||||
return naturalParser.parse(sourceFile, content, copycodes);
|
||||
return parse(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses {@code content} with the parser for {@code language}; {@code copycodes} serves Natural
|
||||
* (item 46a), {@code typescript} serves TypeScript/CSS (item 192). The switch is exhaustive on
|
||||
* purpose: a new {@link SourceFiles.Language} must be routed here, not fall through to a default.
|
||||
*/
|
||||
public LanguageParser.ParseResult parse(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaParser.parse(sourceFile, content);
|
||||
case NATURAL -> naturalParser.parse(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptParser.parse(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,10 +153,16 @@ public class AstIngestService {
|
||||
*/
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes) {
|
||||
if (language == SourceFiles.Language.JAVA) {
|
||||
return javaScanner.scan(sourceFile, content);
|
||||
}
|
||||
return naturalScanner.scan(sourceFile, content, copycodes);
|
||||
return coarseScan(language, sourceFile, content, copycodes, TypeScriptProject.NONE);
|
||||
}
|
||||
|
||||
public LanguageParser.ParseResult coarseScan(SourceFiles.Language language, String sourceFile, String content,
|
||||
CopycodeResolver copycodes, TypeScriptProject typescript) {
|
||||
return switch (language) {
|
||||
case JAVA -> javaScanner.scan(sourceFile, content);
|
||||
case NATURAL -> naturalScanner.scan(sourceFile, content, copycodes);
|
||||
case TYPESCRIPT, CSS -> typeScriptScanner.scan(sourceFile, content, typescript);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +170,12 @@ public class AstIngestService {
|
||||
* the Tier-1 coarse scanners use, so a module's metrics are identical at any ingest depth.
|
||||
*/
|
||||
public LocMetrics count(SourceFiles.Language language, String content) {
|
||||
LineCounter counter = language == SourceFiles.Language.JAVA ? javaLineCounter : naturalLineCounter;
|
||||
LineCounter counter = switch (language) {
|
||||
case JAVA -> javaLineCounter;
|
||||
case NATURAL -> naturalLineCounter;
|
||||
case TYPESCRIPT -> typeScriptLineCounter;
|
||||
case CSS -> cssLineCounter;
|
||||
};
|
||||
return counter.count(content);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.agenticcode.neo4jstore.graph.ProjectInfo;
|
||||
import com.agenticcode.neo4jstore.graph.ProjectIngestInfo;
|
||||
import com.agenticcode.parsercore.ast.model.*;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import com.agenticcode.parsertypescript.TypeScriptProject;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
@@ -52,10 +53,13 @@ public class ProjectIngestService {
|
||||
// Item 130: dropped whenever the project shell changes, so the scope/staleness headers cannot
|
||||
// report "clean" about a graph whose refresh has just started.
|
||||
private final ProjectMetadataCache projectMetadata;
|
||||
// Item 192: builds the per-ingest TypeScript context (workspaces, packages, sidecar facts).
|
||||
private final TypeScriptSidecarService typescript;
|
||||
|
||||
public ProjectIngestService(AstIngestService astIngestService,
|
||||
VersionInfo versionInfo,
|
||||
ProjectMetadataCache projectMetadata,
|
||||
TypeScriptSidecarService typescript,
|
||||
@ConfigProperty(name = "agenticcode.ingest.batch-size", defaultValue = "200") int batchSize,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.default-depth", defaultValue = "5") int defaultDeepDepth,
|
||||
@ConfigProperty(name = "agenticcode.deep-ingest.max-depth", defaultValue = "20") int maxDeepDepth,
|
||||
@@ -64,6 +68,7 @@ public class ProjectIngestService {
|
||||
this.astIngestService = astIngestService;
|
||||
this.versionInfo = versionInfo;
|
||||
this.projectMetadata = projectMetadata;
|
||||
this.typescript = typescript;
|
||||
this.batchSize = Math.max(1, batchSize);
|
||||
this.maxDeepDepth = Math.max(1, maxDeepDepth);
|
||||
this.defaultDeepDepth = Math.min(Math.max(1, defaultDeepDepth), this.maxDeepDepth);
|
||||
@@ -203,13 +208,13 @@ public class ProjectIngestService {
|
||||
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
|
||||
*/
|
||||
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
|
||||
CopycodeLibrary copycodes, Map<String, LocMetrics> userExit,
|
||||
CopycodeLibrary copycodes, TypeScriptProject ts, Map<String, LocMetrics> userExit,
|
||||
ProjectInfo project) throws IOException {
|
||||
String content = SourceFiles.read(candidate.file());
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
ParseResult result = coarse
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes)
|
||||
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
|
||||
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes, ts)
|
||||
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
|
||||
content, candidate.kind().language());
|
||||
result = withUserExitMetrics(result, project.generatedDir(), userExit);
|
||||
return new Parsed(candidate, result);
|
||||
@@ -235,9 +240,12 @@ public class ProjectIngestService {
|
||||
* Build-output directories always skipped when scanning a root (J6: their generated sources
|
||||
* would otherwise create duplicate module/entity definitions), on top of the project's own excludes.
|
||||
*/
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target");
|
||||
// Item 192: node_modules/dist join target — a frontend's dependency tree and build output are never
|
||||
// sources, and walking node_modules would cost minutes (the sidecar reads it on its own for typings).
|
||||
private static final List<String> DEFAULT_EXCLUDE_DIRS = List.of("target", "node_modules", "dist");
|
||||
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs) throws IOException {
|
||||
private static List<Candidate> walk(Path root, List<String> excludeDirs, @Nullable String projectLanguage)
|
||||
throws IOException {
|
||||
List<String> effectiveExcludes = new ArrayList<>(excludeDirs);
|
||||
for (String dir : DEFAULT_EXCLUDE_DIRS) {
|
||||
if (!effectiveExcludes.contains(dir)) {
|
||||
@@ -254,7 +262,7 @@ public class ProjectIngestService {
|
||||
continue;
|
||||
}
|
||||
SourceFiles.Kind kind = SourceFiles.classify(file);
|
||||
if (kind != null) {
|
||||
if (kind != null && SourceFiles.ingestedBy(kind.language(), projectLanguage)) {
|
||||
candidates.add(new Candidate(file, kind));
|
||||
}
|
||||
}
|
||||
@@ -493,12 +501,17 @@ public class ProjectIngestService {
|
||||
markIngestStarted(project, coarse ? "tier1" : level.name().toLowerCase(Locale.ROOT));
|
||||
Path root = Path.of(project.root());
|
||||
List<String> excludeDirs = ingestExcludeDirs(project);
|
||||
List<Candidate> allCandidates = walk(root, excludeDirs);
|
||||
List<Candidate> allCandidates = walk(root, excludeDirs, project.language());
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, excludeDirs);
|
||||
// Item 129: opt-in skip of files whose content is byte-identical to what the graph holds.
|
||||
List<Candidate> candidates = changedOnly ? changedCandidates(project, root, excludeDirs, allCandidates)
|
||||
: allCandidates;
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
|
||||
// Item 192: the TypeScript context — Tier-1 needs only package.json; a deep pass runs the sidecar
|
||||
// per workspace. A sidecar failure is reported as a failed pseudo-path and the files fall back to
|
||||
// Tier-1, so the refresh completes and the response says what is missing.
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
TypeScriptProject ts = typescript.prepare(project, root, excludeDirs, coarse, failed);
|
||||
|
||||
List<String> examinedFiles = candidates.stream()
|
||||
.map(c -> relativeSourceFile(root, c.file()))
|
||||
@@ -509,11 +522,10 @@ public class ProjectIngestService {
|
||||
// Results are collected in candidate order (futures list is parallel to candidates), so the
|
||||
// downstream duplicate detection and persist order stay deterministic.
|
||||
List<Parsed> parsed = new ArrayList<>();
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
List<Future<Parsed>> futures = candidates.stream()
|
||||
.map(candidate -> parseExecutor.submit(
|
||||
() -> parseCandidate(candidate, root, coarse, copycodes, userExit, project)))
|
||||
() -> parseCandidate(candidate, root, coarse, copycodes, ts, userExit, project)))
|
||||
.toList();
|
||||
for (int i = 0; i < futures.size(); i++) {
|
||||
try {
|
||||
@@ -630,8 +642,9 @@ public class ProjectIngestService {
|
||||
// fixtures* that its Java walk never ingests, so they had no stored hash, counted as changed,
|
||||
// and disabled skipping entirely — 487 of 509 unchanged files re-parsed. An unknown language
|
||||
// (a legacy project) keeps the conservative behaviour.
|
||||
if (!"java".equalsIgnoreCase(project.language() == null ? "" : project.language())
|
||||
&& copycodeChanged(root, excludeDirs, stored)) {
|
||||
String declared = project.language() == null ? "" : project.language();
|
||||
boolean inlinesCopycodes = declared.isEmpty() || "natural".equalsIgnoreCase(declared);
|
||||
if (inlinesCopycodes && copycodeChanged(root, excludeDirs, stored)) {
|
||||
LOG.infof("changedOnly refresh of '%s': a copycode changed, so every file is re-parsed "
|
||||
+ "(copycode text is inlined at parse time; skipping would keep stale expansions)",
|
||||
project.name());
|
||||
@@ -753,7 +766,7 @@ public class ProjectIngestService {
|
||||
int depthLimit = Math.min(Math.max(1, maxDepth != null ? maxDepth : defaultDeepDepth), maxDeepDepth);
|
||||
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
|
||||
Path root = Path.of(project.root());
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project));
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
|
||||
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
|
||||
// Reverse index (Java): interface/base simple name -> files that implement/extend it, so a
|
||||
// targeted ingest also pulls in the implementations/subclasses of any interface it reaches.
|
||||
@@ -787,7 +800,7 @@ public class ProjectIngestService {
|
||||
@Nullable Integer maxNodes) throws IOException {
|
||||
int nodeLimit = Math.max(1, maxNodes != null ? maxNodes : defaultDeepNodes);
|
||||
Path root = Path.of(project.root());
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project));
|
||||
List<Candidate> candidates = walk(root, ingestExcludeDirs(project), project.language());
|
||||
Map<NameKey, List<Candidate>> index = nameIndex(candidates);
|
||||
Map<String, List<Candidate>> implementorsByBase = buildImplementorIndex(root, candidates);
|
||||
Map<String, Candidate> byRelPath = new HashMap<>();
|
||||
@@ -867,6 +880,10 @@ public class ProjectIngestService {
|
||||
long startedAt = System.nanoTime();
|
||||
CopycodeLibrary copycodes = CopycodeLibrary.scan(root, ingestExcludeDirs(project));
|
||||
Map<String, LocMetrics> userExit = UserExitMetrics.scan(root, project.userExitDir(), project.excludeDirs(), astIngestService);
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
// Item 192: a by-name deep ingest of a TypeScript module needs the sidecar facts too (3–5 s per
|
||||
// workspace, run once for this call).
|
||||
TypeScriptProject ts = typescript.prepare(project, root, ingestExcludeDirs(project), false, failed);
|
||||
int ingested = 0;
|
||||
// Item 62 (summary surface): collected as refs, not strings, so the data-literal false
|
||||
// positives can be filtered once the whole tree is walked — see filterDataLiteralRefs.
|
||||
@@ -874,7 +891,6 @@ public class ProjectIngestService {
|
||||
Set<String> inferredCallTargets = new HashSet<>();
|
||||
Set<String> staticCallTargets = new HashSet<>();
|
||||
List<IngestSummary.Duplicate> duplicates = new ArrayList<>();
|
||||
List<IngestSummary.Failure> failed = new ArrayList<>();
|
||||
List<String> examinedFiles = new ArrayList<>();
|
||||
Set<String> ingestedModuleNames = new HashSet<>();
|
||||
|
||||
@@ -923,7 +939,7 @@ public class ProjectIngestService {
|
||||
String sourceFile = relativeSourceFile(root, candidate.file());
|
||||
examinedFiles.add(sourceFile);
|
||||
ParseResult result = withUserExitMetrics(withShellMetrics(
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
|
||||
astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes, ts),
|
||||
content, candidate.kind().language()), project.generatedDir(), userExit);
|
||||
LOG.infof("Ingesting %s into project '%s' [%s]", moduleNames(result), project.name(), sourceFile);
|
||||
// Full deep parse of this module — reconcile so a by-name refresh purges its stale
|
||||
|
||||
@@ -18,8 +18,15 @@ import java.util.Locale;
|
||||
*
|
||||
* <p>Java classes carry {@link NodeType#MODULE}; Natural {@code .nat}/{@code .nsn} programs are
|
||||
* {@code MODULE}s and {@code .lda}/{@code .pda}/{@code .gda} data areas (local/parameter/global) are
|
||||
* {@link NodeType#DATA_STRUCTURE}s. Files with any other extension are not ingestible and classify
|
||||
* to {@code null}.
|
||||
* {@link NodeType#DATA_STRUCTURE}s; TypeScript {@code .ts}/{@code .tsx} files (not {@code .d.ts}) and
|
||||
* plain {@code .css} files are {@code MODULE}s (item 192). Files with any other extension are not
|
||||
* ingestible and classify to {@code null}.
|
||||
*
|
||||
* <p>Classification is by extension alone; {@link #ingestedBy(Language, String)} says whether a
|
||||
* project of a given declared language picks a file up. Java and Natural files are ingested by every
|
||||
* project (the {@code ac} project carries {@code .cpy} Natural test fixtures next to its Java), but
|
||||
* TypeScript/CSS only by a {@code typescript} project — a Java project with a bundled web UI
|
||||
* ({@code ac-ui}) must not suddenly parse it, and Tier-2 needs that project's own {@code node_modules}.
|
||||
*/
|
||||
final class SourceFiles {
|
||||
|
||||
@@ -41,9 +48,27 @@ final class SourceFiles {
|
||||
if (name.endsWith(".lda") || name.endsWith(".pda") || name.endsWith(".gda")) {
|
||||
return new Kind(Language.NATURAL, NodeType.DATA_STRUCTURE);
|
||||
}
|
||||
if ((name.endsWith(".ts") || name.endsWith(".tsx")) && !name.endsWith(".d.ts")) {
|
||||
return new Kind(Language.TYPESCRIPT, NodeType.MODULE);
|
||||
}
|
||||
if (name.endsWith(".css")) {
|
||||
return new Kind(Language.CSS, NodeType.MODULE);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether a project declaring {@code projectLanguage} ({@code "java"}, {@code "natural"},
|
||||
* {@code "typescript"}, or {@code null} for a legacy project) ingests files of {@code language}
|
||||
* — see the class comment.
|
||||
*/
|
||||
static boolean ingestedBy(Language language, @Nullable String projectLanguage) {
|
||||
return switch (language) {
|
||||
case JAVA, NATURAL -> true;
|
||||
case TYPESCRIPT, CSS -> "typescript".equalsIgnoreCase(projectLanguage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the uppercased filename stem (name without extension), used as the case-insensitive
|
||||
* lookup key for resolving {@code CALLNAT}/{@code PERFORM}/{@code USING}/{@code extends} targets.
|
||||
@@ -84,7 +109,10 @@ final class SourceFiles {
|
||||
return false;
|
||||
}
|
||||
|
||||
enum Language {JAVA, NATURAL}
|
||||
/**
|
||||
* The parser a file goes to. {@code CSS} shares the TypeScript parser but has its own line counter.
|
||||
*/
|
||||
enum Language {JAVA, NATURAL, TYPESCRIPT, CSS}
|
||||
|
||||
/**
|
||||
* Classification of an ingestible source file.
|
||||
|
||||
@@ -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=294
|
||||
agenticcode.version=329
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
@@ -44,3 +44,14 @@ agenticcode.ingest.batch-size=200
|
||||
# persist. Comments are 45.8 % of the upms node population; this exists to measure what they cost.
|
||||
# Not a feature — no REST parameter and no CLI flag. Leave true for normal operation.
|
||||
agenticcode.ingest.comments.enabled=true
|
||||
# Item 192: the TypeScript Tier-2 sidecar (ac-parser-typescript/sidecar/extract.mjs). A `typescript`
|
||||
# project's deep pass runs `node --max-old-space-size=<max-heap-mb> extract.mjs` once per npm
|
||||
# workspace, sequentially (measured ~0.5 GB heap, 3-5 s per workspace on the pur frontend). A bare
|
||||
# `node` is looked up on PATH; dev mode runs from ac-code-server/, hence the relative script path.
|
||||
# The image copies node and the sidecar to /usr/local/bin/node and /work/sidecar (Dockerfile.jvm).
|
||||
agenticcode.typescript.node=node
|
||||
agenticcode.typescript.sidecar-script=../ac-parser-typescript/sidecar/extract.mjs
|
||||
agenticcode.typescript.max-heap-mb=1024
|
||||
agenticcode.typescript.timeout-seconds=600
|
||||
%prod.agenticcode.typescript.node=/usr/local/bin/node
|
||||
%prod.agenticcode.typescript.sidecar-script=/work/sidecar/extract.mjs
|
||||
|
||||
@@ -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,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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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,194 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
/**
|
||||
* Item 196: a frontend with an MUI theme, components styling through sx/style/styled with theme
|
||||
* tokens, constants and hard-coded literals, and a CSS file. After a deep refresh the theme tokens
|
||||
* carry use counts, token reads resolve across files, undeclared tokens are listed as such, and the
|
||||
* style inventory answers. Needs the Node sidecar; skipped otherwise (see {@link CounterpartsIT}).
|
||||
*/
|
||||
@QuarkusTest
|
||||
class StylesIT {
|
||||
|
||||
private static final String PROJECT = "styles-frontend";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@BeforeAll
|
||||
static void ingestFixture() {
|
||||
assumeTrue(CounterpartsIT.sidecarAvailable(), "node + sidecar/node_modules/typescript not installed here");
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
write(root, "package.json", """
|
||||
{"name": "fe", "workspaces": ["app"], "dependencies": {"react": "^18"}}
|
||||
""");
|
||||
Path src = root.resolve("app/src");
|
||||
write(src, "mui.ts", """
|
||||
// stand-in for @mui/material (no node_modules in the fixture)
|
||||
export function createTheme(options: any): any { return options }
|
||||
export function styled(base: any, options?: any): any { return (fn: any) => base }
|
||||
export function useTheme(): any { return {} }
|
||||
export const Box: any = 'Box'
|
||||
""");
|
||||
write(src, "theme.ts", """
|
||||
import { createTheme } from 'mui'
|
||||
export const PRIMARY = '#005CA9'
|
||||
const PRIMARY_DARK = '#0054A2'
|
||||
export const UNUSED_GRAY = '#808080'
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
primary: { main: PRIMARY, dark: PRIMARY_DARK },
|
||||
background: { paper: '#FCFCFD' },
|
||||
},
|
||||
shape: { borderRadius: 4 },
|
||||
})
|
||||
""");
|
||||
// item 199: a second theme file declares the same primary.main (and only that) -> a read of it
|
||||
// resolves onto both declarations, and neither is reported unused
|
||||
write(src, "darkTheme.ts", """
|
||||
import { createTheme } from 'mui'
|
||||
import { PRIMARY } from './theme'
|
||||
export const darkTheme = createTheme({
|
||||
palette: { mode: 'dark', primary: { main: PRIMARY } },
|
||||
})
|
||||
""");
|
||||
write(src.resolve("components"), "Panel.tsx", """
|
||||
import { styled, Box } from 'mui'
|
||||
export const Panel = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(2),
|
||||
color: theme.palette.primary.dark,
|
||||
border: '1px solid #D2D2D2',
|
||||
}))
|
||||
""");
|
||||
write(src.resolve("components"), "Page.tsx", """
|
||||
import { Box, useTheme } from 'mui'
|
||||
import { PRIMARY, theme as appTheme } from 'theme'
|
||||
import { Panel } from './Panel'
|
||||
|
||||
export function Page() {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Panel sx={{ mt: 2, color: PRIMARY, '&:hover': { background: theme.palette.background.paper } }}>
|
||||
<Box style={{ color: appTheme.palette.primary.main, height: '17px' }} borderColor={theme.palette.grey['200']}>x</Box>
|
||||
<Box sx={{ width: '100%' }} />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
""");
|
||||
write(src, "index.css", """
|
||||
body {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
}
|
||||
@font-face {
|
||||
font-family: OpenSans;
|
||||
src: url("/fonts/OpenSans.ttf");
|
||||
}
|
||||
""");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "typescript", null, null))
|
||||
.when().post("/api/projects/" + PROJECT).then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200)
|
||||
.body("failed", empty());
|
||||
}
|
||||
|
||||
private static void write(Path dir, String fileName, String content) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve(fileName), content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static io.restassured.response.Response get(String path) {
|
||||
return given().when().get("/api/projects/" + PROJECT + path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void themeTokensCarryValuesAndUseCounts() {
|
||||
get("/theme").then()
|
||||
.statusCode(200)
|
||||
.body("find { it.token == 'palette.primary.dark' }.value", equalTo("#0054A2"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.constant", equalTo("PRIMARY_DARK"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.kind", equalTo("path"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.declared", equalTo(true))
|
||||
.body("find { it.token == 'palette.primary.dark' }.module", equalTo("app/src/theme"))
|
||||
.body("find { it.token == 'palette.primary.dark' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'palette.primary.main' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'palette.background.paper' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'PRIMARY' }.kind", equalTo("constant"))
|
||||
.body("find { it.token == 'PRIMARY' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'shape.borderRadius' }.value", equalTo("4"))
|
||||
.body("find { it.token == 'shape.borderRadius' }.uses", equalTo(0))
|
||||
// read by the code, declared by no theme: MUI defaults / the spacing function
|
||||
.body("find { it.token == 'palette.grey.200' }.declared", equalTo(false))
|
||||
.body("find { it.token == 'palette.grey.200' }.uses", equalTo(1))
|
||||
.body("find { it.token == 'spacing' }.declared", equalTo(false));
|
||||
get("/theme?unused=true").then().statusCode(200)
|
||||
.body("token", containsInAnyOrder("shape.borderRadius", "UNUSED_GRAY", "palette.mode"));
|
||||
// item 199: two declaring files, one row each, both counting the single read; no placeholder left
|
||||
get("/theme").then().statusCode(200)
|
||||
.body("findAll { it.token == 'palette.primary.main' }.module", containsInAnyOrder("app/src/theme", "app/src/darkTheme"))
|
||||
.body("findAll { it.token == 'palette.primary.main' }.uses", everyItem(equalTo(1)))
|
||||
.body("findAll { it.token == 'palette.primary.main' }.declared", everyItem(equalTo(true)));
|
||||
get("/theme/palette.primary.main/usages").then().statusCode(200)
|
||||
.body("size()", equalTo(1)).body("[0].function", equalTo("Page"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenUsagesNameTheBlockAndTheProperty() {
|
||||
get("/theme/palette.primary.dark/usages").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(1))
|
||||
.body("[0].function", equalTo("Panel"))
|
||||
.body("[0].styleKind", equalTo("styled"))
|
||||
.body("[0].element", equalTo("Box"))
|
||||
.body("[0].property", equalTo("color"))
|
||||
.body("[0].module", equalTo("app/src/components/Panel"))
|
||||
.body("[0].lineNo", equalTo(4));
|
||||
get("/theme/palette.grey.200/usages").then().statusCode(200)
|
||||
.body("[0].function", equalTo("Page"))
|
||||
.body("[0].context", equalTo("borderColor"))
|
||||
.body("[0].styleKind", nullValue());
|
||||
get("/theme/PRIMARY/usages").then().statusCode(200)
|
||||
.body("[0].styleKind", equalTo("sx")).body("[0].property", equalTo("color")).body("[0].element", equalTo("Panel"));
|
||||
get("/theme/nope/usages").then().statusCode(404).body("code", equalTo("TOKEN_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void styleInventoryListsBlocksLiteralsAndCssRules() {
|
||||
get("/styles").then()
|
||||
.statusCode(200)
|
||||
.body("size()", equalTo(6))
|
||||
.body("findAll { it.styleKind == 'sx' }.size()", equalTo(2))
|
||||
.body("find { it.styleKind == 'styled' }.function", equalTo("Panel"))
|
||||
.body("find { it.styleKind == 'styled' }.properties", equalTo("padding,color,border"))
|
||||
.body("find { it.styleKind == 'styled' }.literals", equalTo("1px,#D2D2D2"))
|
||||
.body("find { it.styleKind == 'styled' }.tokens", containsInAnyOrder("spacing", "palette.primary.dark"))
|
||||
.body("find { it.styleKind == 'style' }.literals", equalTo("17px"))
|
||||
.body("find { it.styleKind == 'style' }.tokens", contains("palette.primary.main"))
|
||||
.body("find { it.name == 'Page.sx@8:12' }.properties", equalTo("mt,color,&:hover.background"))
|
||||
.body("find { it.name == 'body@1' }.styleKind", equalTo("css"))
|
||||
.body("find { it.name == 'body@1' }.literals", equalTo("#333333"))
|
||||
.body("find { it.name == '@font-face@5' }.properties", equalTo("font-family,src"));
|
||||
get("/styles?withLiterals=true").then().statusCode(200).body("size()", equalTo(4));
|
||||
get("/styles?kind=css&countOnly=true").then().statusCode(200).body("count", equalTo(2));
|
||||
get("/styles?module=Page").then().statusCode(200).body("size()", equalTo(3));
|
||||
get("/styles?kind=bogus").then().statusCode(400).body("code", equalTo("KIND_UNSUPPORTED"));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link SourceFiles#classify} — the extension → (language, node kind) mapping that
|
||||
@@ -38,6 +40,32 @@ class SourceFilesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void typeScriptAndCssAreModulesButDeclarationFilesAreNot() {
|
||||
// item 192
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-r-vstamm/src/store/slices/agstammSlice.ts")));
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.TYPESCRIPT, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-ui/src/App.tsx")));
|
||||
assertEquals(new SourceFiles.Kind(SourceFiles.Language.CSS, NodeType.MODULE),
|
||||
SourceFiles.classify(Path.of("pur-ui/src/index.css")));
|
||||
assertNull(SourceFiles.classify(Path.of("pur-ui/src/vite-env.d.ts")), "type declarations carry no code");
|
||||
assertNull(SourceFiles.classify(Path.of("pur-ui/src/routeTree.gen.js")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyATypeScriptProjectIngestsTypeScript() {
|
||||
// item 192: a Java project with a bundled web UI (ac-ui) must not start parsing it.
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "typescript"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.CSS, "TypeScript"));
|
||||
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.TYPESCRIPT, "java"));
|
||||
assertFalse(SourceFiles.ingestedBy(SourceFiles.Language.CSS, null));
|
||||
// Java and Natural stay language-agnostic: `ac` carries .cpy Natural fixtures next to its Java.
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.NATURAL, "java"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, "typescript"));
|
||||
assertTrue(SourceFiles.ingestedBy(SourceFiles.Language.JAVA, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownExtensionsAreNotIngestible() {
|
||||
assertNull(SourceFiles.classify(Path.of("WNAUTD0S.meta")));
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
""";
|
||||
@@ -1459,19 +1479,39 @@ public final class CypherQueries {
|
||||
""";
|
||||
/**
|
||||
* Item 52: function-level callers â the {@code FUNCTION} nodes that {@code CALLS} the subroutine/method
|
||||
* {@code $function} defined in module {@code $name} (the intra-module {@code PERFORM} sites, and any
|
||||
* cross-class Java method callers). Complements module-granularity {@link #callers(String)}. Shaped
|
||||
* like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos) so it reuses the same DTO.
|
||||
* {@code $function} defined in module {@code $name}. Complements module-granularity
|
||||
* {@link #callers(String)}. Shaped like the other call-ref queries (name/type/sourceFile/edgeKind/lineNos)
|
||||
* so it reuses the same DTO. Two branches (item 197): the direct {@code FUNCTION -CALLS-> FUNCTION}
|
||||
* edges (Natural {@code PERFORM}, same-class Java calls), and the cross-module calls, which the Java
|
||||
* and TypeScript parsers write as a {@code MODULE -CALLS-> MODULE} edge carrying {@code callerFn} and
|
||||
* {@code calleeMethod}, joined on those two names back to the calling {@code FUNCTION}. Methods are
|
||||
* matched by name, so overloads over-approximate (like {@link #LINK_ARGS_TO_PARAMS_JAVA_CROSS}); a
|
||||
* call whose {@code callerFn} is not a FUNCTION of the calling module (top-level code) has no row here
|
||||
* and shows only in the module-level {@code callers}.
|
||||
*/
|
||||
public static final String FUNCTION_CALLERS = """
|
||||
MATCH (m:MODULE {name: $name, project: $project})
|
||||
WHERE ($sourceFile = '' OR m.sourceFile = $sourceFile)
|
||||
MATCH (m)-[:CONTAINS*0..1]->(callee:FUNCTION {name: $function})
|
||||
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'PERFORM') AS edgeKind,
|
||||
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
|
||||
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
|
||||
CALL {
|
||||
WITH callee
|
||||
MATCH (caller:FUNCTION)-[r:CALLS]->(callee)
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'PERFORM') AS edgeKind,
|
||||
collect({lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, caller.sourceFile),
|
||||
viaCopycode: r.viaCopycode, includedAt: r.includedAt, includePath: r.includePath}) AS sites
|
||||
UNION
|
||||
WITH m, callee
|
||||
MATCH (callerModule:MODULE {project: $project})-[r:CALLS]->(m)
|
||||
WHERE r.callerFn IS NOT NULL AND r.calleeMethod = callee.name
|
||||
AND coalesce(r.manualHidden, false) = false
|
||||
MATCH (callerModule)-[:CONTAINS]->(caller:FUNCTION {name: r.callerFn})
|
||||
RETURN caller.name AS name, caller.type AS type, caller.sourceFile AS sourceFile,
|
||||
coalesce(r.callKind, 'METHOD_CALL') AS edgeKind,
|
||||
collect(DISTINCT {lineNo: r.lineNo, callSiteFile: coalesce(r.originFile, callerModule.sourceFile),
|
||||
viaCopycode: null, includedAt: null, includePath: null}) AS sites
|
||||
}
|
||||
RETURN name, type, sourceFile, edgeKind, sites
|
||||
ORDER BY name
|
||||
""";
|
||||
|
||||
@@ -1526,12 +1566,15 @@ public final class CypherQueries {
|
||||
] AS memberRoots
|
||||
UNWIND (CASE WHEN size(memberRoots) > 0 THEN memberRoots ELSE canon END) AS s
|
||||
MATCH (s)-[:CONTAINS*1..]->(f:AstNode)
|
||||
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
|
||||
// FIELD: a TypeScript interface property (item 193); items 195 counts its bindings
|
||||
WHERE f.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE', 'FIELD']
|
||||
MATCH (p:AstNode)-[:CONTAINS]->(f)
|
||||
WHERE p = s OR (s)-[:CONTAINS*1..]->(p)
|
||||
RETURN DISTINCT f.name AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
|
||||
RETURN DISTINCT coalesce(f.field, f.name) AS name, f.type AS type, f.dataType AS dataType, f.value AS value, p.name AS parent,
|
||||
f.startLine AS startLine, f.endLine AS endLine, f.scope AS scope,
|
||||
f.sourceFile AS sourceFile
|
||||
f.sourceFile AS sourceFile,
|
||||
size([(f)<-[r:READS]-() WHERE r.via = 'binding' | 1]) AS boundReads,
|
||||
size([(f)<-[r:WRITES]-() WHERE r.via = 'binding' | 1]) AS boundWrites
|
||||
""";
|
||||
/**
|
||||
* Deletes placeholder nodes ({@code sourceFile = ""}, type {@code MODULE}/
|
||||
@@ -2702,7 +2745,8 @@ public final class CypherQueries {
|
||||
""";
|
||||
public static final String CREATE_PROJECT = """
|
||||
CREATE (p:Project {name: $name, description: $description, root: $root, excludeDirs: $excludeDirs,
|
||||
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir})
|
||||
language: $language, generatedDir: $generatedDir, userExitDir: $userExitDir,
|
||||
counterparts: $counterparts})
|
||||
""";
|
||||
/**
|
||||
* Updates a project. {@code null} parameters leave the corresponding property unchanged
|
||||
@@ -2716,12 +2760,14 @@ public final class CypherQueries {
|
||||
p.excludeDirs = COALESCE($excludeDirs, p.excludeDirs),
|
||||
p.language = COALESCE($language, p.language),
|
||||
p.generatedDir = COALESCE($generatedDir, p.generatedDir),
|
||||
p.userExitDir = COALESCE($userExitDir, p.userExitDir)
|
||||
p.userExitDir = COALESCE($userExitDir, p.userExitDir),
|
||||
p.counterparts = COALESCE($counterparts, p.counterparts)
|
||||
""";
|
||||
public static final String GET_PROJECT = """
|
||||
MATCH (p:Project {name: $name})
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
p.counterparts AS counterparts,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
@@ -2750,6 +2796,7 @@ public final class CypherQueries {
|
||||
MATCH (p:Project)
|
||||
RETURN p.name AS name, p.description AS description, p.root AS root, p.excludeDirs AS excludeDirs,
|
||||
p.language AS language, p.generatedDir AS generatedDir, p.userExitDir AS userExitDir,
|
||||
p.counterparts AS counterparts,
|
||||
// Item 126: what the last whole-root ingest did. Null on a project last ingested
|
||||
// before this was recorded — "never measured", which is not the same as zero.
|
||||
p.ingestedAt AS ingestedAt, p.ingestMode AS ingestMode,
|
||||
@@ -2847,17 +2894,26 @@ public final class CypherQueries {
|
||||
// identical rows — 183 of 436 on `pur`.
|
||||
""";
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
* The projects whose counterpart edges must be (re)built after {@code $project} was refreshed:
|
||||
* the project itself, and every project that lists it as a counterpart. The per-file reconcile
|
||||
* {@code DETACH DELETE}s a refreshed handler together with the edges pointing at it, so a
|
||||
* backend refresh has to relink from the frontend side too.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' AS outbound
|
||||
public static final String COUNTERPART_HOLDERS = """
|
||||
MATCH (p:Project)
|
||||
WHERE p.name = $project OR $project IN coalesce(p.counterparts, [])
|
||||
RETURN p.name AS name
|
||||
""";
|
||||
/**
|
||||
* Step 3: the fields of a linked DTO, by name.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_FIELD = """
|
||||
MATCH (d:AstNode {project: $project, type: 'DATA_STRUCTURE'})-[:COUNTERPART_OF {via: 'dto'}]->(j)
|
||||
MATCH (d)-[:CONTAINS]->(fd:AstNode {type: 'FIELD'})
|
||||
// a TypeScript field is named <Interface>.<member> (item 195); `field` is the bare member
|
||||
MATCH (j)-[:CONTAINS]->(jf:AstNode {type: 'FIELD', name: coalesce(fd.field, fd.name)})
|
||||
MERGE (fd)-[r:COUNTERPART_OF]->(jf)
|
||||
SET r.via = 'field'
|
||||
""";
|
||||
public static final String REST_ENDPOINTS = REST_ENDPOINTS_CORE + "RETURN " + REST_ENDPOINTS_ROW + """
|
||||
ORDER BY path, httpMethod
|
||||
@@ -4008,6 +4064,383 @@ public final class CypherQueries {
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Item 193: COUNTERPART_OF — the same thing in another project.
|
||||
// ------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Step 0: this project's own counterpart edges are rebuilt from scratch, never accumulated.
|
||||
*/
|
||||
public static final String DELETE_COUNTERPART_EDGES = """
|
||||
MATCH (n:AstNode {project: $project})-[r:COUNTERPART_OF]->()
|
||||
DELETE r
|
||||
""";
|
||||
/**
|
||||
* Step 1: an outbound web-service call ({@code FUNCTION} with {@code outbound='true'},
|
||||
* {@code restPath}, {@code httpMethod} — a TypeScript endpoint, item 193) is linked to the handler
|
||||
* in a counterpart project that serves the same verb and path shape (every {@code {param}}
|
||||
* segment compares as {@code {}}, empty segments are ignored). The handler's path is composed
|
||||
* exactly as {@link #REST_ENDPOINTS} composes it (class-level + method-level {@code @Path},
|
||||
* inherited from the nearest ancestor) — <b>keep the two in step</b>. When several handlers match
|
||||
* (the backend project holds more than one application), the one whose source lives under the
|
||||
* frontend's {@code backend} name wins.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_REST = """
|
||||
MATCH (p:Project {name: $project})
|
||||
UNWIND coalesce(p.counterparts, []) AS cp
|
||||
MATCH (fm:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(f:AstNode {type: 'FUNCTION'})
|
||||
WHERE f.outbound = 'true' AND f.restPath IS NOT NULL AND f.httpMethod IS NOT NULL
|
||||
MATCH (m:AstNode {project: cp, type: 'MODULE'})-[:CONTAINS]->(h:AstNode {type: 'FUNCTION'})
|
||||
WHERE h.httpMethod = f.httpMethod AND h.sourceFile <> ''
|
||||
OPTIONAL MATCH ancestry = (m)-[:EXTENDS|IMPLEMENTS*1..4]->(base:AstNode {type: 'MODULE'})
|
||||
WHERE base.restPath IS NOT NULL
|
||||
WITH f, m, h, base, length(ancestry) AS depth
|
||||
ORDER BY depth
|
||||
WITH f, m, h, head(collect(base.restPath)) AS inheritedPath
|
||||
WITH f, m, h, coalesce(m.restPath, inheritedPath, '') AS classPath, coalesce(h.restPath, '') AS methodPath
|
||||
WITH f, m, h, [x IN [classPath, methodPath] WHERE x <> '' AND x <> '/' |
|
||||
CASE WHEN left(x, 1) = '/' THEN substring(x, 1) ELSE x END] AS lead
|
||||
WITH f, m, h, [x IN lead | CASE WHEN size(x) > 0 AND right(x, 1) = '/' THEN left(x, size(x) - 1) ELSE x END] AS parts
|
||||
WITH f, m, h, '/' + reduce(acc = '', x IN parts | CASE WHEN acc = '' THEN x ELSE acc + '/' + x END) AS hpath
|
||||
WITH f, m, h, reduce(acc = '', s IN split(hpath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS hkey,
|
||||
reduce(acc = '', s IN split(f.restPath, '/') | CASE WHEN s = '' THEN acc WHEN s STARTS WITH '{' THEN acc + '/{}' ELSE acc + '/' + s END) AS fkey
|
||||
WHERE hkey = fkey
|
||||
WITH f, h, CASE WHEN f.backend IS NOT NULL AND m.sourceFile CONTAINS f.backend THEN 0 ELSE 1 END AS pref
|
||||
ORDER BY pref
|
||||
WITH f, collect(h)[0] AS h
|
||||
MERGE (f)-[r:COUNTERPART_OF]->(h)
|
||||
SET r.via = 'rest'
|
||||
""";
|
||||
/**
|
||||
* Step 2: a data structure declared in a <em>generated</em> module (the frontend's mirror of the
|
||||
* backend DTOs) is linked to the counterpart project's class of the same simple name — only when
|
||||
* that name is unique there; an ambiguous name is left unlinked rather than guessed.
|
||||
*/
|
||||
public static final String LINK_COUNTERPARTS_DTO = """
|
||||
MATCH (p:Project {name: $project})
|
||||
UNWIND coalesce(p.counterparts, []) AS cp
|
||||
MATCH (gm:AstNode {project: $project, type: 'MODULE', generated: 'true'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
MATCH (j:AstNode {project: cp, type: 'MODULE', simpleName: d.name})
|
||||
WHERE j.sourceFile <> ''
|
||||
WITH d, collect(DISTINCT j) AS js
|
||||
WHERE size(js) = 1
|
||||
WITH d, js[0] AS j
|
||||
MERGE (d)-[r:COUNTERPART_OF]->(j)
|
||||
SET r.via = 'dto'
|
||||
""";
|
||||
/**
|
||||
* Item 196: redirects {@code REFERENCES} from a theme-token placeholder ({@code FIELD},
|
||||
* {@code sourceFile=""}, {@code theme='true'}) onto every real token of the same name — with a
|
||||
* light and a dark theme file the read reaches whichever is active, so each declaring file's row
|
||||
* counts the use (item 199); an undeclared token (an MUI default, a typo) keeps its
|
||||
* placeholder on purpose — {@code GET /theme} lists it with {@code declared=false}.
|
||||
*/
|
||||
public static final String RESOLVE_THEME_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.theme = 'true'
|
||||
MATCH (real:AstNode {project: $project, type: 'FIELD', name: ph.name})
|
||||
WHERE real.sourceFile <> "" AND real.theme = 'true'
|
||||
WITH ph, collect(real) AS reals
|
||||
MATCH (src:AstNode)-[r:REFERENCES]->(ph)
|
||||
UNWIND reals AS real
|
||||
MERGE (src)-[r2:REFERENCES {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
WITH DISTINCT r
|
||||
DELETE r
|
||||
""".formatted(edgeKey(EdgeType.REFERENCES, "coalesce(r.lineNo, -1)", "r.originFile", "src"));
|
||||
/**
|
||||
* Item 194: a store placeholder whose every access was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_STORE_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD'] AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart listing. Candidates are this project's outbound calls, its generated
|
||||
* data structures and their fields; each row carries its twin, or nulls when {@code $unmatched}
|
||||
* selects exactly the ones without one — "what is not served / not mirrored yet".
|
||||
*/
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 194: the frontend Redux store — slices, fields, reducer/selector accesses
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 194: every slice of the project's store with its state keys and access counts.
|
||||
* {@code $slice} narrows to one slice by reducer key or by RTK slice name.
|
||||
*/
|
||||
public static final String STORE_SLICES = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(s:AstNode {project: $project, type: 'STORE_SLICE'})
|
||||
WHERE s.sourceFile <> '' AND ($slice IS NULL OR s.name = $slice OR s.sliceName = $slice)
|
||||
OPTIONAL MATCH (s)-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
|
||||
WITH m, s, f ORDER BY f.startLine, f.name
|
||||
WITH m, s, collect(CASE WHEN f IS NULL THEN null ELSE {
|
||||
name: f.field, type: f.dataType, optional: f.optional = 'true',
|
||||
reads: size([(f)<-[:READS]-() | 1]), writes: size([(f)<-[:WRITES]-() | 1])} END) AS fields
|
||||
RETURN s.name AS slice, s.sliceName AS sliceName, m.name AS module, s.sourceFile AS sourceFile,
|
||||
s.startLine AS startLine, s.endLine AS endLine, s.stateType AS stateType, fields,
|
||||
size([(m)-[:CONTAINS]->(r:AstNode {type: 'FUNCTION', kind: 'reducer', slice: s.name}) | 1]) AS reducers,
|
||||
size([(s)<-[:READS]-() | 1]) AS sliceReads, size([(s)<-[:WRITES]-() | 1]) AS sliceWrites
|
||||
ORDER BY slice
|
||||
""";
|
||||
/**
|
||||
* Item 195: a binding placeholder whose every edge was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_BINDING_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.binding = 'true' AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
private static final Map<EdgeType, String> RESOLVE_STORE_PLACEHOLDERS = buildResolveStorePlaceholderQueries();
|
||||
/**
|
||||
* Item 194: who reads and writes a slice's state — reducers (the writers, {@code functionKind =
|
||||
* reducer}) and the components/hooks/thunks selecting from it. A row per access site; {@code field}
|
||||
* is null for an access to the whole slice state, {@code path} is the full sub-path as written
|
||||
* ({@code agstammUseCaseSvcResult.result.purMode}), {@code via} the hook or {@code reducer}/{@code getState}.
|
||||
*/
|
||||
private static final String STORE_ACCESSES_CORE = """
|
||||
MATCH (s:AstNode {project: $project, type: 'STORE_SLICE'})
|
||||
WHERE s.sourceFile <> '' AND (s.name = $slice OR s.sliceName = $slice)
|
||||
MATCH (s)-[:CONTAINS*0..1]->(t:AstNode)
|
||||
WHERE (t = s OR t.type = 'FIELD') AND ($field IS NULL OR t.field = $field)
|
||||
MATCH (fn:AstNode)-[r:READS|WRITES]->(t)
|
||||
WHERE $mode IS NULL OR type(r) = $mode
|
||||
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
|
||||
WITH s, t, r, fn, head(collect(om)) AS owner
|
||||
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
|
||||
""";
|
||||
private static final String STORE_ACCESSES_ROW = """
|
||||
type(r) AS mode, s.name AS slice, t.field AS field, r.path AS path, fn.name AS function, fn.type AS functionType,
|
||||
fn.kind AS functionKind, owner.name AS module, fn.sourceFile AS sourceFile, r.lineNo AS lineNo, r.via AS via
|
||||
""";
|
||||
public static final String STORE_ACCESSES = STORE_ACCESSES_CORE + "RETURN " + STORE_ACCESSES_ROW + """
|
||||
ORDER BY mode, field, module, lineNo
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String STORE_ACCESSES_COUNT = STORE_ACCESSES_CORE + "WITH " + STORE_ACCESSES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
private static final Map<EdgeType, String> RESOLVE_BINDING_PLACEHOLDERS = buildResolveBindingPlaceholderQueries();
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 195: DTO field bindings
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 196: a theme placeholder whose every reference was redirected is scaffolding — drop it.
|
||||
*/
|
||||
public static final String DELETE_RESOLVED_THEME_PLACEHOLDERS = """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.theme = 'true' AND NOT (ph)--()
|
||||
DELETE ph
|
||||
""";
|
||||
/**
|
||||
* Item 196: every theme token — declared ones (under the {@code theme} structure) and the
|
||||
* undeclared ones the code reads (placeholders) — with its project-side use count.
|
||||
*/
|
||||
public static final String THEME_TOKENS = """
|
||||
MATCH (t:AstNode {project: $project, type: 'FIELD'})
|
||||
WHERE t.theme = 'true'
|
||||
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS]->(:AstNode {type: 'DATA_STRUCTURE'})-[:CONTAINS]->(t)
|
||||
WITH t, head(collect(m)) AS m, size([(t)<-[:REFERENCES]-() | 1]) AS uses
|
||||
WHERE NOT $unused OR uses = 0
|
||||
RETURN coalesce(t.token, t.name) AS token, t.tokenKind AS kind, t.value AS value, t.constant AS constant,
|
||||
t.sourceFile <> '' AS declared, m.name AS module, t.startLine AS lineNo, uses
|
||||
ORDER BY declared DESC, token
|
||||
""";
|
||||
public static final String BINDINGS = BINDINGS_CORE + "RETURN " + BINDINGS_ROW + """
|
||||
ORDER BY dto, field, mode, module, lineNo
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String BINDINGS_COUNT = BINDINGS_CORE + "WITH " + BINDINGS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Item 196: styling — theme tokens, style blocks
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
/**
|
||||
* Item 196: where one token is read — style blocks (with the key they feed) and plain code/prop reads.
|
||||
*/
|
||||
public static final String THEME_USAGES = """
|
||||
MATCH (t:AstNode {project: $project, type: 'FIELD'})
|
||||
WHERE t.theme = 'true' AND (t.token = $token OR t.name = $token)
|
||||
MATCH (x:AstNode)-[r:REFERENCES]->(t)
|
||||
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(x)
|
||||
WITH r, x, CASE WHEN x.type = 'FUNCTION' THEN x ELSE fn END AS f
|
||||
OPTIONAL MATCH (m:AstNode {type: 'MODULE'})-[:CONTAINS*0..2]->(x)
|
||||
WITH r, x, f, head(collect(m)) AS m
|
||||
// DISTINCT: a read resolved onto two theme files (item 199) is one usage, not two
|
||||
RETURN DISTINCT coalesce(f.name, x.name) AS function, f.kind AS functionKind, m.name AS module, x.sourceFile AS sourceFile,
|
||||
r.lineNo AS lineNo, x.styleKind AS styleKind, x.element AS element, r.property AS property, r.context AS context
|
||||
ORDER BY module, lineNo
|
||||
""";
|
||||
/**
|
||||
* The row projection; {@link #REST_ENDPOINTS_COUNT} must stay distinct over the same columns.
|
||||
*/
|
||||
private static final String REST_ENDPOINTS_ROW = """
|
||||
DISTINCT f.httpMethod AS httpMethod, path AS path,
|
||||
m.name AS module, m.simpleName AS moduleSimpleName, f.name AS handler,
|
||||
f.sourceFile AS sourceFile, f.startLine AS startLine,
|
||||
// A @RegisterRestClient interface declares a call this application *makes*, not one
|
||||
// it serves. Listing those as endpoints (3 in `pur`) states the traffic's direction
|
||||
// backwards; they are flagged rather than dropped, because "who calls out to what"
|
||||
// is a real question too.
|
||||
// Item 193: a TypeScript endpoint FUNCTION (generated web-service client) stamps
|
||||
// outbound='true' itself — the frontend's calls, listed by the same query.
|
||||
(coalesce(m.annotations, '') CONTAINS 'RegisterRestClient' OR f.outbound = 'true') AS outbound
|
||||
""";
|
||||
/**
|
||||
* Item 195: every binding site of the project — a component/hook reading or writing a DTO field
|
||||
* through a generated {@code Fields} path object — with the field's {@code COUNTERPART_OF} twin in
|
||||
* the backend project, so "which page edits Java {@code Broker.ebene}" is one query.
|
||||
*/
|
||||
private static final String BINDINGS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE'})
|
||||
-[:CONTAINS]->(f:AstNode {type: 'FIELD'})
|
||||
WHERE m.sourceFile <> '' AND ($dto IS NULL OR d.name = $dto) AND ($field IS NULL OR f.field = $field)
|
||||
MATCH (fn:AstNode)-[r:READS|WRITES]->(f)
|
||||
WHERE r.via = 'binding' AND ($mode IS NULL OR type(r) = $mode) AND ($partial IS NULL OR r.partial = $partial)
|
||||
OPTIONAL MATCH (om:AstNode {type: 'MODULE'})-[:CONTAINS*0..1]->(fn)
|
||||
WITH d, f, r, fn, head(collect(om)) AS owner
|
||||
WHERE $module IS NULL OR owner.name = $module OR owner.simpleName = $module
|
||||
OPTIONAL MATCH (f)-[:COUNTERPART_OF]->(c:AstNode)
|
||||
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
|
||||
WHERE cm.sourceFile <> ''
|
||||
WITH d, f, r, fn, owner, c, head(collect(cm)) AS cm
|
||||
""";
|
||||
private static final String BINDINGS_ROW = """
|
||||
type(r) AS mode, d.name AS dto, coalesce(f.field, f.name) AS field, r.path AS path, r.rootDto AS rootDto, r.kind AS kind,
|
||||
r.partial = 'true' AS partial, r.component AS component, r.attribute AS attribute,
|
||||
fn.name AS function, fn.type AS functionType, fn.kind AS functionKind, owner.name AS module,
|
||||
fn.sourceFile AS sourceFile, r.lineNo AS lineNo,
|
||||
c.project AS counterpartProject, cm.name AS counterpartModule, c.name AS counterpartField
|
||||
""";
|
||||
/**
|
||||
* Item 196: the style inventory — every sx/style/styled block and CSS rule with keys, literals and tokens.
|
||||
*/
|
||||
private static final String STYLES_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(s:AstNode {type: 'STYLE'})
|
||||
WHERE m.sourceFile <> ''
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
AND ($kind IS NULL OR s.styleKind = $kind)
|
||||
AND (NOT $withLiterals OR (s.literals IS NOT NULL AND s.literals <> ''))
|
||||
OPTIONAL MATCH (fn:AstNode {type: 'FUNCTION'})-[:CONTAINS]->(s)
|
||||
WITH DISTINCT m, s, head(collect(fn)) AS fn
|
||||
""";
|
||||
private static final String STYLES_ROW = """
|
||||
s.name AS name, s.styleKind AS styleKind, s.element AS element, s.selector AS selector, fn.name AS function,
|
||||
m.name AS module, s.sourceFile AS sourceFile, s.startLine AS lineNo, s.properties AS properties,
|
||||
s.literals AS literals, s.dynamic = 'true' AS dynamic,
|
||||
reduce(acc = [], tk IN [(s)-[:REFERENCES]->(t) | coalesce(t.token, t.name)] |
|
||||
CASE WHEN tk IN acc THEN acc ELSE acc + tk END) AS tokens
|
||||
""";
|
||||
public static final String STYLES = STYLES_CORE + "RETURN " + STYLES_ROW + """
|
||||
ORDER BY module, lineNo, name
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String STYLES_COUNT = STYLES_CORE + "WITH " + STYLES_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
private static final String COUNTERPARTS_CORE = """
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*1..2]->(n:AstNode)
|
||||
WHERE m.sourceFile <> ''
|
||||
AND ($module IS NULL OR m.name = $module OR m.simpleName = $module)
|
||||
AND ( (n.type = 'FUNCTION' AND n.outbound = 'true' AND n.restPath IS NOT NULL)
|
||||
OR (n.type = 'DATA_STRUCTURE' AND m.generated = 'true')
|
||||
OR (n.type = 'FIELD' AND m.generated = 'true'))
|
||||
WITH DISTINCT m, n, CASE n.type WHEN 'FUNCTION' THEN 'rest' WHEN 'DATA_STRUCTURE' THEN 'dto' ELSE 'field' END AS kind
|
||||
WHERE $kind IS NULL OR kind = $kind
|
||||
OPTIONAL MATCH (n)-[:COUNTERPART_OF]->(c:AstNode)
|
||||
OPTIONAL MATCH (cm:AstNode {type: 'MODULE'})-[:CONTAINS*1..2]->(c)
|
||||
WHERE cm.sourceFile <> ''
|
||||
WITH m, n, kind, c, head(collect(cm)) AS cm
|
||||
WHERE NOT $unmatched OR c IS NULL
|
||||
""";
|
||||
private static final String COUNTERPARTS_ROW = """
|
||||
kind AS kind, n.name AS name, m.name AS module, n.sourceFile AS sourceFile, n.startLine AS startLine,
|
||||
n.httpMethod AS httpMethod, n.restPath AS path,
|
||||
c.project AS counterpartProject, c.name AS counterpartName,
|
||||
CASE WHEN c IS NULL THEN null WHEN c.type = 'MODULE' THEN c.name ELSE cm.name END AS counterpartModule,
|
||||
c.sourceFile AS counterpartSourceFile, c.startLine AS counterpartStartLine
|
||||
""";
|
||||
public static final String COUNTERPARTS = COUNTERPARTS_CORE + "RETURN " + COUNTERPARTS_ROW + """
|
||||
ORDER BY kind, module, name
|
||||
SKIP $offset LIMIT $limit
|
||||
""";
|
||||
public static final String COUNTERPARTS_COUNT = COUNTERPARTS_CORE + "WITH " + COUNTERPARTS_ROW + """
|
||||
RETURN count(*) AS total
|
||||
""";
|
||||
|
||||
/**
|
||||
* Item 194: redirects {@code READS}/{@code WRITES} from a store placeholder — a {@code STORE_SLICE}
|
||||
* or store {@code FIELD} with {@code sourceFile=""} and {@code store='true'}, minted by the file
|
||||
* that reads {@code state.<key>.<field>} — onto the real node of the same type and name (the slice
|
||||
* file declares {@code <key>.<field>} with {@code store='true'}). Store names are unique by
|
||||
* construction (one reducer key per store), so a plain name match is exact; the generic
|
||||
* placeholder resolver never sees these because it is limited to {@code MODULE}/{@code DATA_STRUCTURE}.
|
||||
* Cheap (bounded by the project's store placeholders), so it runs in every finalize mode.
|
||||
*/
|
||||
public static String resolveStorePlaceholders(EdgeType type) {
|
||||
String query = RESOLVE_STORE_PLACEHOLDERS.get(type);
|
||||
if (query == null) {
|
||||
throw new IllegalArgumentException("Not a store access edge type: " + type);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static Map<EdgeType, String> buildResolveStorePlaceholderQueries() {
|
||||
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
|
||||
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
queries.put(type, """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE ph.store = 'true' AND ph.type IN ['STORE_SLICE', 'FIELD']
|
||||
MATCH (real:AstNode {project: $project, type: ph.type, name: ph.name})
|
||||
WHERE real.sourceFile <> "" AND real.store = 'true'
|
||||
MATCH (src:AstNode)-[r:%s]->(ph)
|
||||
MERGE (src)-[r2:%s {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
DELETE r
|
||||
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 195: redirects {@code READS}/{@code WRITES} from a binding placeholder — a {@code FIELD}
|
||||
* with {@code sourceFile=""}, {@code binding='true'}, {@code owner}, {@code field} and
|
||||
* {@code targetModule} — onto the real {@code FIELD} declared under that {@code DATA_STRUCTURE} in
|
||||
* that module. Exact by construction (module → structure → field), which is why the generic
|
||||
* placeholder resolver (name only, and never a module-owned structure — item 74) is not used.
|
||||
*/
|
||||
public static String resolveBindingPlaceholders(EdgeType type) {
|
||||
String query = RESOLVE_BINDING_PLACEHOLDERS.get(type);
|
||||
if (query == null) {
|
||||
throw new IllegalArgumentException("Not a binding edge type: " + type);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static Map<EdgeType, String> buildResolveBindingPlaceholderQueries() {
|
||||
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
|
||||
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
queries.put(type, """
|
||||
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'FIELD'})
|
||||
WHERE ph.binding = 'true'
|
||||
MATCH (m:AstNode {project: $project, type: 'MODULE', name: ph.targetModule})
|
||||
-[:CONTAINS]->(d:AstNode {type: 'DATA_STRUCTURE', name: ph.owner})
|
||||
-[:CONTAINS]->(real:AstNode {type: 'FIELD', field: ph.field})
|
||||
WHERE m.sourceFile <> "" AND real.sourceFile <> ""
|
||||
MATCH (src:AstNode)-[r:%s]->(ph)
|
||||
MERGE (src)-[r2:%s {%s}]->(real)
|
||||
SET r2 += properties(r)
|
||||
SET r2.lineNo = r.lineNo
|
||||
DELETE r
|
||||
""".formatted(type.name(), type.name(), edgeKey(type, "coalesce(r.lineNo, -1)", "r.originFile", "src")));
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
private static String edgeKey(EdgeType type, String lineNoExpr, String originExpr, String sourceNodeVar) {
|
||||
if (!needsFileDiscriminator(type)) {
|
||||
return "lineNo: %s".formatted(lineNoExpr);
|
||||
@@ -4026,6 +4459,7 @@ public final class CypherQueries {
|
||||
MERGE (a)-[r:%s {%s}]->(b)
|
||||
SET r.value = e.value
|
||||
SET r += e.properties
|
||||
SET r.ingestGen = $ingestGen
|
||||
""".formatted(type.name(), edgeKey(type, "e.lineNo", "e.properties.originFile", "a")));
|
||||
}
|
||||
return queries;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -103,192 +103,11 @@ public class GraphRepository {
|
||||
// -------------------------------------------------------------------------
|
||||
// Call graph
|
||||
// -------------------------------------------------------------------------
|
||||
/**
|
||||
* The ordered enrichment statements: placeholder resolution → bare-include redirection →
|
||||
* placeholder cleanup → dataflow → polymorphic fan-out. The order matters (e.g. dataflow and
|
||||
* fan-out must see resolved {@code CALLS} edges), but the steps need not be atomic together —
|
||||
* {@link #finalizeProject} runs each in its own transaction, and each is idempotent.
|
||||
*
|
||||
* <p>When {@code scoped} is {@code true} (a per-program deep ingest, see
|
||||
* {@link #finalizeProjectScoped}) the field-resolution and dataflow steps use the
|
||||
* {@code $names}-scoped query variants, while call-graph resolution, placeholder cleanup, and
|
||||
* CHA fan-out remain project-wide (cheap, idempotent). The step list is otherwise identical, so
|
||||
* a scoped run produces the same graph as the project-wide FULL run restricted to {@code $names}.
|
||||
*/
|
||||
private static List<EnrichmentStep> enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) {
|
||||
String sfx = scoped ? "-scoped" : "";
|
||||
List<EnrichmentStep> statements = new ArrayList<>();
|
||||
// Call-graph / module-level placeholder resolution (cheap) — always run, including for a
|
||||
// call-graph-only ingest. Resolves CALLS (callers/callees), INCLUDES, USES_TYPE,
|
||||
// EXTENDS/IMPLEMENTS. Project-wide in both modes (no scoped variant). Note: when field
|
||||
// resolution is skipped we deliberately do NOT delete placeholders, so a later (scoped) deep
|
||||
// ingest can still resolve the field placeholders left behind.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-placeholder " + type, CypherQueries.resolvePlaceholderTargets(type)));
|
||||
}
|
||||
// J7: resolve Panache-ness inherited through a project base class (needs EXTENDS pointing at
|
||||
// real nodes, i.e. after the placeholder loop above) by setting repositoryEntity on the
|
||||
// concrete repository; must run before resolve-java-db-access, which reads it. Project-wide,
|
||||
// idempotent (plain SET).
|
||||
// Item 117: attach references the parser could not qualify (no import proved the type) to the
|
||||
// module they mean. Runs FIRST: every later step joins on module edges or on a module's
|
||||
// sourceFile — the Java DB resolvers, the inheritance materialization, the cross-class
|
||||
// argument→parameter wiring and the CHA fan-out. Placed late, it left those joins reading a
|
||||
// placeholder that was about to disappear, which silently emptied cross-class dataflow.
|
||||
statements.add(new EnrichmentStep("resolve-simple-name-references",
|
||||
CypherQueries.RESOLVE_SIMPLE_NAME_REFERENCES));
|
||||
statements.add(new EnrichmentStep("resolve-panache-inherited-entity", CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY));
|
||||
// J1: resolve Java JPA/Panache DB_ACCESS candidates to the entity's DB_TABLE (READS/WRITES for
|
||||
// db-accesses, USES_TYPE for sql-statements). Depends only on persisted MAPS_TO + node
|
||||
// properties, so it runs in every mode; project-wide and idempotent (all MERGE).
|
||||
statements.add(new EnrichmentStep("resolve-java-db-access", CypherQueries.RESOLVE_JAVA_DB_ACCESS));
|
||||
// J1b: resolve @Query JPQL/native-SQL DB_ACCESS candidates the same way. JPQL needs MAPS_TO
|
||||
// (entity -> table); native SQL matches a literal DB_TABLE name directly, no dependency.
|
||||
statements.add(new EnrichmentStep("resolve-java-query-jpql", CypherQueries.RESOLVE_JAVA_QUERY_JPQL));
|
||||
statements.add(new EnrichmentStep("resolve-java-query-native-sql", CypherQueries.RESOLVE_JAVA_QUERY_NATIVE_SQL));
|
||||
// Item 140: a project with no DB_TABLE cannot resolve a single Java DB_ACCESS candidate, so
|
||||
// everything the over-approximating parse-time heuristic emitted there is a false positive.
|
||||
// Reap it (Java only — a Natural READ/FIND is a real access even with an unresolved view).
|
||||
// Must follow all three resolvers above.
|
||||
statements.add(new EnrichmentStep("reap-java-db-access-without-tables",
|
||||
CypherQueries.REAP_JAVA_DB_ACCESS_WITHOUT_TABLES));
|
||||
// Reap self-EXTENDS/IMPLEMENTS edges (a class cannot extend/implement itself) before any step
|
||||
// traverses the inheritance graph — clears stale name-collision edges a non-wiping refresh leaves.
|
||||
statements.add(new EnrichmentStep("delete-self-inheritance-edges", CypherQueries.DELETE_SELF_INHERITANCE_EDGES));
|
||||
// J3: materialize interface -> implementation edges (needs IMPLEMENTS pointing at real nodes,
|
||||
// i.e. after the placeholder loop above). Project-wide, idempotent.
|
||||
statements.add(new EnrichmentStep("build-implemented-by", CypherQueries.BUILD_IMPLEMENTED_BY));
|
||||
// J4: materialize base-method -> subclass-override edges (needs EXTENDS resolved). Idempotent.
|
||||
statements.add(new EnrichmentStep("build-overridden-by", CypherQueries.BUILD_OVERRIDDEN_BY));
|
||||
// Reap prior synthetic INHERITANCE edges so a refresh (which does not wipe the graph) rebuilds
|
||||
// them fresh with current properties/gates — otherwise a stale phantom or a missing originFile
|
||||
// survives ON CREATE. Must precede every *_TO_SUBCLASSES / *_TO_IMPLEMENTATIONS materializer.
|
||||
statements.add(new EnrichmentStep("delete-synthetic-inheritance-edges", CypherQueries.DELETE_SYNTHETIC_INHERITANCE_EDGES));
|
||||
// Item 31: materialize ancestor-declared REFERENCES/INJECTS wiring onto concrete subclasses
|
||||
// (needs EXTENDS/REFERENCES/INJECTS resolved). Project-wide, idempotent. Each inherited edge
|
||||
// carries originFile=base file so callees sites attribute the base-class lineNo to the base file.
|
||||
statements.add(new EnrichmentStep("link-references-to-subclasses", CypherQueries.LINK_REFERENCES_TO_SUBCLASSES));
|
||||
statements.add(new EnrichmentStep("link-injects-to-subclasses", CypherQueries.LINK_INJECTS_TO_SUBCLASSES));
|
||||
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA));
|
||||
// Item 83: constant-fold string-assembled dynamic CALLNAT targets (base literal + SUBSTR
|
||||
// overlays → folded module name). Cheap (bounded by dynamic call sites), all modes. Skips sites
|
||||
// with a manual override so item-82 overrides still win. Runs right after the direct-literal
|
||||
// resolver and before apply-manual/placeholder cleanup.
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-fold" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD));
|
||||
if (resolveFields) {
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT));
|
||||
// FIELD-level resolution (expensive on a whole codebase — ~minutes per step on a large
|
||||
// project, dominated by the WRITES pass). Done globally only for a deep whole-root ingest;
|
||||
// otherwise deferred to a scoped per-program deep ingest (scoped=true).
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-field-placeholder" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolvePlaceholderFieldTargetsScoped(type) : CypherQueries.resolvePlaceholderFieldTargets(type)));
|
||||
}
|
||||
// Fallback by global name for the remainder whose module has no matching INCLUDES edge
|
||||
// (GLOBAL USING / copycode). Runs after the INCLUDES-scoped pass deleted what it
|
||||
// resolved, so it only sees the small leftover set.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-field-placeholder-by-name" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolvePlaceholderFieldTargetsByNameScoped(type) : CypherQueries.resolvePlaceholderFieldTargetsByName(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-field-contains", CypherQueries.DELETE_RESOLVED_PLACEHOLDER_FIELD_CONTAINS));
|
||||
// Item 18: redirect bare (unqualified) references to an included field onto the matching
|
||||
// real field, when uniquely resolvable through the module's includes.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-bare-included" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolveBareIncludedFieldTargetsScoped(type) : CypherQueries.resolveBareIncludedFieldTargets(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-bare-placeholder-contains", CypherQueries.DELETE_RESOLVED_BARE_PLACEHOLDER_CONTAINS));
|
||||
// Only safe once field placeholders are resolved — otherwise it would reap placeholder
|
||||
// structures a later scoped deep ingest still needs to resolve their fields.
|
||||
statements.add(new EnrichmentStep("delete-resolved-placeholders", CypherQueries.DELETE_RESOLVED_PLACEHOLDERS));
|
||||
}
|
||||
if (dataflow) {
|
||||
// Dataflow: map CALLNAT arguments to callee parameters by position. Expensive on a whole
|
||||
// codebase (on `upms`, link-args-to-params ran ~25 min), so — like field resolution — it
|
||||
// is NOT in the fast whole-root pass; it runs only for a `deep=true` whole-root ingest or,
|
||||
// scoped, for a per-program deep ingest (which fans out top-down from the analyzed
|
||||
// program). Cross-module dynamic CALLNAT dispatch builds on the ARG_TO_PARAM edges below,
|
||||
// so it is likewise deferred to those paths.
|
||||
statements.add(new EnrichmentStep("link-args-to-params" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS));
|
||||
statements.add(new EnrichmentStep("link-args-to-params-java" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA));
|
||||
// J5: cross-class (MODULE->MODULE) Java arg->param dataflow, so flow-forward/backward span classes.
|
||||
statements.add(new EnrichmentStep("link-args-to-params-java-cross" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS));
|
||||
// Cross-module dynamic CALLNAT resolution: follows ARG_TO_PARAM (built just above).
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-cross" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS));
|
||||
}
|
||||
// Polymorphic call resolution: fan class-level CALLS edges that target an interface/base
|
||||
// type out to its implementations/subclasses (CHA). Runs last so it sees resolved
|
||||
// CALLS + IMPLEMENTS/EXTENDS edges and doesn't perturb dataflow. Project-wide in both modes.
|
||||
// Item 116b: resolve calls on inherited fields before the polymorphic fan-out, so an edge
|
||||
// recovered here is itself eligible for CHA expansion — a repository interface reached through
|
||||
// an inherited field should fan out to its implementations like any other.
|
||||
statements.add(new EnrichmentStep("resolve-inherited-field-receivers",
|
||||
CypherQueries.RESOLVE_INHERITED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("delete-unresolved-field-receivers",
|
||||
CypherQueries.DELETE_UNRESOLVED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("link-calls-to-implementations", CypherQueries.LINK_CALLS_TO_IMPLEMENTATIONS));
|
||||
// Item 82: apply human/agent-set manual overrides for dynamic CALLNAT sites the auto-resolvers
|
||||
// couldn't reach. Runs after the auto dynamic-CALLNAT resolvers and before the placeholder
|
||||
// cleanup below, so a manually-resolved marker is flagged manualHidden (kept for inline reset)
|
||||
// rather than deleted. Project-wide in both modes; idempotent (MERGE); a no-op when there are
|
||||
// no overrides. The :DynamicCallOverride nodes it reads survived the refresh (not :AstNode).
|
||||
// Item 83: a manual override wins over an auto-fold — drop a stale folded edge at an overridden
|
||||
// site so apply-manual (next) can pin the human/agent target. Runs right before apply-manual.
|
||||
statements.add(new EnrichmentStep("delete-folded-overridden-dynamic-callnat", CypherQueries.DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT));
|
||||
statements.add(new EnrichmentStep("apply-manual-dynamic-callnat", CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT));
|
||||
// Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
|
||||
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx,
|
||||
scoped ? CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES_SCOPED : CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES));
|
||||
// Item 62: reap placeholder MODULEs that are really data literals (browse keys) mis-recovered as
|
||||
// call targets by dynamic-CALLNAT constant propagation or include-macro accessor extraction.
|
||||
// Runs after the marker cleanup above (a marker that resolved is already gone) and before the
|
||||
// stamping below (so the flags reflect the reaped graph). Project-wide in both modes: the test
|
||||
// is name-based and globally true, and it only removes edges that are false everywhere.
|
||||
statements.add(new EnrichmentStep("delete-data-literal-call-placeholders",
|
||||
CypherQueries.DELETE_DATA_LITERAL_CALL_PLACEHOLDERS));
|
||||
// Item 88: reap DB_TABLE/WORKFILE placeholders left edgeless by item-86 edge reaping (a former
|
||||
// phantom `WORK`/`NUMBER` whose access edges are gone) — the node sweep never touches
|
||||
// sourceFile="" placeholders, so they would otherwise linger in the DB-table inventory forever.
|
||||
// Item 98: redirect DML that names a view alias declared in a `USING` data area onto the real
|
||||
// table. Must run before the orphan reaper below, which then removes the alias DB_TABLE left
|
||||
// edgeless by the redirect. Project-wide: the join is scoped by each module's own USING set, so
|
||||
// it is correct regardless of which modules were re-ingested.
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-tables READS",
|
||||
CypherQueries.resolveViewAliasTables(EdgeType.READS)));
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-tables WRITES",
|
||||
CypherQueries.resolveViewAliasTables(EdgeType.WRITES)));
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
|
||||
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
|
||||
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
|
||||
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
|
||||
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
|
||||
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
|
||||
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
|
||||
// Item 68: project the finished call graph onto module->module CALLS_MODULE edges, so a
|
||||
// traversal can be bounded in module hops instead of raw CALLS hops. Must come after every step
|
||||
// that adds CALLS edges (placeholder + dynamic-CALLNAT resolution, link-calls-to-implementations)
|
||||
// or removes them (the item-62 data-literal reaping above) — it is a projection, so it is only
|
||||
// as correct as the graph at the moment it runs.
|
||||
// The DELETE is what makes it correct across refreshes, not just idempotent: MERGE re-creates
|
||||
// what still exists but never removes what no longer should (the item-58 sweep deletes stale
|
||||
// *nodes* only, so a surviving module's edges are never reaped).
|
||||
statements.add(new EnrichmentStep("delete-calls-module" + sfx,
|
||||
scoped ? CypherQueries.DELETE_CALLS_MODULE_SCOPED : CypherQueries.DELETE_CALLS_MODULE));
|
||||
statements.add(new EnrichmentStep("build-calls-module" + sfx,
|
||||
scoped ? CypherQueries.BUILD_CALLS_MODULE_SCOPED : CypherQueries.BUILD_CALLS_MODULE));
|
||||
return statements;
|
||||
}
|
||||
private static final List<EnrichmentStep> COUNTERPART_STEPS = List.of(
|
||||
new EnrichmentStep("delete-counterpart-edges", CypherQueries.DELETE_COUNTERPART_EDGES),
|
||||
new EnrichmentStep("link-counterparts-rest", CypherQueries.LINK_COUNTERPARTS_REST),
|
||||
new EnrichmentStep("link-counterparts-dto", CypherQueries.LINK_COUNTERPARTS_DTO),
|
||||
new EnrichmentStep("link-counterparts-field", CypherQueries.LINK_COUNTERPARTS_FIELD));
|
||||
|
||||
/**
|
||||
* Strips a single leading Natural sigil ({@code #}, {@code &}, {@code +}) so name search is sigil-insensitive.
|
||||
@@ -901,19 +720,206 @@ public class GraphRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static DataStructureField toDataStructureField(Record record) {
|
||||
return new DataStructureField(
|
||||
record.get("name").asString(),
|
||||
record.get("type").asString(),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("parent").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|
||||
|| record.get("sourceFile").asString().isEmpty()
|
||||
? null : record.get("sourceFile").asString());
|
||||
/**
|
||||
* The ordered enrichment statements: placeholder resolution → bare-include redirection →
|
||||
* placeholder cleanup → dataflow → polymorphic fan-out. The order matters (e.g. dataflow and
|
||||
* fan-out must see resolved {@code CALLS} edges), but the steps need not be atomic together —
|
||||
* {@link #finalizeProject} runs each in its own transaction, and each is idempotent.
|
||||
*
|
||||
* <p>When {@code scoped} is {@code true} (a per-program deep ingest, see
|
||||
* {@link #finalizeProjectScoped}) the field-resolution and dataflow steps use the
|
||||
* {@code $names}-scoped query variants, while call-graph resolution, placeholder cleanup, and
|
||||
* CHA fan-out remain project-wide (cheap, idempotent). The step list is otherwise identical, so
|
||||
* a scoped run produces the same graph as the project-wide FULL run restricted to {@code $names}.
|
||||
*/
|
||||
private static List<EnrichmentStep> enrichmentSteps(boolean dataflow, boolean resolveFields, boolean scoped) {
|
||||
String sfx = scoped ? "-scoped" : "";
|
||||
List<EnrichmentStep> statements = new ArrayList<>();
|
||||
// Call-graph / module-level placeholder resolution (cheap) — always run, including for a
|
||||
// call-graph-only ingest. Resolves CALLS (callers/callees), INCLUDES, USES_TYPE,
|
||||
// EXTENDS/IMPLEMENTS. Project-wide in both modes (no scoped variant). Note: when field
|
||||
// resolution is skipped we deliberately do NOT delete placeholders, so a later (scoped) deep
|
||||
// ingest can still resolve the field placeholders left behind.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-placeholder " + type, CypherQueries.resolvePlaceholderTargets(type)));
|
||||
}
|
||||
// Item 194: store READS/WRITES from selectors and reducers onto the real slice/field nodes.
|
||||
// Cheap (bounded by the store placeholders of a TypeScript project, none elsewhere) and
|
||||
// exact by name, so it runs in every mode and its placeholders are dropped right away.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-store-placeholder " + type, CypherQueries.resolveStorePlaceholders(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-store-placeholders", CypherQueries.DELETE_RESOLVED_STORE_PLACEHOLDERS));
|
||||
// Item 195: DTO field bindings onto the generated interface's FIELD (module -> structure -> field).
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-binding-placeholder " + type, CypherQueries.resolveBindingPlaceholders(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-binding-placeholders", CypherQueries.DELETE_RESOLVED_BINDING_PLACEHOLDERS));
|
||||
// Item 196: theme-token reads onto the declared token (exact name, one theme per project).
|
||||
statements.add(new EnrichmentStep("resolve-theme-placeholder", CypherQueries.RESOLVE_THEME_PLACEHOLDERS));
|
||||
statements.add(new EnrichmentStep("delete-resolved-theme-placeholders", CypherQueries.DELETE_RESOLVED_THEME_PLACEHOLDERS));
|
||||
// J7: resolve Panache-ness inherited through a project base class (needs EXTENDS pointing at
|
||||
// real nodes, i.e. after the placeholder loop above) by setting repositoryEntity on the
|
||||
// concrete repository; must run before resolve-java-db-access, which reads it. Project-wide,
|
||||
// idempotent (plain SET).
|
||||
// Item 117: attach references the parser could not qualify (no import proved the type) to the
|
||||
// module they mean. Runs FIRST: every later step joins on module edges or on a module's
|
||||
// sourceFile — the Java DB resolvers, the inheritance materialization, the cross-class
|
||||
// argument→parameter wiring and the CHA fan-out. Placed late, it left those joins reading a
|
||||
// placeholder that was about to disappear, which silently emptied cross-class dataflow.
|
||||
statements.add(new EnrichmentStep("resolve-simple-name-references",
|
||||
CypherQueries.RESOLVE_SIMPLE_NAME_REFERENCES));
|
||||
statements.add(new EnrichmentStep("resolve-panache-inherited-entity", CypherQueries.RESOLVE_PANACHE_INHERITED_ENTITY));
|
||||
// J1: resolve Java JPA/Panache DB_ACCESS candidates to the entity's DB_TABLE (READS/WRITES for
|
||||
// db-accesses, USES_TYPE for sql-statements). Depends only on persisted MAPS_TO + node
|
||||
// properties, so it runs in every mode; project-wide and idempotent (all MERGE).
|
||||
statements.add(new EnrichmentStep("resolve-java-db-access", CypherQueries.RESOLVE_JAVA_DB_ACCESS));
|
||||
// J1b: resolve @Query JPQL/native-SQL DB_ACCESS candidates the same way. JPQL needs MAPS_TO
|
||||
// (entity -> table); native SQL matches a literal DB_TABLE name directly, no dependency.
|
||||
statements.add(new EnrichmentStep("resolve-java-query-jpql", CypherQueries.RESOLVE_JAVA_QUERY_JPQL));
|
||||
statements.add(new EnrichmentStep("resolve-java-query-native-sql", CypherQueries.RESOLVE_JAVA_QUERY_NATIVE_SQL));
|
||||
// Item 140: a project with no DB_TABLE cannot resolve a single Java DB_ACCESS candidate, so
|
||||
// everything the over-approximating parse-time heuristic emitted there is a false positive.
|
||||
// Reap it (Java only — a Natural READ/FIND is a real access even with an unresolved view).
|
||||
// Must follow all three resolvers above.
|
||||
statements.add(new EnrichmentStep("reap-java-db-access-without-tables",
|
||||
CypherQueries.REAP_JAVA_DB_ACCESS_WITHOUT_TABLES));
|
||||
// Reap self-EXTENDS/IMPLEMENTS edges (a class cannot extend/implement itself) before any step
|
||||
// traverses the inheritance graph — clears stale name-collision edges a non-wiping refresh leaves.
|
||||
statements.add(new EnrichmentStep("delete-self-inheritance-edges", CypherQueries.DELETE_SELF_INHERITANCE_EDGES));
|
||||
// J3: materialize interface -> implementation edges (needs IMPLEMENTS pointing at real nodes,
|
||||
// i.e. after the placeholder loop above). Project-wide, idempotent.
|
||||
statements.add(new EnrichmentStep("build-implemented-by", CypherQueries.BUILD_IMPLEMENTED_BY));
|
||||
// J4: materialize base-method -> subclass-override edges (needs EXTENDS resolved). Idempotent.
|
||||
statements.add(new EnrichmentStep("build-overridden-by", CypherQueries.BUILD_OVERRIDDEN_BY));
|
||||
// Reap prior synthetic INHERITANCE edges so a refresh (which does not wipe the graph) rebuilds
|
||||
// them fresh with current properties/gates — otherwise a stale phantom or a missing originFile
|
||||
// survives ON CREATE. Must precede every *_TO_SUBCLASSES / *_TO_IMPLEMENTATIONS materializer.
|
||||
statements.add(new EnrichmentStep("delete-synthetic-inheritance-edges", CypherQueries.DELETE_SYNTHETIC_INHERITANCE_EDGES));
|
||||
// Item 31: materialize ancestor-declared REFERENCES/INJECTS wiring onto concrete subclasses
|
||||
// (needs EXTENDS/REFERENCES/INJECTS resolved). Project-wide, idempotent. Each inherited edge
|
||||
// carries originFile=base file so callees sites attribute the base-class lineNo to the base file.
|
||||
statements.add(new EnrichmentStep("link-references-to-subclasses", CypherQueries.LINK_REFERENCES_TO_SUBCLASSES));
|
||||
statements.add(new EnrichmentStep("link-injects-to-subclasses", CypherQueries.LINK_INJECTS_TO_SUBCLASSES));
|
||||
// Resolve intra-module dynamic CALLNAT <var> sites to real CALLS edges (cheap, all modes).
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA));
|
||||
// Item 83: constant-fold string-assembled dynamic CALLNAT targets (base literal + SUBSTR
|
||||
// overlays → folded module name). Cheap (bounded by dynamic call sites), all modes. Skips sites
|
||||
// with a manual override so item-82 overrides still win. Runs right after the direct-literal
|
||||
// resolver and before apply-manual/placeholder cleanup.
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-fold" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_FOLD));
|
||||
if (resolveFields) {
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-intra-indirect" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_INTRA_INDIRECT));
|
||||
// FIELD-level resolution (expensive on a whole codebase — ~minutes per step on a large
|
||||
// project, dominated by the WRITES pass). Done globally only for a deep whole-root ingest;
|
||||
// otherwise deferred to a scoped per-program deep ingest (scoped=true).
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-field-placeholder" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolvePlaceholderFieldTargetsScoped(type) : CypherQueries.resolvePlaceholderFieldTargets(type)));
|
||||
}
|
||||
// Fallback by global name for the remainder whose module has no matching INCLUDES edge
|
||||
// (GLOBAL USING / copycode). Runs after the INCLUDES-scoped pass deleted what it
|
||||
// resolved, so it only sees the small leftover set.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-field-placeholder-by-name" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolvePlaceholderFieldTargetsByNameScoped(type) : CypherQueries.resolvePlaceholderFieldTargetsByName(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-resolved-field-contains", CypherQueries.DELETE_RESOLVED_PLACEHOLDER_FIELD_CONTAINS));
|
||||
// Item 18: redirect bare (unqualified) references to an included field onto the matching
|
||||
// real field, when uniquely resolvable through the module's includes.
|
||||
for (EdgeType type : CypherQueries.RESOLVABLE_FIELD_EDGE_TYPES) {
|
||||
statements.add(new EnrichmentStep("resolve-bare-included" + sfx + " " + type,
|
||||
scoped ? CypherQueries.resolveBareIncludedFieldTargetsScoped(type) : CypherQueries.resolveBareIncludedFieldTargets(type)));
|
||||
}
|
||||
statements.add(new EnrichmentStep("delete-bare-placeholder-contains", CypherQueries.DELETE_RESOLVED_BARE_PLACEHOLDER_CONTAINS));
|
||||
// Only safe once field placeholders are resolved — otherwise it would reap placeholder
|
||||
// structures a later scoped deep ingest still needs to resolve their fields.
|
||||
statements.add(new EnrichmentStep("delete-resolved-placeholders", CypherQueries.DELETE_RESOLVED_PLACEHOLDERS));
|
||||
}
|
||||
if (dataflow) {
|
||||
// Dataflow: map CALLNAT arguments to callee parameters by position. Expensive on a whole
|
||||
// codebase (on `upms`, link-args-to-params ran ~25 min), so — like field resolution — it
|
||||
// is NOT in the fast whole-root pass; it runs only for a `deep=true` whole-root ingest or,
|
||||
// scoped, for a per-program deep ingest (which fans out top-down from the analyzed
|
||||
// program). Cross-module dynamic CALLNAT dispatch builds on the ARG_TO_PARAM edges below,
|
||||
// so it is likewise deferred to those paths.
|
||||
statements.add(new EnrichmentStep("link-args-to-params" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS));
|
||||
statements.add(new EnrichmentStep("link-args-to-params-java" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA));
|
||||
// J5: cross-class (MODULE->MODULE) Java arg->param dataflow, so flow-forward/backward span classes.
|
||||
statements.add(new EnrichmentStep("link-args-to-params-java-cross" + sfx,
|
||||
scoped ? CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS_SCOPED : CypherQueries.LINK_ARGS_TO_PARAMS_JAVA_CROSS));
|
||||
// Cross-module dynamic CALLNAT resolution: follows ARG_TO_PARAM (built just above).
|
||||
statements.add(new EnrichmentStep("resolve-dynamic-callnat-cross" + sfx,
|
||||
scoped ? CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS_SCOPED : CypherQueries.RESOLVE_DYNAMIC_CALLNAT_CROSS));
|
||||
}
|
||||
// Polymorphic call resolution: fan class-level CALLS edges that target an interface/base
|
||||
// type out to its implementations/subclasses (CHA). Runs last so it sees resolved
|
||||
// CALLS + IMPLEMENTS/EXTENDS edges and doesn't perturb dataflow. Project-wide in both modes.
|
||||
// Item 116b: resolve calls on inherited fields before the polymorphic fan-out, so an edge
|
||||
// recovered here is itself eligible for CHA expansion — a repository interface reached through
|
||||
// an inherited field should fan out to its implementations like any other.
|
||||
statements.add(new EnrichmentStep("resolve-inherited-field-receivers",
|
||||
CypherQueries.RESOLVE_INHERITED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("delete-unresolved-field-receivers",
|
||||
CypherQueries.DELETE_UNRESOLVED_FIELD_RECEIVERS));
|
||||
statements.add(new EnrichmentStep("link-calls-to-implementations", CypherQueries.LINK_CALLS_TO_IMPLEMENTATIONS));
|
||||
// Item 82: apply human/agent-set manual overrides for dynamic CALLNAT sites the auto-resolvers
|
||||
// couldn't reach. Runs after the auto dynamic-CALLNAT resolvers and before the placeholder
|
||||
// cleanup below, so a manually-resolved marker is flagged manualHidden (kept for inline reset)
|
||||
// rather than deleted. Project-wide in both modes; idempotent (MERGE); a no-op when there are
|
||||
// no overrides. The :DynamicCallOverride nodes it reads survived the refresh (not :AstNode).
|
||||
// Item 83: a manual override wins over an auto-fold — drop a stale folded edge at an overridden
|
||||
// site so apply-manual (next) can pin the human/agent target. Runs right before apply-manual.
|
||||
statements.add(new EnrichmentStep("delete-folded-overridden-dynamic-callnat", CypherQueries.DELETE_FOLDED_OVERRIDDEN_DYNAMIC_CALLNAT));
|
||||
statements.add(new EnrichmentStep("apply-manual-dynamic-callnat", CypherQueries.APPLY_MANUAL_DYNAMIC_CALLNAT));
|
||||
// Drop the now-unresolved dynamic-call markers (placeholder edges); resolved edges remain.
|
||||
statements.add(new EnrichmentStep("delete-dynamic-callnat-placeholders" + sfx,
|
||||
scoped ? CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES_SCOPED : CypherQueries.DELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGES));
|
||||
// Item 62: reap placeholder MODULEs that are really data literals (browse keys) mis-recovered as
|
||||
// call targets by dynamic-CALLNAT constant propagation or include-macro accessor extraction.
|
||||
// Runs after the marker cleanup above (a marker that resolved is already gone) and before the
|
||||
// stamping below (so the flags reflect the reaped graph). Project-wide in both modes: the test
|
||||
// is name-based and globally true, and it only removes edges that are false everywhere.
|
||||
statements.add(new EnrichmentStep("delete-data-literal-call-placeholders",
|
||||
CypherQueries.DELETE_DATA_LITERAL_CALL_PLACEHOLDERS));
|
||||
// Item 88: reap DB_TABLE/WORKFILE placeholders left edgeless by item-86 edge reaping (a former
|
||||
// phantom `WORK`/`NUMBER` whose access edges are gone) — the node sweep never touches
|
||||
// sourceFile="" placeholders, so they would otherwise linger in the DB-table inventory forever.
|
||||
// Item 98: redirect DML that names a view alias declared in a `USING` data area onto the real
|
||||
// table. Must run before the orphan reaper below, which then removes the alias DB_TABLE left
|
||||
// edgeless by the redirect. Project-wide: the join is scoped by each module's own USING set, so
|
||||
// it is correct regardless of which modules were re-ingested.
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-tables READS",
|
||||
CypherQueries.resolveViewAliasTables(EdgeType.READS)));
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-tables WRITES",
|
||||
CypherQueries.resolveViewAliasTables(EdgeType.WRITES)));
|
||||
statements.add(new EnrichmentStep("resolve-view-alias-access-nodes",
|
||||
CypherQueries.RESOLVE_VIEW_ALIAS_ACCESS_NODES));
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-tables", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_TABLES));
|
||||
// Item 124: the same for call-target placeholders left edgeless by the CALLS reap — otherwise a
|
||||
// phantom target of a since-fixed parser bug keeps showing up as an unresolved module.
|
||||
statements.add(new EnrichmentStep("delete-orphaned-placeholder-modules", CypherQueries.DELETE_ORPHANED_PLACEHOLDER_MODULES));
|
||||
// Item 40: flag surviving placeholders as (un)resolved by whether a real definition now exists.
|
||||
// Runs last so it sees the fully redirected/reaped graph. Project-wide, cheap, idempotent.
|
||||
statements.add(new EnrichmentStep("stamp-unresolved-placeholders", CypherQueries.STAMP_UNRESOLVED_PLACEHOLDERS));
|
||||
// Item 68: project the finished call graph onto module->module CALLS_MODULE edges, so a
|
||||
// traversal can be bounded in module hops instead of raw CALLS hops. Must come after every step
|
||||
// that adds CALLS edges (placeholder + dynamic-CALLNAT resolution, link-calls-to-implementations)
|
||||
// or removes them (the item-62 data-literal reaping above) — it is a projection, so it is only
|
||||
// as correct as the graph at the moment it runs.
|
||||
// The DELETE is what makes it correct across refreshes, not just idempotent: MERGE re-creates
|
||||
// what still exists but never removes what no longer should (the item-58 sweep deletes stale
|
||||
// *nodes* only, so a surviving module's edges are never reaped).
|
||||
statements.add(new EnrichmentStep("delete-calls-module" + sfx,
|
||||
scoped ? CypherQueries.DELETE_CALLS_MODULE_SCOPED : CypherQueries.DELETE_CALLS_MODULE));
|
||||
statements.add(new EnrichmentStep("build-calls-module" + sfx,
|
||||
scoped ? CypherQueries.BUILD_CALLS_MODULE_SCOPED : CypherQueries.BUILD_CALLS_MODULE));
|
||||
return statements;
|
||||
}
|
||||
|
||||
public Uni<List<DataStructureField>> dbTableColumns(String project, String name) {
|
||||
@@ -1404,20 +1410,21 @@ public class GraphRepository {
|
||||
// Write operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
|
||||
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
|
||||
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
|
||||
*/
|
||||
private static ProjectInfo toProjectInfo(Record record) {
|
||||
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
|
||||
String root = record.get("root").isNull() ? "" : record.get("root").asString();
|
||||
List<String> excludeDirs = record.get("excludeDirs").isNull()
|
||||
? List.of()
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record));
|
||||
private static DataStructureField toDataStructureField(Record record) {
|
||||
return new DataStructureField(
|
||||
record.get("name").asString(),
|
||||
record.get("type").asString(),
|
||||
record.get("dataType").isNull() ? null : record.get("dataType").asString(),
|
||||
record.get("value").isNull() ? null : record.get("value").asString(),
|
||||
record.get("parent").asString(),
|
||||
record.get("startLine").asInt(),
|
||||
record.get("endLine").asInt(),
|
||||
record.get("scope").isNull() ? null : record.get("scope").asString(),
|
||||
!record.containsKey("sourceFile") || record.get("sourceFile").isNull()
|
||||
|| record.get("sourceFile").asString().isEmpty()
|
||||
? null : record.get("sourceFile").asString(),
|
||||
record.containsKey("boundReads") ? record.get("boundReads").asInt(0) : 0,
|
||||
record.containsKey("boundWrites") ? record.get("boundWrites").asInt(0) : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1838,15 +1845,22 @@ public class GraphRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-enrich only the field references of {@code moduleNames} (a just-ingested program tree),
|
||||
* via the scoped field-resolution queries. Lets a deep ingest stay fast even in a project that
|
||||
* already holds the whole call graph — instead of re-resolving all ~100k placeholder edges.
|
||||
* Call-graph resolution, placeholder cleanup, and CHA fan-out still run project-wide (cheap and
|
||||
* idempotent).
|
||||
* Maps a project {@code Record} (from {@link CypherQueries#LIST_PROJECTS}/{@link CypherQueries#GET_PROJECT})
|
||||
* to {@link ProjectInfo}, tolerating legacy projects that predate {@code root}/{@code excludeDirs}
|
||||
* (their properties read back as {@code null}, mapped to {@code ""} / an empty list).
|
||||
*/
|
||||
public Uni<Void> finalizeProjectScoped(String project, List<String> moduleNames) {
|
||||
Map<String, Object> params = Map.of("project", project, "names", moduleNames);
|
||||
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep");
|
||||
private static ProjectInfo toProjectInfo(Record record) {
|
||||
@Nullable String description = record.get("description").isNull() ? null : record.get("description").asString();
|
||||
String root = record.get("root").isNull() ? "" : record.get("root").asString();
|
||||
List<String> excludeDirs = record.get("excludeDirs").isNull()
|
||||
? List.of()
|
||||
: record.get("excludeDirs").asList(value -> value.asString());
|
||||
List<String> counterparts = !record.containsKey("counterparts") || record.get("counterparts").isNull()
|
||||
? List.of()
|
||||
: record.get("counterparts").asList(value -> value.asString());
|
||||
return new ProjectInfo(record.get("name").asString(), description, root, excludeDirs,
|
||||
nullableString(record, "language"), nullableString(record, "generatedDir"),
|
||||
nullableString(record, "userExitDir"), toProjectIngestInfo(record), counterparts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1858,13 +1872,189 @@ public class GraphRepository {
|
||||
return finalizeProject(project, level, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-enrich only the field references of {@code moduleNames} (a just-ingested program tree),
|
||||
* via the scoped field-resolution queries. Lets a deep ingest stay fast even in a project that
|
||||
* already holds the whole call graph — instead of re-resolving all ~100k placeholder edges.
|
||||
* Call-graph resolution, placeholder cleanup, and CHA fan-out still run project-wide (cheap and
|
||||
* idempotent).
|
||||
*/
|
||||
public Uni<Void> finalizeProjectScoped(String project, List<String> moduleNames) {
|
||||
Map<String, Object> params = Map.of("project", project, "names", moduleNames);
|
||||
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep")
|
||||
.chain(() -> linkCounterparts(project));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param profile diagnostic profiling of the slow steps — see
|
||||
* {@link #runEnrichment(String, List, Map, String, boolean)}.
|
||||
*/
|
||||
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
|
||||
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
|
||||
Map.of("project", project), level.name().toLowerCase(java.util.Locale.ROOT), profile);
|
||||
Map.of("project", project), level.name().toLowerCase(java.util.Locale.ROOT), profile)
|
||||
.chain(() -> linkCounterparts(project));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: (re)builds the {@code COUNTERPART_OF} edges of {@code project} and of every project
|
||||
* that lists it as a counterpart — the latter because a refresh of the backend
|
||||
* {@code DETACH DELETE}d the handlers the frontend's edges pointed at.
|
||||
*/
|
||||
public Uni<Void> linkCounterparts(String project) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
return read(CypherQueries.COUNTERPART_HOLDERS, params, record -> record.get("name").asString())
|
||||
.chain(holders -> {
|
||||
Uni<Void> chain = Uni.createFrom().voidItem();
|
||||
for (String holder : holders) {
|
||||
chain = chain.chain(() -> runEnrichment(holder, COUNTERPART_STEPS, Map.of("project", holder), "counterparts"));
|
||||
}
|
||||
return chain;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 193: the counterpart listing as a page (see {@link CypherQueries#COUNTERPARTS}).
|
||||
*
|
||||
* @param kind {@code rest}, {@code dto}, {@code field} or {@code null} for all
|
||||
* @param unmatched only rows without a twin
|
||||
*/
|
||||
/**
|
||||
* Item 194: the slices of the project's Redux store, optionally one by reducer key or slice name.
|
||||
*/
|
||||
public Uni<List<StoreSlice>> storeSlices(String project, @Nullable String slice) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("slice", slice);
|
||||
return read(CypherQueries.STORE_SLICES, params, record -> {
|
||||
List<StoreSlice.Field> fields = new ArrayList<>();
|
||||
int reads = record.get("sliceReads").asInt();
|
||||
int writes = record.get("sliceWrites").asInt();
|
||||
for (Value f : record.get("fields").asList(v -> v)) {
|
||||
int fr = f.get("reads").asInt();
|
||||
int fw = f.get("writes").asInt();
|
||||
reads += fr;
|
||||
writes += fw;
|
||||
fields.add(new StoreSlice.Field(f.get("name").asString(), f.get("type").isNull() ? null : f.get("type").asString(),
|
||||
f.get("optional").asBoolean(false), fr, fw));
|
||||
}
|
||||
return new StoreSlice(record.get("slice").asString(), nullableString(record, "sliceName"), record.get("module").asString(),
|
||||
record.get("sourceFile").asString(), record.get("startLine").asInt(), record.get("endLine").asInt(),
|
||||
nullableString(record, "stateType"), fields, record.get("reducers").asInt(), reads, writes);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 194: the access sites of one slice — {@code mode} = {@code READS}/{@code WRITES}/null (both),
|
||||
* {@code field} = one state key or null (all, including whole-slice accesses), {@code module} = only
|
||||
* accesses from that module.
|
||||
*/
|
||||
public Uni<Page<StoreAccess>> storeAccessesPage(String project, String slice, @Nullable String field, @Nullable String mode,
|
||||
@Nullable String module, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("slice", slice);
|
||||
params.put("field", field);
|
||||
params.put("mode", mode);
|
||||
params.put("module", module);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.STORE_ACCESSES, params, record -> new StoreAccess(
|
||||
record.get("mode").asString(), record.get("slice").asString(), nullableString(record, "field"),
|
||||
nullableString(record, "path"), record.get("function").asString(), record.get("functionType").asString(),
|
||||
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "via")))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STORE_ACCESSES_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: every theme token with its use count; {@code unused} = only tokens no project code references.
|
||||
*/
|
||||
public Uni<List<ThemeToken>> themeTokens(String project, boolean unused) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("unused", unused);
|
||||
return read(CypherQueries.THEME_TOKENS, params, record -> new ThemeToken(record.get("token").asString(),
|
||||
nullableString(record, "kind"), nullableString(record, "value"), nullableString(record, "constant"),
|
||||
record.get("declared").asBoolean(false), nullableString(record, "module"), intOrNull(record.get("lineNo")),
|
||||
record.get("uses").asInt(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: where one theme token (by token path or {@code theme.<token>} name) is read.
|
||||
*/
|
||||
public Uni<List<ThemeUsage>> themeUsages(String project, String token) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("token", token);
|
||||
return read(CypherQueries.THEME_USAGES, params, record -> new ThemeUsage(record.get("function").asString(),
|
||||
nullableString(record, "functionKind"), nullableString(record, "module"), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "styleKind"), nullableString(record, "element"),
|
||||
nullableString(record, "property"), nullableString(record, "context")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 196: the style inventory, optionally one module, one kind ({@code sx|style|styled|css}) or only blocks with literals.
|
||||
*/
|
||||
public Uni<Page<StyleBlock>> stylesPage(String project, @Nullable String module, @Nullable String kind, boolean withLiterals,
|
||||
int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
params.put("kind", kind);
|
||||
params.put("withLiterals", withLiterals);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.STYLES, params, record -> new StyleBlock(record.get("name").asString(),
|
||||
record.get("styleKind").asString(), nullableString(record, "element"), nullableString(record, "selector"),
|
||||
nullableString(record, "function"), record.get("module").asString(), record.get("sourceFile").asString(),
|
||||
intOrNull(record.get("lineNo")), nullableString(record, "properties"), nullableString(record, "literals"),
|
||||
record.get("dynamic").asBoolean(false), record.get("tokens").asList(Value::asString)))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.STYLES_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Item 195: the binding sites of DTO fields, each with the field's backend counterpart.
|
||||
*/
|
||||
public Uni<Page<Binding>> bindingsPage(String project, @Nullable String dto, @Nullable String field, @Nullable String mode,
|
||||
@Nullable String module, @Nullable Boolean partial, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("dto", dto);
|
||||
params.put("field", field);
|
||||
params.put("mode", mode);
|
||||
params.put("module", module);
|
||||
params.put("partial", partial == null ? null : partial.toString());
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.BINDINGS, params, record -> new Binding(
|
||||
record.get("mode").asString(), record.get("dto").asString(), record.get("field").asString(),
|
||||
nullableString(record, "path"), nullableString(record, "rootDto"), nullableString(record, "kind"),
|
||||
record.get("partial").asBoolean(false), nullableString(record, "component"), nullableString(record, "attribute"),
|
||||
record.get("function").asString(), record.get("functionType").asString(), nullableString(record, "functionKind"),
|
||||
nullableString(record, "module"), record.get("sourceFile").asString(), intOrNull(record.get("lineNo")),
|
||||
nullableString(record, "counterpartProject"), nullableString(record, "counterpartModule"),
|
||||
nullableString(record, "counterpartField")))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.BINDINGS_COUNT, params)));
|
||||
}
|
||||
|
||||
public Uni<Page<Counterpart>> counterpartsPage(String project, @Nullable String module, @Nullable String kind,
|
||||
boolean unmatched, int limit, int offset) {
|
||||
Map<String, @Nullable Object> params = new HashMap<>();
|
||||
params.put("project", project);
|
||||
params.put("module", module);
|
||||
params.put("kind", kind);
|
||||
params.put("unmatched", unmatched);
|
||||
params.put("offset", Math.max(offset, 0));
|
||||
params.put("limit", limit > 0 ? limit : Integer.MAX_VALUE);
|
||||
return read(CypherQueries.COUNTERPARTS, params, record -> new Counterpart(
|
||||
record.get("kind").asString(), record.get("name").asString(), record.get("module").asString(),
|
||||
record.get("sourceFile").asString(), record.get("startLine").asInt(),
|
||||
nullableString(record, "httpMethod"), nullableString(record, "path"),
|
||||
nullableString(record, "counterpartProject"), nullableString(record, "counterpartName"),
|
||||
nullableString(record, "counterpartModule"), nullableString(record, "counterpartSourceFile"),
|
||||
intOrNull(record.get("counterpartStartLine"))))
|
||||
.flatMap(rows -> withTotal(rows, limit, offset, () -> count(CypherQueries.COUNTERPARTS_COUNT, params)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2170,6 +2360,16 @@ public class GraphRepository {
|
||||
String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir) {
|
||||
return createProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param counterparts item 193: projects this one's web-service calls and generated DTOs are linked to
|
||||
*/
|
||||
public Uni<ProjectOpResult> createProject(String name, @Nullable String description,
|
||||
String root, List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir, List<String> counterparts) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
@@ -2184,6 +2384,7 @@ public class GraphRepository {
|
||||
params.put("language", language);
|
||||
params.put("generatedDir", generatedDir);
|
||||
params.put("userExitDir", userExitDir);
|
||||
params.put("counterparts", counterparts);
|
||||
tx.run(CypherQueries.CREATE_PROJECT, params);
|
||||
return ProjectOpResult.SUCCESS;
|
||||
});
|
||||
@@ -2201,6 +2402,16 @@ public class GraphRepository {
|
||||
@Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir) {
|
||||
return updateProject(name, description, root, excludeDirs, language, generatedDir, userExitDir, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param counterparts item 193; {@code null} leaves the list unchanged
|
||||
*/
|
||||
public Uni<ProjectOpResult> updateProject(String name, @Nullable String description,
|
||||
@Nullable String root, @Nullable List<String> excludeDirs,
|
||||
@Nullable String language, @Nullable String generatedDir,
|
||||
@Nullable String userExitDir, @Nullable List<String> counterparts) {
|
||||
return Uni.createFrom().item(() -> {
|
||||
try (Session session = driver.session()) {
|
||||
return session.executeWrite(tx -> {
|
||||
@@ -2215,6 +2426,7 @@ public class GraphRepository {
|
||||
params.put("language", language);
|
||||
params.put("generatedDir", generatedDir);
|
||||
params.put("userExitDir", userExitDir);
|
||||
params.put("counterparts", counterparts);
|
||||
tx.run(CypherQueries.UPDATE_PROJECT, params);
|
||||
return ProjectOpResult.SUCCESS;
|
||||
});
|
||||
@@ -2751,6 +2963,15 @@ public class GraphRepository {
|
||||
stats.runQuery("merge-edges", tx, CypherQueries.mergeEdgesBatch(entry.getKey()),
|
||||
Map.of("edges", entry.getValue(), "ingestGen", ingestGen));
|
||||
}
|
||||
// Item 198: every edge from a re-parsed file's nodes that this parse did not re-emit (its
|
||||
// stamp is older than this run's) — the Java/TypeScript counterpart of the three Natural
|
||||
// reaps, for every edge type the parser owns. Before the node sweep on purpose: a resolved
|
||||
// edge to a real target of an older generation goes too, and the deep finalize rebuilds it
|
||||
// from the fresh placeholder edge; a placeholder left edgeless falls to the finalize sweeps.
|
||||
if (reconcile && !freshFiles.isEmpty()) {
|
||||
stats.runQuery("reap-stale-parsed-edges", tx, CypherQueries.DELETE_STALE_PARSED_EDGES,
|
||||
Map.of("project", project, "files", freshFiles, "ingestGen", ingestGen));
|
||||
}
|
||||
// Item 58: after merging the fresh nodes (whose ids are now written), delete each re-parsed
|
||||
// file's nodes that the fresh parse no longer produced (renamed/removed fields, moved
|
||||
// statements) so a refresh purges stale nodes instead of leaving them to shadow the new ones.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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,338 @@
|
||||
package com.agenticcode.parsertypescript;
|
||||
|
||||
import com.agenticcode.parsercore.ast.model.AstEdge;
|
||||
import com.agenticcode.parsercore.ast.model.AstNode;
|
||||
import com.agenticcode.parsercore.ast.model.EdgeType;
|
||||
import com.agenticcode.parsercore.ast.model.NodeType;
|
||||
import com.agenticcode.parsercore.ast.spi.LanguageParser.ParseResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tier-2 merge of {@link TypeScriptParser} (item 192): facts override the outline; calls become graph edges shaped like the Java parser's.
|
||||
*/
|
||||
class TypeScriptParserTest {
|
||||
|
||||
private static final Set<String> WS = Set.of("pur-ui", "pur-ui-common", "pur-r-vstamm", "pur-r-vbuch");
|
||||
private final TypeScriptParser parser = new TypeScriptParser(WS, TypeScriptCoarseScanner.DEFAULT_EXTERNAL_PACKAGES);
|
||||
|
||||
private static AstNode byId(ParseResult r, UUID id) {
|
||||
return r.nodes().stream().filter(n -> n.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private static AstNode one(ParseResult r, NodeType type, String name) {
|
||||
return r.nodes().stream().filter(n -> n.type() == type && n.name().equals(name) && !n.sourceFile().isEmpty())
|
||||
.findFirst().orElseThrow(() -> new AssertionError("missing " + type + " " + name));
|
||||
}
|
||||
|
||||
private static String prop(AstEdge e, String key) {
|
||||
return Objects.requireNonNull(Objects.requireNonNull(e.properties()).get(key), key);
|
||||
}
|
||||
|
||||
private static List<AstEdge> access(ParseResult r, EdgeType type, AstNode from) {
|
||||
return r.edges().stream().filter(e -> e.type() == type && e.sourceId().equals(from.id())).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutFactsItIsTheOutline() {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE));
|
||||
assertTrue(r.edges().stream().noneMatch(e -> e.type() == EdgeType.CALLS));
|
||||
assertNull(Objects.requireNonNull(r.nodes().get(0).properties()).get("ingestTier"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void factsReplaceDeclarationsAndMarkTier2() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode module = r.nodes().get(0);
|
||||
assertEquals("2", Objects.requireNonNull(module.properties()).get("ingestTier"));
|
||||
assertEquals("63", Objects.requireNonNull(module.properties()).get("loc"), "shell metrics stay");
|
||||
Set<String> names = r.nodes().stream().filter(n -> n.type() != NodeType.MODULE && n.type() != NodeType.STYLE && !n.sourceFile().isEmpty())
|
||||
.map(n -> n.type() + ":" + n.name()).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("DATA_STRUCTURE:AgstammState", "FIELD:AgstammState.requestStatus", "FIELD:AgstammState.agstammUseCaseSvcResult",
|
||||
"FUNCTION:loadBrokerFromServer", "FUNCTION:saveBrokerToServer", "FUNCTION:loadHistorie",
|
||||
"FUNCTION:selectAgstamm", "FUNCTION:statusOf",
|
||||
// item 194: the slice under its store key, its state keys, its reducers by action type
|
||||
"STORE_SLICE:broker", "FIELD:broker.requestStatus", "FIELD:broker.agstammUseCaseSvcResult",
|
||||
"FUNCTION:agstamm/updateAgstammUseCaseSvcResult", "FUNCTION:agstamm/resetIfIdle", "FUNCTION:agstamm/loadBroker/fulfilled",
|
||||
"FUNCTION:agstamm/matcher:(action) => action.type.endsWith('/rejected')"), names, "plain consts are not nodes");
|
||||
assertEquals("thunk", Objects.requireNonNull(one(r, NodeType.FUNCTION, "loadBrokerFromServer").properties()).get("kind"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossFileCallsAreModuleToPlaceholderEdgesLikeJava() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode module = r.nodes().get(0);
|
||||
List<AstEdge> calls = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id())).toList();
|
||||
assertEquals(4, calls.size(), "new Endpoint(), searchBroker.get, saveBroker.post, hey-api fn; lib/unresolved calls are no edges");
|
||||
assertEquals(5, r.edges().stream().filter(e -> e.type() == EdgeType.CALLS).count(), "+ the same-file thunk -> case edge (item 194)");
|
||||
AstEdge post = calls.stream().filter(e -> "agstammUiApi.saveBroker.post".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-endpoints", byId(r, post.targetId()).name());
|
||||
assertEquals("METHOD_CALL", prop(post, "callKind"));
|
||||
assertEquals("saveBrokerToServer", prop(post, "callerFn"));
|
||||
assertEquals("AgstammControllerEndpoint.saveBroker", prop(post, "calleeMethod"),
|
||||
"item 193: retargeted from the generic PostMethod.post signature to the endpoint function");
|
||||
AstEdge hey = calls.stream().filter(e -> "getPurRVstammV1AgstammUiHistorieByVermnr".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("loadHistorie", prop(hey, "callerFn"));
|
||||
assertEquals("getPurRVstammV1AgstammUiHistorieByVermnr", prop(hey, "calleeMethod"));
|
||||
assertEquals("AgstammControllerEndpoint", prop(post, "receiver"));
|
||||
assertEquals("saveBroker.post", prop(post, "member"));
|
||||
assertEquals(48, post.lineNo());
|
||||
AstEdge ctor = calls.stream().filter(e -> "AgstammControllerEndpoint".equals(e.value())).findFirst().orElseThrow();
|
||||
assertEquals("CONSTRUCTOR", prop(ctor, "callKind"));
|
||||
assertNull(Objects.requireNonNull(ctor.properties()).get("callerFn"), "module-level const: no calling function");
|
||||
assertEquals("AgstammControllerEndpoint", prop(ctor, "calleeMethod"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameFileCallsAreFunctionToFunctionAndJsxCountsAsACall() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
List<AstEdge> fromPage = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(page.id())).toList();
|
||||
Set<String> targets = fromPage.stream().map(e -> byId(r, e.targetId()).name()).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("useVermnr", "Panel"), targets, "local top-level callees; the local variable call is no edge");
|
||||
AstEdge panel = fromPage.stream().filter(e -> byId(r, e.targetId()).name().equals("Panel")).findFirst().orElseThrow();
|
||||
assertEquals("jsx", prop(panel, "callSyntax"));
|
||||
AstNode module = r.nodes().get(0);
|
||||
Set<String> cross = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(module.id()))
|
||||
.map(e -> byId(r, e.targetId()).name() + "#" + prop(e, "calleeMethod") + "<-" + prop(e, "callerFn")).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("pur-r-vstamm/src/store/hooks/useAgstamm#useAgstamm<-AgstammPage",
|
||||
"pur-r-vstamm/src/components/Agstamm/HistorieDrawer#HistorieDrawer<-AgstammPage",
|
||||
"pur-r-vstamm/src/store/rv-redux-types#useAppSelector<-AgstammPage",
|
||||
// item 194: a dispatched action creator targets the reducer FUNCTION, not the slice const
|
||||
"pur-r-vstamm/src/store/slices/agstammSlice#agstamm/resetIfIdle<-AgstammPage"), cross);
|
||||
AstEdge dispatch = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "agstamm/resetIfIdle".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/resetIfIdle", prop(dispatch, "actionType"));
|
||||
assertEquals("@mui/material,@mui/material/styles,react", Objects.requireNonNull(module.properties()).get("externalImports"),
|
||||
"no node_modules in the fixture tree: the sidecar resolves none of these, so the Tier-1 rule decides — "
|
||||
+ "@-scoped and known packages are external, pur-ui-common is a workspace and became a placeholder");
|
||||
assertTrue(r.nodes().stream().anyMatch(n -> n.sourceFile().isEmpty() && n.name().equals("pur-ui-common/src/index")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointFunctionsCarryTheJavaHandlerProperties() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.ENDPOINTS, Fixtures.read(Fixtures.ENDPOINTS), TypeScriptFactsReaderTest.facts());
|
||||
AstNode save = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.saveBroker");
|
||||
java.util.Map<String, String> p = Objects.requireNonNull(save.properties());
|
||||
assertEquals("endpoint", p.get("kind"));
|
||||
assertEquals("true", p.get("outbound"));
|
||||
assertEquals("POST", p.get("httpMethod"));
|
||||
assertEquals("/agstamm/ui", p.get("restPath"));
|
||||
assertEquals("", p.get("restBase"));
|
||||
assertEquals("/agstamm/ui/", p.get("restUrl"));
|
||||
assertEquals("pur", p.get("backend"));
|
||||
assertEquals("AgstammUseCase", p.get("requestType"), "namespace qualifiers stripped");
|
||||
assertEquals("SvcResult<AgstammUseCase>", p.get("responseType"));
|
||||
assertEquals("typescript-generator", p.get("generator"));
|
||||
AstNode search = one(r, NodeType.FUNCTION, "AgstammControllerEndpoint.searchBroker");
|
||||
assertEquals("/agstamm/ui/search", Objects.requireNonNull(search.properties()).get("restPath"));
|
||||
assertEquals("vermnr", Objects.requireNonNull(search.properties()).get("queryParams"));
|
||||
assertTrue(r.nodes().stream().anyMatch(n -> n.type() == NodeType.FUNCTION && n.name().equals("AgstammControllerEndpoint")
|
||||
&& "class".equals(Objects.requireNonNull(n.properties()).get("kind"))), "the class shell stays");
|
||||
String heyFile = "pur-r-vstamm/src/generated/client/sdk.gen.ts";
|
||||
ParseResult h = parser.parse(heyFile, Fixtures.read(heyFile), TypeScriptFactsReaderTest.facts());
|
||||
AstNode fn = one(h, NodeType.FUNCTION, "getPurRVstammV1AgstammUiHistorieByVermnr");
|
||||
assertEquals(1, h.nodes().stream().filter(n -> n.name().equals(fn.name())).count(), "endpoint node wins over the const declaration");
|
||||
assertEquals("endpoint", Objects.requireNonNull(fn.properties()).get("kind"));
|
||||
assertEquals("/pur-r-vstamm/v1", Objects.requireNonNull(fn.properties()).get("restBase"));
|
||||
assertEquals("/agstamm/ui/historie/{vermnr}", Objects.requireNonNull(fn.properties()).get("restPath"));
|
||||
assertEquals("hey-api", Objects.requireNonNull(fn.properties()).get("generator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void interfaceMembersBecomeFields() throws IOException {
|
||||
String f = "pur-r-vstamm/src/generated/agstamm-interfaces.ts";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode useCase = one(r, NodeType.DATA_STRUCTURE, "AgstammUseCase");
|
||||
List<AstNode> fields = r.edges().stream()
|
||||
.filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(useCase.id()))
|
||||
.map(e -> byId(r, e.targetId())).toList();
|
||||
assertEquals(List.of("AgstammUseCase.broker:Broker", "AgstammUseCase.brokerName:string"),
|
||||
fields.stream().map(n -> n.name() + ":" + n.dataType()).toList(), "qualified: one node per interface member, not per name");
|
||||
assertTrue(fields.stream().allMatch(n -> n.type() == NodeType.FIELD));
|
||||
assertEquals("broker", Objects.requireNonNull(fields.get(0).properties()).get("field"));
|
||||
assertEquals("AgstammUseCase", Objects.requireNonNull(fields.get(0).properties()).get("owner"));
|
||||
}
|
||||
|
||||
// ----- item 194: the Redux store -----
|
||||
|
||||
@Test
|
||||
void everyEdgeConnectsTwoEmittedNodes() throws IOException {
|
||||
TypeScriptFacts facts = TypeScriptFactsReaderTest.facts();
|
||||
for (String f : facts.byFile().keySet()) {
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), facts);
|
||||
Set<UUID> ids = r.nodes().stream().map(AstNode::id).collect(Collectors.toSet());
|
||||
for (AstEdge e : r.edges()) {
|
||||
assertTrue(ids.contains(e.sourceId()) && ids.contains(e.targetId()), "dangling edge in " + f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sliceBecomesStoreSliceWithFieldsAndReducerFunctions() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.SLICE, Fixtures.read(Fixtures.SLICE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode slice = one(r, NodeType.STORE_SLICE, "broker");
|
||||
assertEquals("agstamm", Objects.requireNonNull(slice.properties()).get("sliceName"));
|
||||
assertEquals("broker", Objects.requireNonNull(slice.properties()).get("storeKey"), "named by the key store.ts mounts it under");
|
||||
assertEquals("true", Objects.requireNonNull(slice.properties()).get("store"));
|
||||
assertEquals("AgstammState", slice.dataType());
|
||||
assertEquals(15, slice.startLine());
|
||||
assertEquals(37, slice.endLine());
|
||||
AstNode module = r.nodes().get(0);
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(module.id()) && e.targetId().equals(slice.id())));
|
||||
AstNode status = one(r, NodeType.FIELD, "broker.requestStatus");
|
||||
AstNode result = one(r, NodeType.FIELD, "broker.agstammUseCaseSvcResult");
|
||||
assertEquals("RequestStatus", status.dataType());
|
||||
assertEquals("requestStatus", Objects.requireNonNull(status.properties()).get("field"));
|
||||
assertEquals("broker", Objects.requireNonNull(status.properties()).get("slice"));
|
||||
assertEquals("true", Objects.requireNonNull(result.properties()).get("optional"));
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(slice.id()) && e.targetId().equals(status.id())));
|
||||
|
||||
AstNode reset = one(r, NodeType.FUNCTION, "agstamm/resetIfIdle");
|
||||
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("kind"));
|
||||
assertEquals("reducer", Objects.requireNonNull(reset.properties()).get("reducerKind"));
|
||||
assertEquals("broker", Objects.requireNonNull(reset.properties()).get("slice"));
|
||||
assertEquals("agstamm/resetIfIdle", Objects.requireNonNull(reset.properties()).get("actionType"));
|
||||
assertEquals(22, reset.startLine());
|
||||
assertEquals(26, reset.endLine());
|
||||
List<AstEdge> reads = access(r, EdgeType.READS, reset);
|
||||
assertEquals(1, reads.size());
|
||||
assertEquals(status.id(), reads.get(0).targetId());
|
||||
assertEquals("requestStatus", prop(reads.get(0), "path"));
|
||||
assertEquals("reducer", prop(reads.get(0), "via"));
|
||||
assertEquals(23, reads.get(0).lineNo());
|
||||
List<AstEdge> writes = access(r, EdgeType.WRITES, reset);
|
||||
assertEquals(1, writes.size());
|
||||
assertEquals(result.id(), writes.get(0).targetId());
|
||||
assertEquals(24, writes.get(0).lineNo());
|
||||
|
||||
AstNode fulfilled = one(r, NodeType.FUNCTION, "agstamm/loadBroker/fulfilled");
|
||||
assertEquals("case", Objects.requireNonNull(fulfilled.properties()).get("reducerKind"));
|
||||
assertEquals("loadBrokerFromServer.fulfilled", Objects.requireNonNull(fulfilled.properties()).get("trigger"));
|
||||
assertEquals(2, access(r, EdgeType.WRITES, fulfilled).size());
|
||||
AstNode thunk = one(r, NodeType.FUNCTION, "loadBrokerFromServer");
|
||||
AstEdge fires = r.edges().stream().filter(e -> e.type() == EdgeType.CALLS && e.sourceId().equals(thunk.id()) && e.targetId().equals(fulfilled.id()))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("the thunk fires its fulfilled case"));
|
||||
assertEquals("extraReducer", prop(fires, "callSyntax"));
|
||||
assertEquals("agstamm/loadBroker/fulfilled", prop(fires, "actionType"));
|
||||
AstNode matcher = one(r, NodeType.FUNCTION, "agstamm/matcher:(action) => action.type.endsWith('/rejected')");
|
||||
assertEquals("matcher", Objects.requireNonNull(matcher.properties()).get("reducerKind"));
|
||||
assertNull(Objects.requireNonNull(matcher.properties()).get("actionType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumersReadStorePlaceholdersByKeyAndField() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
List<String> reads = access(r, EdgeType.READS, page).stream()
|
||||
.filter(e -> !"binding".equals(Objects.requireNonNull(e.properties()).get("via"))) // item 195 reads are tested separately
|
||||
.map(e -> byId(r, e.targetId()).type() + ":" + byId(r, e.targetId()).name() + "[" + prop(e, "path") + "]@" + e.lineNo() + " via " + prop(e, "via"))
|
||||
.sorted().toList();
|
||||
assertEquals(List.of("FIELD:broker.agstammUseCaseSvcResult[agstammUseCaseSvcResult]@27 via useAppSelector",
|
||||
"FIELD:broker.requestStatus[requestStatus]@26 via useAppSelector",
|
||||
"FIELD:broker.requestStatus[requestStatus]@30 via getState"), reads);
|
||||
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).findFirst().orElseThrow();
|
||||
assertEquals("", ph.sourceFile(), "a placeholder: the slice lives in another file");
|
||||
assertEquals(NodeType.FIELD, ph.type());
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("store"), "what the store resolver matches on");
|
||||
assertEquals(1, r.nodes().stream().filter(n -> n.name().equals("broker.requestStatus")).count(), "one placeholder per name");
|
||||
|
||||
String drawer = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
|
||||
ParseResult d = parser.parse(drawer, Fixtures.read(drawer), TypeScriptFactsReaderTest.facts());
|
||||
AstEdge wrapped = access(d, EdgeType.READS, one(d, NodeType.FUNCTION, "HistorieDrawer")).get(0);
|
||||
assertEquals("broker.agstammUseCaseSvcResult", byId(d, wrapped.targetId()).name());
|
||||
assertEquals("agstammUseCaseSvcResult.result.purMode", prop(wrapped, "path"), "the wrapper hook's base path plus the selector's own");
|
||||
assertEquals("useAgstammSelector", prop(wrapped, "via"));
|
||||
|
||||
String hook = "pur-r-vstamm/src/store/hooks/useAgstamm.ts";
|
||||
ParseResult h = parser.parse(hook, Fixtures.read(hook), TypeScriptFactsReaderTest.facts());
|
||||
assertEquals(1, access(h, EdgeType.READS, one(h, NodeType.FUNCTION, "useAgstammSelector")).size(), "the wrapper's own inner selector is a read too");
|
||||
AstEdge thunkCall = h.edges().stream().filter(e -> e.type() == EdgeType.CALLS && "loadBrokerFromServer".equals(Objects.requireNonNull(e.properties()).get("calleeMethod"))).findFirst().orElseThrow();
|
||||
assertEquals("agstamm/loadBroker", prop(thunkCall, "actionType"), "a thunk keeps its FUNCTION as target and carries the type prefix");
|
||||
}
|
||||
|
||||
// ----- item 195: DTO field bindings -----
|
||||
|
||||
@Test
|
||||
void aBoundFieldIsReadAndWrittenThroughAPlaceholderResolvedByOwner() throws IOException {
|
||||
ParseResult r = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode page = one(r, NodeType.FUNCTION, "AgstammPage");
|
||||
AstNode ph = r.nodes().stream().filter(n -> n.name().equals("Broker.ebene")).findFirst().orElseThrow();
|
||||
assertEquals("", ph.sourceFile());
|
||||
assertEquals(NodeType.FIELD, ph.type());
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("binding"));
|
||||
assertEquals("Broker", Objects.requireNonNull(ph.properties()).get("owner"));
|
||||
assertEquals("ebene", Objects.requireNonNull(ph.properties()).get("field"));
|
||||
assertEquals("pur-r-vstamm/src/generated/agstamm-interfaces", Objects.requireNonNull(ph.properties()).get("targetModule"));
|
||||
for (EdgeType type : List.of(EdgeType.READS, EdgeType.WRITES)) {
|
||||
AstEdge e = r.edges().stream().filter(x -> x.type() == type && x.sourceId().equals(page.id()) && x.targetId().equals(ph.id()))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("SmartInput binds " + type));
|
||||
assertEquals("broker.ebene", prop(e, "path"));
|
||||
assertEquals("AgstammUseCase", prop(e, "rootDto"));
|
||||
assertEquals("SmartInput", prop(e, "component"));
|
||||
assertEquals("binding", prop(e, "via"));
|
||||
assertEquals("false", prop(e, "partial"));
|
||||
assertEquals(38, e.lineNo());
|
||||
}
|
||||
}
|
||||
|
||||
// ----- item 196: styling -----
|
||||
|
||||
@Test
|
||||
void themeTokensAreFieldsOfATheme() throws IOException {
|
||||
String f = "pur-r-vstamm/src/rvTheme.ts";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode theme = one(r, NodeType.DATA_STRUCTURE, "theme");
|
||||
assertEquals("theme", Objects.requireNonNull(theme.properties()).get("kind"));
|
||||
AstNode dark = one(r, NodeType.FIELD, "theme.palette.primary.dark");
|
||||
assertEquals("#0054A2", dark.dataType());
|
||||
assertEquals("true", Objects.requireNonNull(dark.properties()).get("theme"));
|
||||
assertEquals("palette.primary.dark", Objects.requireNonNull(dark.properties()).get("token"));
|
||||
assertEquals("path", Objects.requireNonNull(dark.properties()).get("tokenKind"));
|
||||
assertEquals("PRIMARY_DARK", Objects.requireNonNull(dark.properties()).get("constant"));
|
||||
assertEquals("constant", Objects.requireNonNull(one(r, NodeType.FIELD, "theme.PRIMARY").properties()).get("tokenKind"));
|
||||
// item 199: the dark theme in the same file adds palette.mode, shares primary.main/dark (first value wins, variants=2)
|
||||
assertEquals(10, r.edges().stream().filter(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(theme.id())).count());
|
||||
assertEquals("2", Objects.requireNonNull(dark.properties()).get("variants"));
|
||||
assertNull(Objects.requireNonNull(one(r, NodeType.FIELD, "theme.palette.mode").properties()).get("variants"));
|
||||
assertEquals("dark", one(r, NodeType.FIELD, "theme.palette.mode").dataType());
|
||||
assertTrue(r.nodes().stream().noneMatch(n -> n.sourceFile().isEmpty() && n.name().startsWith("theme.")), "no self-placeholders");
|
||||
}
|
||||
|
||||
@Test
|
||||
void styleBlocksAreStylesWithTokenReferences() throws IOException {
|
||||
String f = "pur-r-vstamm/src/components/Agstamm/HistorieDrawer.tsx";
|
||||
ParseResult r = parser.parse(f, Fixtures.read(f), TypeScriptFactsReaderTest.facts());
|
||||
AstNode drawer = one(r, NodeType.FUNCTION, "HistorieDrawer");
|
||||
AstNode sx = one(r, NodeType.STYLE, "HistorieDrawer.sx@9:14");
|
||||
assertEquals("sx", Objects.requireNonNull(sx.properties()).get("styleKind"));
|
||||
assertEquals("Box", Objects.requireNonNull(sx.properties()).get("element"));
|
||||
assertEquals("color,borderColor,height,&:hover.background,mt", Objects.requireNonNull(sx.properties()).get("properties"));
|
||||
assertEquals("17px", Objects.requireNonNull(sx.properties()).get("literals"));
|
||||
assertTrue(r.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(drawer.id()) && e.targetId().equals(sx.id())));
|
||||
List<AstEdge> refs = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(sx.id())).toList();
|
||||
// item 199: PRIMARY feeds color and borderColor on one line -> one edge, both keys
|
||||
assertEquals(List.of("theme.PRIMARY[color,borderColor]", "theme.palette.background.paper[&:hover.background]"),
|
||||
refs.stream().map(e -> byId(r, e.targetId()).name() + "[" + prop(e, "property") + "]").toList());
|
||||
AstNode ph = byId(r, refs.get(0).targetId());
|
||||
assertEquals("", ph.sourceFile(), "the theme lives in another file: a placeholder");
|
||||
assertEquals("true", Objects.requireNonNull(ph.properties()).get("theme"));
|
||||
AstEdge prop = r.edges().stream().filter(e -> e.type() == EdgeType.REFERENCES && e.sourceId().equals(drawer.id())).findFirst().orElseThrow();
|
||||
assertEquals("theme.palette.primary.main", byId(r, prop.targetId()).name());
|
||||
assertEquals("borderColor", prop(prop, "context"));
|
||||
assertEquals("theme", prop(prop, "via"));
|
||||
ParseResult page = parser.parse(Fixtures.PAGE, Fixtures.read(Fixtures.PAGE), TypeScriptFactsReaderTest.facts());
|
||||
AstNode styled = one(page, NodeType.STYLE, "Panel.styled@14:15");
|
||||
AstNode panel = one(page, NodeType.FUNCTION, "Panel");
|
||||
assertTrue(page.edges().stream().anyMatch(e -> e.type() == EdgeType.CONTAINS && e.sourceId().equals(panel.id()) && e.targetId().equals(styled.id())),
|
||||
"a styled block hangs under its styled FUNCTION");
|
||||
assertEquals("padding", Objects.requireNonNull(styled.properties()).get("properties"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,8 +49,10 @@ services:
|
||||
|
||||
ac-code-server:
|
||||
build:
|
||||
context: ./ac-code-server
|
||||
dockerfile: src/main/docker/Dockerfile.jvm
|
||||
# Repository root, not ./ac-code-server: the image also carries the TypeScript sidecar from
|
||||
# ac-parser-typescript/sidecar (item 192). The root .dockerignore limits what is sent.
|
||||
context: .
|
||||
dockerfile: ac-code-server/src/main/docker/Dockerfile.jvm
|
||||
container_name: agenticcode-server
|
||||
ports:
|
||||
- "8787:8787"
|
||||
@@ -73,9 +75,12 @@ services:
|
||||
-Xmx2500m
|
||||
-XX:G1PeriodicGCInterval=300000
|
||||
-Djava.util.logging.manager=org.jboss.logmanager.LogManager
|
||||
# Heap 2G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
|
||||
# heap cap, i.e. ~1.9 GB non-heap, so the ceiling stays at 4g even though the heap shrank.
|
||||
mem_limit: 4g
|
||||
# Heap 2.5G + metaspace/code cache/direct buffers. Measured RSS peak was 3472 MB against a 3 GB
|
||||
# heap cap, i.e. ~1.9 GB non-heap. Item 192 adds the TypeScript sidecar, a Node process the JVM
|
||||
# runs per npm workspace during a deep pass: measured ~0.75 GB RSS per workspace, capped at
|
||||
# --max-old-space-size=1024 and run before the JVM's own persist/enrichment peak — so 4g would
|
||||
# have been tight only if the two peaks coincided; 5g leaves room for that case.
|
||||
mem_limit: 5g
|
||||
volumes:
|
||||
# Mounted at the same absolute host path so project roots registered via
|
||||
# the API (which store absolute host paths) resolve inside the container too.
|
||||
@@ -84,6 +89,9 @@ services:
|
||||
- /home/ingo/deve/agenticCode:/home/ingo/deve/agenticCode:ro
|
||||
- /home/ingo/deve/uniqa/uniqa-upms-app:/home/ingo/deve/uniqa/uniqa-upms-app:ro
|
||||
- /home/ingo/deve/uniqa/pur-sources/backend:/home/ingo/deve/uniqa/pur-sources/backend:ro
|
||||
# Item 192: the pur frontend (project `purfe`, language typescript). Read-only like the others;
|
||||
# the sidecar runs with noEmit and reads the frontend's own node_modules for library typings.
|
||||
- /home/ingo/deve/uniqa/pur-sources/frontend:/home/ingo/deve/uniqa/pur-sources/frontend:ro
|
||||
- /home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:/home/ingo/deve/tools/conqat/system/src/250401_UMPS/src/pur-analysis/pur-legacy:ro
|
||||
depends_on:
|
||||
neo4j:
|
||||
|
||||
10
pom.xml
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>
|
||||
@@ -18,6 +20,7 @@
|
||||
<module>ac-parser-core</module>
|
||||
<module>ac-parser-natural</module>
|
||||
<module>ac-parser-java</module>
|
||||
<module>ac-parser-typescript</module>
|
||||
<module>ac-neo4j-store</module>
|
||||
<module>ac-code-server</module>
|
||||
<module>ac-cli</module>
|
||||
@@ -74,6 +77,11 @@
|
||||
<artifactId>ac-parser-java</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-parser-typescript</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.agenticcode</groupId>
|
||||
<artifactId>ac-neo4j-store</artifactId>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -994,32 +994,38 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
|
||||
|
||||
## Endpoint quick reference
|
||||
|
||||
| Endpoint | Use for |
|
||||
|----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
|
||||
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
|
||||
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
|
||||
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
|
||||
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
|
||||
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
|
||||
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
|
||||
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
|
||||
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
|
||||
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
|
||||
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
|
||||
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
|
||||
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
|
||||
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
|
||||
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
|
||||
| `GET /modules/{name}/comments?kind=&limit=&offset=` (`ac comments`) | **Item 141:** the module's **comment blocks** — `{text, kind, sourceFile, startLine, endLine, target, targetType, truncated}`, one row per contiguous block, ordered by line. `target`/`targetType` name the declaration the block documents: the declaration immediately below it, else the one enclosing it (so a file header banner documents the `MODULE`, a `/*` comment on a field's own line documents that field). `kind` is `JAVADOC`\|`LINE`\|`BLOCK` (Java) or `NATURAL_BANNER`\|`NATURAL_INLINE`\|`SAG` (Natural); `?kind=` filters to one. **`SAG` is excluded by default** — `**SAG` directives are generator metadata, not human notes, and would otherwise be most of the answer for every generated Natural module. Text is cut at 4 000 chars (`truncated:true`); read the file for the rest. Natural copycode comments belong to the **copycode's own module**, not to each includer. **Deep-gated:** comments come from the full parse, not the Tier-1 coarse scan, so the module is deep-ingested on demand and a still-shallow module answers `409 NOT_DEEPLY_INGESTED` rather than a misleading `[]` |
|
||||
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
|
||||
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
|
||||
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
|
||||
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
|
||||
| Endpoint | Use for |
|
||||
|----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `GET /modules?sourceFile=&moduleKind=&extends=` | List/filter modules; map a source file to its module name(s). Each row carries `loc`/`sloc` (item 46) and `ingestStatus`/`ingestDepth` (item 50) for status badges without a per-module round trip |
|
||||
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (`typescript`/`css` too, item 192) (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
|
||||
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
|
||||
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
|
||||
| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance), **rolled up to the calling MODULE**: a call made from inside a subroutine/method is attributed to its owning module (never the calling `FUNCTION` node), and repeated call sites from one caller collapse to a single row whose `sites` list every line — symmetric with how `callees` anchors its source side. `internal` = the module's own subroutines' `PERFORM` wiring (function-level). The default is external-only, module-typed only, and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module / function-level wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
|
||||
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java, TypeScript — same-module **and** cross-module, item 197) a specific subroutine/method, with call-site `lineNos`. Cross-module callers come from the module-to-module `CALLS` edge's `callerFn`/`calleeMethod`, matched by name (overloads over-approximate; a call from top-level code with no enclosing function shows only in the module-level `/callers`). Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. CLI `ac function-callers <module> <function>` |
|
||||
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature
|
||||
| `GET /modules/{name}/reaches?target=A,B,C&direction=up\|down&depth=` | **Item 110 — "can A reach B, and how?"** Returns `{reachable, paths, truncated}` with one witness route per reached target (module names, source→target). `direction=down` (default): paths from this module to each target. `up`: paths from each target to this module. The counterpart to `call-tree`, which only walks downward and returns a closure without routes — one audit hand-rolled this as ~100 `/callers` requests. **`reachable: false` means "no path over known edges", not "no path"**: the traversal runs on resolved module calls, so a route through an unresolved dynamic `CALLNAT` (item 82) is invisible. Bounded by `depth` (item 75: the call graph has cycles). CLI `ac reaches <module> --target A,B --direction up` |
|
||||
| `GET /duplicates` | **Item 114 — identities skipped at ingest** because they exist in more than one file (`{name, kind, paths}`, paths relative to the project root). These are *not* in `/modules`; asking for one by name gives `409 DUPLICATE_IDENTITY`. Their own calls are absent from the graph, so caller lists elsewhere can be short. CLI `ac duplicates` | |
|
||||
| `GET /dynamic-calls/unresolved` \| `/overrides` · `POST`/`DELETE /overrides` | **Manual dynamic-`CALLNAT` overrides (item 82).** `unresolved` lists open `CALLNAT <var>` sites `{module, originFile, lineNo, variable}`; `POST /overrides {originFile, lineNo, targets[], variable?, note?}` pins a site to real module(s) (applied at once, persisted across refreshes, `400 UNKNOWN_TARGET` for a non-module); `DELETE /overrides?originFile=&lineNo=` resets one site (omit both = all) and restores the placeholder inline; `GET /overrides` lists them with an `obsolete` flag. CLI `ac dynamic-calls unresolved\|overrides\|set\|reset` |
|
||||
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. CLI `ac ego-graph` |
|
||||
| `GET /counterparts?module=&kind=&unmatched=` (`ac counterparts`) | **Item 193:** this project's web-service calls / generated DTOs / fields with their twin in the counterpart project; `unmatched=true` = what nothing serves or mirrors yet |
|
||||
| `GET /store?slice=` (`ac store`) | **Item 194:** the frontend Redux store — one row per slice (reducer key, RTK `sliceName`, `stateType`, the top-level state keys with type/optional/read/write counts, reducer count, total access sites) |
|
||||
| `GET /store/{slice}/accesses?field=&mode=reads\|writes&module=` (`ac store-accesses`) | **Item 194:** who reads/writes a slice — reducers (`functionKind=reducer`, `via=reducer`) and the components/hooks/thunks selecting from it (`via` = `useAppSelector`, a wrapper hook, `getState`), with the full sub-`path` and line. Store fields also answer `variables/<slice>.<field>/reads\|writes` |
|
||||
| `GET /bindings?dto=&field=&mode=reads\|writes&module=&partial=` (`ac bindings`) | **Item 195:** which component reads/writes which DTO field through the generated `Fields` path objects (`<SmartInput field={X.broker.ebene}>`), with the field's backend counterpart — `--dto Broker --field ebene --mode writes` = which page edits Java `Broker.ebene`. `data-structures/{dto}/fields` carries `boundReads`/`boundWrites` |
|
||||
| `GET /theme?unused=` · `GET /theme/{token}/usages` (`ac theme`, `ac theme-usages`) | **Item 196:** the MUI theme's tokens (createTheme leaves + theme constants, value, `uses`; `declared=false` = read by the code but declared by no theme) and where one token is read (style block + CSS `property`, or plain `context`) |
|
||||
| `GET /styles?module=&kind=sx\|style\|styled\|css&withLiterals=` (`ac styles`) | **Item 196:** the style inventory — every `sx`/`style`/`styled` block and CSS rule with CSS keys, hard-coded `literals` and the theme `tokens` it reads; `withLiterals=true` = what bypasses the theme |
|
||||
| `GET /modules/{name}/db-accesses` \| `/sql-statements` | DB tables + mode, raw statement text (pass `?depth=` for Natural). **`db-accesses`/`workfile-accesses` return every row when no `limit` is given (item 103)** — they used to default to 50, and since the response is a bare array with no total and no `truncated` flag the cut was invisible: `WGEAGB0S?depth=10` returned 50 of 64 rows and hid 7 tables outright. An explicit `limit` is still honoured exactly. `db-accesses` items carry **`sites: [{lineNo, sourceFile, viaCopycode, includedAt}]`** (+ kept `lineNos`); `sql-statements` items carry **`sourceFile`** + **`viaCopycode`** — so a copycode-sourced access (e.g. `SELECT … FROM SYSIBM-SYSDUMMY1` in `USIX043C.cpy`) reports the `.cpy` line, not a bare number that reads as a host-file line |
|
||||
| `GET /modules/{name}/workfile-accesses` | Natural **work files** (sequential/flat-file I/O — `READ`/`WRITE WORK FILE n`), the work-file analogue of `db-accesses` (item 84): `[{workFile, physicalName, mode: READS\|WRITES, recordBuffers, lineNos, sites}]`, aggregated per work-file number + mode. `sites: [{lineNo, sourceFile, viaCopycode, includedAt}]` gives each access its file context (copycode-aware), like `db-accesses`. `physicalName` comes from a `DEFINE WORK FILE n '<name>'`, else `null`. **Kept separate from `db-accesses`** — a work file is not an ADABAS/SQL table (fixes a former bug where `READ WORK FILE` created a phantom `DB_TABLE 'WORK'`). CLI `ac workfile-accesses <module>` |
|
||||
| `GET /modules/{name}/data-structures` | Which copybooks/inline groups a module uses. A `USING <member>` binds by **member (file) name**, never by a level-1 record inside the file (item 100) — before that, `WGEAGB0S USING W-WIF-A2` reported `old/W-WIF-A7.pda` (whose level-1 record is a copy-pasted `1W-WIF-A2`), and a data area with several level-1 records and none named after the member (`VLAYERLA.lda`, `USIX020L.lda`) resolved to nothing at all (`sourceFile: null`, `area: UNKNOWN`, `fieldCount: 0`) although the file was ingested. One row per resolved definition, `(name, sourceFile)` (item 102) — never one row blending an arbitrary file with another definition's `fieldCount` |
|
||||
| `GET /modules/{name}/payload` | Natural XML wire-payload contract: `{tag, field, direction, source, lineNo, sourceFile}` — static `ADD-XML-LINE` idiom (`source=IDIOM`, item 45) or derived from the wrapper's interface PDA (`source=PDA`, item 46b). `sourceFile` is the file `lineNo` refers to (module for IDIOM, PDA for PDA) |
|
||||
| `GET /modules/{name}/comments?kind=&limit=&offset=` (`ac comments`) | **Item 141:** the module's **comment blocks** — `{text, kind, sourceFile, startLine, endLine, target, targetType, truncated}`, one row per contiguous block, ordered by line. `target`/`targetType` name the declaration the block documents: the declaration immediately below it, else the one enclosing it (so a file header banner documents the `MODULE`, a `/*` comment on a field's own line documents that field). `kind` is `JAVADOC`\|`LINE`\|`BLOCK` (Java) or `NATURAL_BANNER`\|`NATURAL_INLINE`\|`SAG` (Natural); `?kind=` filters to one. **`SAG` is excluded by default** — `**SAG` directives are generator metadata, not human notes, and would otherwise be most of the answer for every generated Natural module. Text is cut at 4 000 chars (`truncated:true`); read the file for the rest. Natural copycode comments belong to the **copycode's own module**, not to each includer. **Deep-gated:** comments come from the full parse, not the Tier-1 coarse scan, so the module is deep-ingested on demand and a still-shallow module answers `409 NOT_DEEPLY_INGESTED` rather than a misleading `[]` |
|
||||
| `GET /modules/{name}/dispatch-table` | Natural `DECIDE ON VALUE OF` routing table |
|
||||
| `GET /modules/{name}/functions?kind=` \| `/functions/{fn}/overrides` \| `/functions/overrides` | Method list, modifier filter (Java), subclass overrides (single/bulk). Each item carries **`sourceFile`** + **`viaCopycode`** (item 84): a Natural subroutine pulled in via `INCLUDE` reports the **copycode** file and `viaCopycode:true`, so its `startLine`/`endLine` are read as offsets into that copycode — **not** into the including module's own file (which is shorter). `viaCopycode:false` = declared inline. Always `false` for Java |
|
||||
| `GET /data-structures/{name}/fields` \| `/db-tables/{name}/columns` \| `/modules/{name}/columns` | Field/column schemas for DTO/entity generation. Every field carries **`sourceFile`** (item 101). When a structure name resolves to several definitions (42 level-1 names recur across `upms` data areas), the **member root** — the definition whose file basename equals the name, i.e. what a `USING <member>` binds to — wins; **`?sourceFile=`** pins a specific one. Before item 101 the definitions were silently unioned: `W-WIF-A2` returned 15 fields, the merge of `W-WIF-A2.pda` (5) and `W-WIF-A7.pda` (10), a layout that exists nowhere |
|
||||
| `GET /variables/{name}/reads` \| `/writes` \| `/flow-forward` \| `/flow-backward` \| `/field-flow` | Impact analysis and dataflow tracing |
|
||||
| `GET /search/identifier` \| `/search/value` \| `/search/annotation` | Cross-project lookup by name / literal value / annotation. **All three see code only by default — an empty result is not evidence that the string is absent.** `search/value` takes **`includeComments=true`** (CLI `--include-comments`, item 141) to search comment blocks as well; those hits come back as `kind: "COMMENT"`, so a comment is never read as code. It is opt-in because a comment hit is different evidence from a literal, and folding it in silently would move every existing completeness count (item 131). `search/identifier` and `search/annotation` never match comments at all — use `includeComments`, `/modules/{name}/comments` or `/search/source` before concluding "not present" (see "Comments: reachable, but never by default" above). `search/identifier` matches the **exact** declared name but is **sigil-insensitive**: a leading Natural sigil (`#` user, `&` AIV, `+` GDA) is ignored on both sides, so `name=K-OUT-MAX` finds the declared `#K-OUT-MAX` (and vice-versa). **Item 125:** a Java **type declaration** is matched by its **short name** as well as by the fully-qualified identity the graph stores (item 117) — `name=PartnerUpdateLogic` finds `com.example.PartnerUpdateLogic`; before this it answered `[]`, which reads as "no such name". Every match carries `simpleName` and `moduleKind` (`CLASS`/`INTERFACE`/`ENUM`/`RECORD`, `PROGRAM`/`SUBPROGRAM` for Natural), both `null` for non-`MODULE` hits — so "is this name a type or a method?" needs no second call. `contains=true` (CLI `--contains`) switches to a case-insensitive **substring** match, as on `/search/value`; it was previously accepted and silently dropped. It matches the FQN too, so a package fragment also hits — filter with `type=MODULE`/`moduleKind` if that is noise. `contains` without a `name` is `400 MISSING_NAME` (a substring search for nothing is a full node dump). The substring scan is **unindexed**: it is bounded to `offset+limit` rows, so keep a `limit` on large projects. Optional **scope** filters `sourceFile=<relpath>` and `module=<name>` (item 53) narrow the match to one file / one module — use them to pinpoint a module-local declaration when a name recurs across dozens of modules (the result is otherwise paginated and the local one may fall off the page). To keep the **full cross-project list** yet still guarantee a given module's own declaration is on the first page, pass `priorityModule=<name>` instead of `module=`: it does not filter, but pins that module's matches to the front (ahead of the otherwise `sourceFile`-ordered rest) so they survive the `limit`. This is what the web UI's click-to-identify sends for the open module. CLI `ac search-identifier --module --priority-module --source-file --type --contains` accept the same filters. **Latency (item 105):** a lookup whose hits lie in a Natural data area used to take 60-75 s — every fan-out query deep-ingested the surfaced `.lda`/`.pda`, which can never reach `FULL` (a data area yields no `MODULE` node), so it was re-warmed on every call and each warm dragged a whole-project finalize behind it. Data areas are now excluded from the fan-out warm; they have no deep tier to gain |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
|
||||
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
|
||||
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
|
||||
| `GET /search/source?regex=&limit=&ignoreCase=` (`ac search-source`) | Regex **grep over module source text** (item 54): `{module, sourceFile, lineNo, line}` hits + `truncated`. Case-insensitive by default. Complements `/search/identifier` (declared names) — use for code patterns (statements, table names, literals). Sees **everything in the file, comments included**, and needs no ingest depth — so it is the fallback when a module is not deeply ingested, or when the text is something the parsers do not model. For comments specifically, prefer the graph routes added by item 141 (`/modules/{name}/comments`, `search/value?includeComments=true`), which also tell you which declaration a comment belongs to |
|
||||
| `GET /nodes/{id}` | Every property of one node (when a curated DTO is missing something) |
|
||||
| `GET /nodes/{id}/source` \| `/modules/{name}/source` \| `/source?file=` | Source text — **only when you have no other access to the source** (you always do in this repo, see "Reading source in this repo" above). `/modules/{name}/source` returns the **whole file** when the line range is omitted (M1), or a `[startLine,endLine]` slice when both are given. `/source?file=<relpath>` (CLI `ac file-source`) serves a file by **relative path** rather than module name — for files that aren't standalone modules, e.g. a Natural data area (PDA/LDA) USING'd by a module, whose field line numbers refer to that file. Same whole-file/range + stale-source semantics; the client-supplied path is rejected (`400 INVALID_SOURCE_FILE`) if it escapes the project root |
|
||||
|
||||
Full endpoint list, request params, and response field details:
|
||||
`x-docs/agent-api-system-prompt.md`.
|
||||
@@ -1063,6 +1069,336 @@ What it is **not**: a substitute for `mvn test`. The integration tests pin seman
|
||||
"the deployed thing is not obviously broken". A green run is not a quality gate. All assertions are
|
||||
invariants, never fixed counts — counts move with every refresh.
|
||||
|
||||
## TypeScript / React projects (item 192)
|
||||
|
||||
A project may declare `language: typescript` (`ac project create purfe --root … --language typescript`).
|
||||
Its `.ts`/`.tsx` files (not `.d.ts`) and plain `.css` files are ingested; `node_modules` and `dist`
|
||||
are excluded by default. **Only a `typescript` project ingests TypeScript** — a Java project with a
|
||||
bundled web UI (`ac` has `ac-ui/`) never parses it. Java and Natural files stay language-agnostic.
|
||||
|
||||
**Identities are paths.** A TypeScript `MODULE` is named by its root-relative path without the script
|
||||
extension — `pur-r-vstamm/src/store/slices/agstammSlice` — with `simpleName` = the file stem
|
||||
(`agstammSlice`), `workspace` = the first path segment, `moduleKind` = `ts`/`tsx`/`css`, and
|
||||
`generated=true` + `generator` (`typescript-generator` for the Java-side EndpointGenerator output
|
||||
under `generated/`, `hey-api` for `@hey-api/openapi-ts`). A CSS module keeps its extension
|
||||
(`pur-ui/src/index.css`). Every `/modules/{name}/…` endpoint accepts either form (item 117), so
|
||||
`ac context agstammSlice` works — until two workspaces have a file with the same stem, then use the
|
||||
path.
|
||||
|
||||
**Two tiers, like Java/Natural.** Project creation and `refresh` without `--deep` run the **Tier-1
|
||||
regex outline** in Java: module shell (`sourceHash`, `loc`/`sloc`), one `FUNCTION` per top-level
|
||||
function / arrow / class (`kind` = `function` | `component` | `hook` | `thunk` | `styled` | `class`,
|
||||
`exported`), one `DATA_STRUCTURE` per `interface`/`type`/`enum` (`dataType` says which), and a
|
||||
`REFERENCES` edge per import to the module it resolves to (`value` = the import clause, `specifier`
|
||||
= as written). npm packages are not placeholders; they are listed on the module as
|
||||
`externalImports`. **A deep pass** (`refresh --deep`, `ingest` by name) runs the **Node sidecar**
|
||||
(`ac-parser-typescript/sidecar/extract.mjs`, TypeScript compiler API, one whole-program run per npm
|
||||
workspace, 3–5 s and ~0.5 GB each on the pur frontend) and replaces the outline with the checker's
|
||||
view: exact positions, imports resolved against the file system, and **calls**:
|
||||
|
||||
- a callee owned by a top-level declaration of the same file → `FUNCTION -CALLS-> FUNCTION`;
|
||||
- a callee in another module → `MODULE -CALLS-> MODULE` carrying `callKind` (`METHOD_CALL`, or
|
||||
`CONSTRUCTOR` for `new`), `callerFn` (the calling function, absent at module level), `calleeMethod`
|
||||
(the owning top-level declaration in the target), `callSyntax` (`call`/`new`/`tagged`/`jsx` — a JSX
|
||||
element `<HistorieDrawer/>` is a call), and on a member call `receiver` (type of the innermost
|
||||
object, e.g. `AgstammControllerEndpoint`) and `member` (`saveBroker.post`);
|
||||
- calls into npm packages and the language library are **not** edges.
|
||||
|
||||
These are the exact properties the Java parser writes, so `callers`/`callees`/`call-tree`,
|
||||
`functions/{fn}/callers` (item 52) and the placeholder rewiring work unchanged. A module that got its
|
||||
Tier-2 pass carries `ingestTier=2`.
|
||||
|
||||
**Sidecar failure is visible, not silent.** If `node`, the script or its `node_modules/typescript`
|
||||
are missing, or a workspace run fails or times out, the refresh still completes at Tier-1 for those
|
||||
files and the response lists a failure with the pseudo-path `sidecar` or `sidecar:<workspace>`.
|
||||
Config: `agenticcode.typescript.node`, `.sidecar-script`, `.max-heap-mb` (1024), `.timeout-seconds`
|
||||
(600); the image carries node and the sidecar (`Dockerfile.jvm`), dev mode expects
|
||||
`npm ci` run once in `ac-parser-typescript/sidecar/`. The project root is read-only in the
|
||||
container; the sidecar reads the project's own `node_modules` for library typings and writes nothing.
|
||||
|
||||
**Scope of the pur frontend project.** The registered project covers the `pur-ui` and
|
||||
`pur-ui-common` workspaces only; `pur-r-vstamm` and `pur-r-vbuch` are excluded via `excludeDirs`,
|
||||
and the sidecar does not load an excluded workspace. Both workspaces call the `pur` backend through
|
||||
the legacy generated client (`generated/endpoints.ts`, backend `pur`); the hey-api client shape is
|
||||
recognised too but is not in scope.
|
||||
|
||||
**Resolution notes (verified on `purfe`, 2026-09-22).** An import of a workspace consumed through
|
||||
its package.json `exports` resolves into its build output (`pur-ui-common/dist/x.d.ts`); the sidecar
|
||||
maps that to the source twin (`pur-ui-common/src/x.ts`) so the edge lands on a real module. A bare
|
||||
specifier the checker resolves to neither a file nor a package (`immer`, `redux` — transitive
|
||||
dependencies the project does not list) is recorded as an external import, not a placeholder.
|
||||
|
||||
Transitive packages are known from `root/node_modules` (directory names), so Tier-1 treats them
|
||||
as external too.
|
||||
|
||||
**Stale parsed edges are reaped on a deep refresh (item 198).** Every edge the parser emits is
|
||||
stamped with the run's `ingestGen` at merge time; after a deep re-parse of a file, the edges from
|
||||
that file's nodes whose stamp is older than the run's (the fresh parse did not re-emit them) are
|
||||
deleted before the node sweep, for every language and edge type. An import or call the new parse
|
||||
names differently (a renamed class, a `dist`→`src` mapping fix) therefore no longer keeps its old
|
||||
placeholder alive next to the fresh edge, and a placeholder left edgeless falls to the usual
|
||||
placeholder sweep. Tier-1 (`changedOnly` or non-deep) refreshes do not reap, because a Tier-1 pass
|
||||
emits fewer edges than a deep one. Edges persisted before the stamp was introduced carry no
|
||||
generation and are never reaped; the first deep refresh after upgrading stamps them, the next one
|
||||
reaps — so a project never needs recreating after a parser change any more, two deep refreshes do.
|
||||
|
||||
**Known limits of 192** (the later items fill them): no field bindings (195), no styles (196);
|
||||
the store is item 194 below. A `changedOnly` refresh re-runs the sidecar over the
|
||||
whole workspace but re-persists only the changed files, so an unchanged file's facts can lag one
|
||||
refresh (same class of caveat as 46a). The by-name deep ingest resolves dependencies by file stem, so
|
||||
a dependency whose stem exists in several workspaces (`index`) is reported as a duplicate and skipped
|
||||
— use `refresh --deep` for the whole frontend.
|
||||
|
||||
## Web-service calls and the counterpart link (item 193)
|
||||
|
||||
**`rest-endpoints` lists the frontend's calls.** Every member of a generated Endpoint class
|
||||
(`AgstammControllerEndpoint.saveBroker`) and every hey-api sdk function is a `FUNCTION` of
|
||||
`kind=endpoint` carrying the same `restPath`/`httpMethod` the Java parser writes for a handler, plus
|
||||
`outbound=true` — so `GET /projects/purfe/rest-endpoints` answers with `outbound: true` rows
|
||||
(`handler` = `AgstammControllerEndpoint.saveBroker`, `path` = `/agstamm/ui`). Who calls it:
|
||||
`modules/{generated module}/callers` names the calling slices/components (module level), and the
|
||||
member call `api.saveBroker.post(...)` is retargeted from the generic `PostMethod.post` signature to
|
||||
the endpoint function, so the module-to-module `CALLS` edge carries `calleeMethod =
|
||||
AgstammControllerEndpoint.saveBroker` and `callerFn = <thunk>`. Since item 197
|
||||
`functions/{fn}/callers` joins these module-to-module edges back to the calling function, so
|
||||
`purfe/modules/pur-ui/src/generated/endpoints/functions/GeneralAgreementUiControllerEndpoint.createNew/callers`
|
||||
names the thunk in `generalAgreementSlice`, and on the Java side
|
||||
`pur/modules/…AgstammLogic/functions/handleMerge/callers` names `AgstammController.mergeBroker`
|
||||
(a REST controller method itself has no Java callers — it is the HTTP entry point). Extra properties on the node (
|
||||
`GET /nodes/{id}`): `restUrl` (as composed,
|
||||
with placeholders and query string), `restBase` (an application base such as `/pur-r-vbuch/v1`
|
||||
split off so paths compare with the backend's base-less `@Path`), `backend` (`pur`, `pur-r-vstamm`,
|
||||
`dynamic` — from the URL builder), `queryParams`, `requestType`, `responseType`, `paramsType`,
|
||||
`generator`, `owner`, `member`. A generated interface's properties are `FIELD`s under its
|
||||
`DATA_STRUCTURE`, **named `<Interface>.<member>`** (`Broker.ebene`; props `field` = the bare member,
|
||||
`owner` = the interface — since item 195: a node's identity is type + name + file, and one generated
|
||||
file declares hundreds of interfaces, so a bare `vid` used to be a single node under six interfaces),
|
||||
so `GET /data-structures/AgstammUseCase/fields` answers for the frontend too (bare member names;
|
||||
since item 195 — before, the query filtered `FIELD` out and returned `[]` for an interface), and
|
||||
`counterparts?kind=field` rows are named `Broker.ebene`.
|
||||
|
||||
**`COUNTERPART_OF`: the same thing in another project.** A project setting
|
||||
`counterparts: ["pur"]` (`ac project create purfe … --counterpart pur`, `ac project update purfe
|
||||
--counterpart pur`, `GET /projects/purfe` shows it) makes enrichment link, after every refresh of
|
||||
either side:
|
||||
|
||||
| this project | → counterpart | matched on |
|
||||
|-----------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| outbound endpoint `FUNCTION` | backend handler `FUNCTION` | `httpMethod` + path shape (every `{param}` segment compares as `{}`, class + method `@Path` composed as `rest-endpoints` does); several matches → the handler whose source lives under the frontend's `backend` name |
|
||||
| `DATA_STRUCTURE` in a `generated=true` module | Java `MODULE` with the same `simpleName` | unique name only; an ambiguous name stays unlinked |
|
||||
| its `FIELD`s | the class's `FIELD`s | name |
|
||||
|
||||
The edges are rebuilt from scratch on each run (never accumulated) and re-run for the frontend when
|
||||
the **backend** refreshes, because a refreshed handler node is deleted together with the edges
|
||||
pointing at it. Roadmap item 143 (Natural ↔ Java counterparts) will use the same edge.
|
||||
|
||||
`GET /projects/{p}/counterparts?module=&kind=rest|dto|field&unmatched=&countOnly=&limit=&offset=`
|
||||
(CLI `ac counterparts [--module] [--kind] [--unmatched] [--count-only]`) lists
|
||||
`{kind, name, module, sourceFile, startLine, httpMethod, path, counterpartProject, counterpartName,
|
||||
counterpartModule, counterpartSourceFile, counterpartStartLine}`; the `counterpart*` fields are null
|
||||
for an unlinked row, and **`unmatched=true` is the planning question**: which calls does nothing
|
||||
serve, which generated DTOs / fields have no backend twin. Paged with `X-AC-Total-Count` /
|
||||
`X-AC-Truncated` like the search endpoints; unknown `kind` → `400 KIND_UNSUPPORTED`; a project
|
||||
naming itself as counterpart → `400 COUNTERPART_SELF`.
|
||||
|
||||
## The Redux store (item 194)
|
||||
|
||||
**What is modelled.** Every `createSlice` / `createAppSlice` in a `typescript` project is a
|
||||
`STORE_SLICE` node **named by the reducer key it is mounted under** in the project's
|
||||
`configureStore` (`state.<key>`; `gruppenprovision` for `generalAgreementSlice`, whose RTK name is
|
||||
`generalAgreement`) — the key is what every selector path starts with, so it is the identity; the
|
||||
RTK name is kept as `sliceName`. The sidecar traces each `reducer: { key: xReducer }` entry through
|
||||
`xReducer = xSlice.reducer` (also `export default xSlice.reducer`) back to the slice, across
|
||||
workspaces; a slice no store mounts is named by its own `sliceName`. Under the slice, one **store
|
||||
`FIELD` per top-level state key**, named `<key>.<field>` (`schluesseltabelle.sucheStatus`, from the
|
||||
checker's type of `initialState`, so keys only the state type declares are present too) with
|
||||
`dataType` = the TS type, `optional`, `store=true`, `slice`, `field`. Every reducer is a **`FUNCTION`
|
||||
of `kind=reducer`** in the slice's module, named by the **action type it handles**:
|
||||
`schluesseltabelle/updateX` for a case reducer (`reducerKind=reducer`), `schluesseltabelle/suche/fulfilled`
|
||||
for `builder.addCase(sucheByServer.fulfilled, …)` (`reducerKind=case`, `trigger` = the expression),
|
||||
`schluesseltabelle/matcher:isSlicePending(sliceName)` for `addMatcher` (`reducerKind=matcher`). A
|
||||
thunk whose lifecycle action a case handles `CALLS` that case (`callSyntax=extraReducer`), when both
|
||||
live in the same file.
|
||||
|
||||
**Reads and writes.** Inside a reducer every `state.a.b` chain is a `READS`/`WRITES` edge from the
|
||||
reducer `FUNCTION` to the store `FIELD` of its first key (`state` alone → the `STORE_SLICE`), carrying
|
||||
`path` (the full sub-path as written, `keyTableUseCaseSvcResult.result.tableId`), `lineNo`,
|
||||
`via=reducer`. A write is an assignment target (compound assignments also read), `++`/`--`, `delete`,
|
||||
a mutating method on the chain (`push`, `splice`, `set`, `delete`, …), `Object.assign(state.x, …)`,
|
||||
or a `return { … }` (each key written) / `return other` (the whole slice). Outside reducers every
|
||||
store read is a `READS` edge from the reading function (component, hook, thunk; the module when at
|
||||
top level) to a **placeholder** `<key>.<field>` that the finalize step `resolve-store-placeholder`
|
||||
redirects onto the real field by name and then drops — so a read in `KeyTablePage.tsx` lands on the
|
||||
field declared in `keytableSlice.ts` without either file knowing the other. Recognised read forms:
|
||||
`useSelector`/`useAppSelector((state) => state.a.b)` (every chain rooted at the arrow's parameter;
|
||||
`const { x, y } = useAppSelector((s) => s.a)` reads `a.x` and `a.y`), **wrapper hooks** such as
|
||||
`useSchluesseltabelleSelector((useCase) => useCase?.result?.purMode)` — the sidecar finds the wrapper's
|
||||
inner `useAppSelector((state) => selector(state.a.b))`, so the read is `a.b.result.purMode` with
|
||||
`via=useSchluesseltabelleSelector` (the wrapper's own inner read is recorded too) — and
|
||||
`store.getState().a.b` / `const s = thunkAPI.getState(); s.a.b` chains (`via=getState`; 81 sites on
|
||||
`pur-ui`). A read of a key the store does not declare stays a placeholder and is listed by
|
||||
`search/identifier` with an empty `sourceFile`.
|
||||
|
||||
**Dispatch.** A call to a slice action creator (`dispatch(updateX(…))`, a binding of
|
||||
`xSlice.actions`) carries `actionType = <sliceName>/updateX` and is a cross-module `CALLS` edge to the
|
||||
slice module with `calleeMethod = <sliceName>/updateX` — the reducer `FUNCTION` — so module
|
||||
`callees` of a component list the slices it dispatches into; a thunk call keeps the thunk `FUNCTION`
|
||||
as target and carries the thunk's type prefix as `actionType`. (Function-level exposure of these
|
||||
cross-module edges is item 197.)
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/store?slice=` (CLI `ac store [--slice]`) → `[{slice, sliceName,
|
||||
module, sourceFile, startLine, endLine, stateType, fields: [{name, type, optional, reads, writes}],
|
||||
reducers, reads, writes}]`, all slices or one by reducer key / RTK name.
|
||||
`GET /projects/{p}/store/{slice}/accesses?field=&mode=reads|writes&module=&countOnly=&limit=&offset=`
|
||||
(CLI `ac store-accesses <slice> [--field] [--mode] [--module] [--count-only]`) → `[{mode, slice,
|
||||
field, path, function, functionType, functionKind, module, sourceFile, lineNo, via}]`, one row per
|
||||
access site, `field` null for a whole-slice access; paged like `counterparts`; unknown `mode` →
|
||||
`400 MODE_UNSUPPORTED`, unknown slice → `404 SLICE_NOT_FOUND`. Because store fields are `FIELD`
|
||||
nodes with a unique name, the generic `GET /variables/<key>.<field>/reads|writes` and
|
||||
`search/identifier?name=<key>.<field>` answer too.
|
||||
|
||||
**Limits.** Reads through `getState()` aliases are followed only inside the file that created the
|
||||
alias; a thunk→case `CALLS` edge is emitted only when thunk and slice share a file (the pur slices
|
||||
do); a case whose trigger cannot be folded to an action type (a predicate matcher) is named by its
|
||||
expression text; the sidecar reads the store of every workspace in scope — a key mounted only by a
|
||||
workspace outside the project (`excludeDirs`) falls back to the slice's own name (matches on `pur`).
|
||||
No `USES_TYPE` from a store field to the DTO it holds yet — the field's `dataType` says
|
||||
`SvcResult<KeyTableUseCase>`, the link is item 195. Only a **top-level** `createSlice` is a slice: a
|
||||
slice built inside a factory function (`pur-ui-common`'s `filetransferSlice`, created per instance
|
||||
and not mounted in the `pur-ui` store) is not modelled.
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 318, recreated + `refresh --deep`, 285 files, 0
|
||||
failures, 0 placeholders left):** 9 slices — `error`, `global`, `healthTables`, `metadata`
|
||||
(pur-ui-common) and `gruppenprovisionSuche`, `gruppenprovision` (RTK name `generalAgreement`),
|
||||
`schluesseltabelle`, `multilinguism`, `translationdata` (pur-ui) — 43 reducer functions, 216 read
|
||||
and 82 write sites. `schluesseltabelle` alone: 62 sites, 25 through `useSchluesseltabelleSelector`,
|
||||
19 through `getState()`, 5 through `useAppSelector`, 13 in reducers.
|
||||
|
||||
## DTO field bindings (item 195)
|
||||
|
||||
**What a binding is.** The generator emits, next to every DTO interface, a `Fields` class tree
|
||||
(`AgstammUseCaseField = new AgstammUseCaseFields<AgstammUseCase, never>()`, members
|
||||
`broker = new BrokerFields<TRoot, Broker>(this, "broker")`, list members as `keyTableList = (index?) =>
|
||||
new KeyTableDOFields(...)`). A path expression on it — `AgstammUseCaseField.broker.ebene` on a
|
||||
`<SmartInput field={…}>`, `<SmartOutput field={…}>`, a table's `fieldTermForRowData`, a column's
|
||||
`field:`, or a `Fields`-typed prop such as `useCaseFieldPrefix` — is a binding. The sidecar types
|
||||
every hop through the checker (`XFields<TRoot, TSelf>`): the **root DTO** is `TRoot`, the **owner**
|
||||
of the leaf is the `TSelf` of the hop before it, the leaf name is the field. Each binding becomes a
|
||||
`READS` edge (plus a `WRITES` edge when the component tag matches `Input$|Dropzone$|Editor$`) from
|
||||
the binding function (component/hook; the module at top level) to the **`FIELD` of the generated
|
||||
interface** that item 193 already creates (`Broker` → `ebene`), carrying `path` (dotted hops from the
|
||||
root, `brokerList[]` for a list hop), `rootDto`, `kind`, `partial`, `component`, `attribute`,
|
||||
`via=binding`, `lineNo`.
|
||||
|
||||
- `kind=field`: the leaf is a scalar (`ebene`); `kind=prefix`: a whole sub-object is handed on
|
||||
(`useCaseFieldPrefix={X.tab.translationData}`, `fieldTermForRowData={X.keyTableList()}`) —
|
||||
recorded as a read of the container field so nothing is silently dropped.
|
||||
- `partial=true`: the expression is rooted at a prop or local (`props.useCaseFieldPrefix.dataName`,
|
||||
`tabPrefix.x(idx).gausVal`), so the leaf and its owner are exact but the prefix of the path is
|
||||
unknown (only the tail is given). A carrier prop's own name is not part of the path.
|
||||
|
||||
Cross-file targets are placeholders `<Dto>.<field>` (`binding=true`, `owner`, `field`,
|
||||
`targetModule`) resolved by the finalize step `resolve-binding-placeholder` **exactly** — module →
|
||||
`DATA_STRUCTURE` → `FIELD` — and dropped afterwards; a leaf the interface does not declare stays a
|
||||
placeholder (listed by `search/identifier` with empty `sourceFile`). The item-74 stale-edge sweep
|
||||
covers binding targets on a deep re-ingest.
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/bindings?dto=&field=&mode=reads|writes&module=&partial=&countOnly=&limit=&offset=`
|
||||
(CLI `ac bindings [--dto] [--field] [--mode] [--module] [--partial] [--count-only]`) → one row per
|
||||
binding site: `{mode, dto, field, path, rootDto, kind, partial, component, attribute, function,
|
||||
functionType, functionKind, module, sourceFile, lineNo, counterpartProject, counterpartModule,
|
||||
counterpartField}`. The `counterpart*` columns are the field's `COUNTERPART_OF` twin (item 193), so
|
||||
**"which page edits Java `Broker.ebene`"** is `ac bindings --dto Broker --field ebene --mode writes
|
||||
-p purfe` and needs no query on the backend project. `dto` is the *declaring* interface (`Broker`),
|
||||
not the root (`AgstammUseCase`) — filter on `rootDto` client-side when you need the latter. Paged like
|
||||
`counterparts`; unknown `mode` → `400 MODE_UNSUPPORTED`. `GET /data-structures/{dto}/fields` now
|
||||
returns TypeScript interface fields (`type=FIELD`) and carries `boundReads`/`boundWrites` per field
|
||||
(0 for Natural/Java). The generic `variables/<Interface>.<field>/reads|writes` sees binding edges
|
||||
too. The leaf's owner is the interface that *declares* the member — `datStart` bound through
|
||||
`GeneralAgreementDO` lands on `AbstractHistorizedDO.datStart`.
|
||||
|
||||
**Limits.** String-form `field="…"` bindings and the lodash-path bindings of `pur-r-vbuch` (excluded
|
||||
workspace) are not modelled; a `partial` binding cannot say which list element or tab; no
|
||||
`USES_TYPE` from the component to the root DTO (the `rootDto` edge property answers that). A
|
||||
binding placeholder and a store placeholder share the `FIELD` type, so a DTO named exactly like a
|
||||
store key would merge their placeholders (`Dto.field` vs `key.field`) — not the case on `pur`.
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 322, recreated + `refresh --deep`, 285 files, 0
|
||||
failures):** 257 binding sites (196 reads, 61 writes; 226 scalar leaves, 31 prefixes; 84 partial)
|
||||
over 23 declaring DTOs, every one linked to its `pur` counterpart field; `SmartInput` 122,
|
||||
`SmartOutput` 64, tables 32, column definitions 36. `counterparts?kind=field`: 744 fields, exactly
|
||||
one twin each (six-fold fan-out before the qualified names), 112 unmatched.
|
||||
|
||||
## Styling: theme tokens and the style inventory (item 196)
|
||||
|
||||
**The theme.** The file with `createTheme({...})` (`pur-ui-common/src/theme.ts`) gets a
|
||||
`DATA_STRUCTURE theme` (`kind=theme`) with one `FIELD` per token: `theme.<path>` for every leaf of the
|
||||
literal (`palette.primary.dark`, `typography.h1.fontWeight`, `shape.borderRadius`, `sizes.*`; props
|
||||
`token`, `tokenKind=path`, `value` folded through constants — `#0054A2` — and `constant` when the
|
||||
leaf names one, `PRIMARY_DARK`) and `theme.<NAME>` for each exported string/number constant of that
|
||||
file (`tokenKind=constant`, `PRIMARY`). MUI `components.styleOverrides` are recorded as tokens too, not
|
||||
interpreted.
|
||||
|
||||
**Style blocks.** Every `sx={…}`, `style={…}` and `styled(X)(…)` block is a `STYLE` node under its
|
||||
component `FUNCTION` (a `styled` block under the `kind=styled` function), named
|
||||
`<function>.<sx|style|styled>@<line>:<col>`, with `styleKind`, `element` (the JSX tag or styled base:
|
||||
`Box`, `'div'`), `properties` (the CSS keys, nested selectors flattened: `&:hover.color`,
|
||||
`& .MuiPaper-root.background`), `literals` (hard-coded colours/lengths: `#005CA9`, `17px`, `-2%`,
|
||||
`calc(100% - 16px)` — `mt: 2` is theme-relative and no literal), `dynamic` (a value the sidecar could
|
||||
not classify, or a whole `sx={props.sx}`), `spread`. Plain `.css` files get one `STYLE` per rule from
|
||||
the Tier-1 scanner (`body@7`, `@font-face@2`; `styleKind=css`, `selector`, `properties`, `literals`).
|
||||
|
||||
**Token reads.** Inside a style block every chain on a theme value is a `REFERENCES` edge `STYLE →
|
||||
theme.<token>` with `property` (the CSS key it feeds) and `via=theme`; a theme value is anything typed
|
||||
`Theme` (`useTheme()`, a `({ theme }) =>` styled parameter, the theme object imported under any name)
|
||||
or named `theme`. `theme.spacing(2)` ends at `spacing`, `theme.palette.grey['200']` is
|
||||
`palette.grey.200`; a theme constant (`color: PRIMARY`) references `theme.PRIMARY`. A token read outside
|
||||
a style block (`borderColor={theme.palette.grey['200']}`, code) is the same edge from the enclosing
|
||||
`FUNCTION` with `context` = the JSX attribute or `code`. Cross-file targets are placeholders
|
||||
`theme.<token>` (`theme=true`) resolved by exact name at finalize when exactly one theme declares the
|
||||
token; **a token no theme declares keeps its placeholder on purpose** — `GET /theme` lists it with
|
||||
`declared=false` (MUI defaults such as `palette.grey.200`, `palette.common.white`, the `spacing`
|
||||
function, or a typo).
|
||||
|
||||
**Endpoints.** `GET /projects/{p}/theme?unused=` (CLI `ac theme [--unused]`) → `[{token, kind, value,
|
||||
constant, declared, module, lineNo, uses}]`, declared tokens first. `uses` counts **project**
|
||||
references (style blocks and code); MUI's own consumption of a token is invisible, so `unused=true`
|
||||
means "no project code references it", never "safe to delete" (`palette.primary.main` colours every
|
||||
Button whether or not a component names it). `GET /projects/{p}/theme/{token}/usages` (`ac
|
||||
theme-usages palette.primary.dark`, token = dotted path or constant name) → `[{function,
|
||||
functionKind, module, sourceFile, lineNo, styleKind, element, property, context}]`; unknown token →
|
||||
`404 TOKEN_NOT_FOUND`.
|
||||
`GET /projects/{p}/styles?module=&kind=sx|style|styled|css&withLiterals=&countOnly=&limit=&offset=`
|
||||
(`ac styles [--module] [--kind] [--with-literals]`) → `[{name, styleKind, element, selector,
|
||||
function, module, sourceFile, lineNo, properties, literals, dynamic, tokens}]`, paged like
|
||||
`counterparts`; **`withLiterals=true` is the review question**: which blocks hard-code colours and
|
||||
lengths instead of using the theme. Unknown `kind` → `400 KIND_UNSUPPORTED`.
|
||||
|
||||
**Several themes (item 199).** Every `createTheme({...})` in a file is read; a token two themes of
|
||||
one file declare is one row with the first theme's value and `variants: 2`. A token declared by two
|
||||
theme *files* (light/dark) is one row per file, and a read of it resolves onto both, so each row
|
||||
counts the use and `?unused=true` stays honest; `theme/{token}/usages` and `styles[].tokens`
|
||||
report such a read once. Two reads of one token on one line of a style block (`color: PRIMARY,
|
||||
borderColor: PRIMARY`) are one usage whose `property` is the comma list of the keys they feed.
|
||||
CSS rules: a block-less `@import`/`@charset` line is not part of the next selector, braces inside
|
||||
string values do not open blocks, a nested rule head inside an at-rule body is not a declaration,
|
||||
and a selector repeated on one line (minified CSS) gets a `:col` suffix in its name.
|
||||
|
||||
**Limits.** `className` strings are not matched to CSS rules; Emotion `css` templates and MUI
|
||||
`styleOverrides` are not modelled; a token read from a component-level function (not a style
|
||||
block) that survives a re-parse keeps its resolved edge until the file's nodes are re-created
|
||||
(same class as item 198).
|
||||
|
||||
**Verified on `purfe` (2026-09-22, server 326, recreated + `refresh --deep`, 285 files, 0
|
||||
failures):** 129 declared tokens (111 paths, 18 constants), 93 of them with no project reference;
|
||||
12 undeclared tokens the code reads (`palette.common.white` 4, `palette.grey.200` 4, `spacing` 3,
|
||||
`palette.divider`, `palette.text.secondary`, `applyStyles`, `transitions.create`, …);
|
||||
`palette.primary.dark` is the most-used token (26 reads: 17 `style`, 3 `sx`, 1 `styled`, 5 as a
|
||||
plain prop such as `confirmColor`). Style inventory: 304 blocks (196 `sx`, 78 `style`, 27 `styled`,
|
||||
3 CSS rules), 109 with hard-coded literals (`100%` 29, `1px` 21, `12px` 14, `17px` 14, …), 75
|
||||
reading theme tokens, 41 dynamic. The only placeholders left in the project are the 12 undeclared
|
||||
theme tokens — by design.
|
||||
|
||||
## Missing capability?
|
||||
|
||||
If the API/CLI genuinely cannot answer a question (not just
|
||||
|
||||
@@ -20,6 +20,11 @@ store cost ~935k of 1.6M dbHits (58%) in a profiled `upms` traversal, because th
|
||||
an 11-property chain, whereas a label lives in the node record. Query sites move to `:LABEL` one at
|
||||
a time under measurement (roadmap 111b); the property is dropped only once none read it (111c).
|
||||
|
||||
**Generation stamps.** Nodes and, since item 198, every parser-emitted edge carry `ingestGen`,
|
||||
the persist run that last wrote them (a re-emitted edge is re-stamped through its MERGE key). A
|
||||
deep refresh deletes a re-parsed file's edges whose stamp is older than the run's; edges the
|
||||
finalize builds (resolved counterparts, links) carry no stamp unless copied from a parser edge.
|
||||
|
||||
Writing your own Cypher: prefer `MATCH (n:MODULE {project: $p})` over
|
||||
`MATCH (n:AstNode {type: 'MODULE', project: $p})` — both are correct today, the first is cheaper.
|
||||
|
||||
@@ -70,6 +75,78 @@ graph LR
|
||||
`DB_ACCESS -USES_TYPE-> DB_TABLE` (for `sql-statements`) — the same shape Natural uses.
|
||||
- Java emits no `DATA_STRUCTURE` edges.
|
||||
|
||||
## TypeScript / React — `TypeScriptParser` (item 192)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
M[MODULE<br/>.ts / .tsx file — name = root-relative path without extension]
|
||||
C[MODULE<br/>.css file — name keeps .css]
|
||||
F[FUNCTION<br/>function / component / hook / thunk / styled / class]
|
||||
D[DATA_STRUCTURE<br/>interface / type / enum]
|
||||
M -->|CONTAINS|F
|
||||
M -->|CONTAINS|D
|
||||
M -->|" REFERENCES (import; value = clause, specifier) "|M
|
||||
F -->|" CALLS — same file, callSyntax=call|new|tagged|jsx "|F
|
||||
M -->|" CALLS — cross-module, callKind=METHOD_CALL|CONSTRUCTOR,<br/>callerFn, calleeMethod, receiver, member "|M
|
||||
```
|
||||
|
||||
- Module properties: `simpleName`, `workspace`, `moduleKind` (`ts`/`tsx`/`css`), `generated`,
|
||||
`generator`, `externalImports` (npm packages, comma list — never placeholders), `sourceHash`,
|
||||
`loc`/`sloc`, `ingestTier=2` after a sidecar pass.
|
||||
- `FUNCTION.kind` and `exported`; `DATA_STRUCTURE.dataType` = `interface`/`type`/`enum`.
|
||||
- Tier-1 (regex, Java) emits the shells and import placeholders; Tier-2 (Node sidecar on the
|
||||
TypeScript compiler API) replaces declarations/imports from the checker and adds the `CALLS`
|
||||
edges, shaped exactly like the Java parser's so every call-graph query and the placeholder
|
||||
rewiring apply unchanged. A `slice`/`const` declaration is not a node in 192 (item 194).
|
||||
- **Item 193:** a generated web-service call is a `FUNCTION` with `kind=endpoint`, `outbound=true`,
|
||||
`httpMethod`, `restPath` (base-less, like a Java handler's composed `@Path`), `restBase`, `restUrl`,
|
||||
`backend`, `queryParams`, `requestType`, `responseType`, `generator`, `owner`, `member`; an
|
||||
interface's properties are `FIELD`s under its `DATA_STRUCTURE` (`dataType` = the TS type,
|
||||
`optional`). A member call on an Endpoint instance carries `calleeMethod = <Class>.<member>`.
|
||||
- **`COUNTERPART_OF`** (item 193, cross-project, built by enrichment, never by a parser):
|
||||
endpoint `FUNCTION` → handler `FUNCTION` in a counterpart project (`via=rest`),
|
||||
generated `DATA_STRUCTURE` → Java `MODULE` of the same simple name (`via=dto`),
|
||||
`FIELD` → `FIELD` by name (`via=field`). Rebuilt on every refresh of either project; the
|
||||
project node carries the `counterparts` list.
|
||||
- **Item 194 (Redux store):**
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
M[MODULE<br/>slice file]
|
||||
S[STORE_SLICE<br/>name = reducer key, sliceName, stateType, store=true]
|
||||
SF[FIELD<br/>name = key.field, dataType, optional, store=true, slice, field]
|
||||
R[FUNCTION<br/>kind=reducer, reducerKind=reducer|case|matcher|default,<br/>name = action type, slice, trigger, actionType]
|
||||
T[FUNCTION<br/>kind=thunk]
|
||||
C[FUNCTION<br/>component / hook — any file]
|
||||
M -->|CONTAINS|S
|
||||
M -->|CONTAINS| R
|
||||
S -->|CONTAINS|SF
|
||||
R -->|" READS / WRITES — path, lineNo, via=reducer "|SF
|
||||
R -->|" READS / WRITES — whole state "|S
|
||||
T -->|" CALLS — callSyntax=extraReducer, actionType "|R
|
||||
C -->|" READS — path, lineNo, via=useAppSelector|wrapper hook|getState "|SF
|
||||
```
|
||||
|
||||
A consumer's read is emitted against a placeholder `<key>.<field>` (`sourceFile=""`, `store=true`)
|
||||
and redirected by the finalize step `resolve-store-placeholder` onto the real field of the same
|
||||
type and name (unique by construction), which then drops the placeholder. A dispatched action
|
||||
creator is a cross-module `CALLS` edge with `actionType` and `calleeMethod` = the reducer's name.
|
||||
|
||||
- **Item 195 (DTO field bindings):** `FUNCTION`(component/hook) `-READS->` / `-WRITES->` the generated
|
||||
interface's `FIELD` (`Broker` → `ebene`), edge props `path` (`broker.ebene`), `rootDto`, `kind`
|
||||
(`field`/`prefix`), `partial`, `component`, `attribute`, `via=binding`, `lineNo`. Cross-file targets
|
||||
are `FIELD` placeholders `<Dto>.<field>` with `binding=true`, `owner`, `field`, `targetModule`,
|
||||
resolved exactly (module → structure → field) by `resolve-binding-placeholder` and then dropped.
|
||||
- **Item 196 (styling):** the theme file's `DATA_STRUCTURE theme` (`kind=theme`) `-CONTAINS->`
|
||||
`FIELD theme.<token>` (`theme=true`, `token`, `tokenKind=path|constant`, `value`, `constant`);
|
||||
`FUNCTION`(component) `-CONTAINS->` `STYLE` (`styleKind=sx|style|styled`, `element`, `properties`,
|
||||
`literals`, `dynamic`, `spread`; a CSS `MODULE -CONTAINS-> STYLE` per rule with `styleKind=css`,
|
||||
`selector`); `STYLE -REFERENCES-> FIELD theme.<token>` (`via=theme`, `property`) and
|
||||
`FUNCTION -REFERENCES-> FIELD theme.<token>` (`via=theme`, `context`). Cross-file token targets are
|
||||
placeholders resolved by exact name by `resolve-theme-placeholder`; an undeclared token keeps its
|
||||
placeholder (`declared=false` in `GET /theme`).
|
||||
- No `DB_*` for TypeScript.
|
||||
|
||||
## Natural — `NaturalParser`
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -5474,6 +5474,296 @@ reproduced the bug.)*
|
||||
**Historical `commit` values in these docs are not comparable with the ones printed from here on** —
|
||||
they were the sum of all three parts. Noted in the javadoc as well.
|
||||
|
||||
## Styling: theme tokens and the style inventory — item 196 (2026-09-22)
|
||||
|
||||
- [x] **196. Styling: theme-token usage and sx/styled inventory**
|
||||
|
||||
MUI v6 + Emotion: 244 `sx={}`, 27 `styled()`, 11 `className`, one theme in
|
||||
`pur-ui-common/src/theme.ts`, three `index.css` (fonts + body reset). Decided scope: theme-token
|
||||
usage and per-component inline inventory; plain `.css` only as `MODULE` with a `STYLE` per
|
||||
selector. `theme.ts` → `DATA_STRUCTURE` with a `FIELD` per token (`palette.primary.dark`,
|
||||
`spacing`, …); each `sx`/`styled`/`style` block → `NodeType.STYLE` under the component with its
|
||||
CSS property keys and `REFERENCES` to the tokens it uses; hard-coded literals (`#005CA9`, `16px`)
|
||||
recorded as `literals`. Answers: where is a token used, which tokens are dead, which components
|
||||
bypass the theme, which components override `height`/`zIndex`. `GET /modules/{name}/styles`,
|
||||
`GET /projects/{p}/styles/theme-usage?token=`; `ac styles`, `ac theme-usage`. Static only — no
|
||||
cascade or rendered-layout claims.
|
||||
|
||||
**Implemented 2026-09-22.** As planned, with the VALIDATE adjustments: three theme-root forms
|
||||
(`Theme`-typed values, `{ theme }` styled parameters, the theme object imported under any name);
|
||||
`uses` counts project references only and `unused` is documented as "no project reference", never
|
||||
"dead" (MUI consumes tokens itself); tokens the code reads that no theme declares keep their
|
||||
placeholder and are listed with `declared=false` (MUI defaults, `spacing`, typos); `sx={props.sx}`
|
||||
is a `dynamic` block; nested selectors flatten to `&:hover.color`; compound values yield their
|
||||
literal parts (`1px solid #D2D2D2` → `1px`, `#D2D2D2`). Endpoints `GET /theme`,
|
||||
`GET /theme/{token}/usages`, `GET /styles`; CLI `ac theme`, `ac theme-usages`, `ac styles`; CSS
|
||||
rules from the Tier-1 scanner (`STYLE` per rule). Sidecar contract version 4 (`themeTokens`,
|
||||
`styles`, `tokenRefs`). Sidecar dry run on `pur-ui-common`: 129 tokens (111 paths, 18 constants),
|
||||
169 style blocks (87 sx, 55 style, 27 styled; 66 with literals, 42 reading tokens), 89 token reads
|
||||
outside blocks. Verified in `StylesIT` and on `purfe` (server 326, recreate + deep refresh): 129
|
||||
declared tokens, 12 undeclared ones the code reads, 304 style blocks (109 with hard-coded literals,
|
||||
75 reading tokens), `palette.primary.dark` read 26 times; no placeholder left except the undeclared
|
||||
tokens. The item-195 inherited-field fix is confirmed on the same run (its 8 placeholders are gone).
|
||||
|
||||
## Styling robustness — item 199 (2026-09-22)
|
||||
|
||||
- [x] **199. Styling review findings: several themes, repeated token reads, CSS scanner edge cases**
|
||||
|
||||
From the code review of item 196. (1) The sidecar read only the first `createTheme` per file and
|
||||
the resolver demanded exactly one declaring token, so a light/dark pair in one file lost the dark
|
||||
tokens and a pair of theme files left every shared token an unresolved placeholder with
|
||||
`?unused=true` reporting used tokens as unused. Now every call is read (one fact per token and
|
||||
file, first value wins, `variants` counts the themes), and the placeholder resolver redirects a
|
||||
read onto every real token of the name; `theme/{token}/usages` and `styles[].tokens` deduplicate
|
||||
the fan-out. (2) Two reads of one token on one line of a style block merged into one edge that
|
||||
kept only the last key; the parser now groups them and `property` is the comma list. (3) The CSS
|
||||
rule scanner: quotes are tracked so a `content: "{"` no longer unbalances the depth counter and
|
||||
drops every following rule; a `;` at depth zero ends a block-less at-statement so `@import` no
|
||||
longer leaks into the next selector; a nested rule head inside an at-rule body is stripped before
|
||||
declaration matching (`a:hover {` was read as property `a`); a selector repeated on one line
|
||||
(minified CSS) gets a `:col` suffix instead of collapsing.
|
||||
|
||||
**Tests.** `TypeScriptCoarseScannerTest.cssRulesSurviveAtStatementsStringsNestingAndMinification`,
|
||||
the dark theme and the doubled `PRIMARY` read in the parser fixture (facts regenerated, contract
|
||||
still 4 — `variants` is optional), `StylesIT` with a second theme file asserting both rows count
|
||||
the read and neither is unused.
|
||||
|
||||
## Function-level callers across modules — item 197 (2026-09-22)
|
||||
|
||||
- [x] **197. `functions/{fn}/callers` cannot see cross-module calls — Java and TypeScript alike**
|
||||
|
||||
A cross-module call (Java cross-class, TypeScript import + call, an item-193 endpoint call from a
|
||||
thunk) is a `MODULE -CALLS-> MODULE` edge carrying `callerFn` and `calleeMethod`; the function-level
|
||||
callers query followed only direct `FUNCTION -CALLS-> FUNCTION` edges (Natural `PERFORM`, same-class
|
||||
Java), so `…/AgstammLogic/functions/handleMerge/callers` answered `[]` although the module-level
|
||||
`callers` listed `AgstammController`. (The roadmap's own Java example, a REST controller method, has
|
||||
no Java callers because it is the HTTP entry point; the gap showed on the logic class it calls.)
|
||||
|
||||
**Fix.** Query only, no enrichment: `FUNCTION_CALLERS` gained a second `UNION` branch that joins the
|
||||
module edges into the target module on `calleeMethod = callee.name` and resolves `callerFn` to the
|
||||
FUNCTION of the calling module, honouring `manualHidden`; the same-module branch is untouched. Rows
|
||||
keep the `CallRefResponse` shape (`edgeKind` = the edge's `callKind`, sites from `lineNo` + the
|
||||
caller module's file). Name matching over-approximates overloads, and a call from top-level code with
|
||||
no enclosing function has no row here (the module-level `callers` still shows it). REST path and
|
||||
`ac function-callers` unchanged.
|
||||
|
||||
**Test.** `FunctionCallersCrossModuleIT`: a Java method called from its own class and from another
|
||||
class lists both callers with their lines; a TypeScript function called from a component in another
|
||||
module lists the component. `AnalysisResourceIT` (Natural PERFORM callers, zero-caller case) stays
|
||||
green.
|
||||
|
||||
**Verified on `pur`/`purfe`** (server 328, no re-ingest): `AgstammLogic.handleMerge` →
|
||||
`mergeBroker` at line 98; `GeneralAgreementUiControllerEndpoint.createNew` → the thunk in
|
||||
`generalAgreementSlice` at line 92 — both empty before.
|
||||
|
||||
## Stale parsed edges are reaped on a deep refresh — item 198 (2026-09-22)
|
||||
|
||||
- [x] **198. Stale edges to placeholder modules survive a re-parse for Java and TypeScript**
|
||||
|
||||
Found while verifying item 193 on `purfe`: after the sidecar fix that maps `pur-ui-common/dist/x`
|
||||
to `…/src/x`, a deep refresh still showed 1 022 `CALLS`/`REFERENCES` edges into 46 `dist`
|
||||
placeholders next to the fresh `src` edges. The per-file reconcile (item 58) sweeps stale *nodes*
|
||||
of a re-parsed file, but the stale-*edge* reaps were Natural-only, so an edge from a surviving
|
||||
module that the new parse no longer produces lived forever — and its placeholder, having an edge,
|
||||
escaped the placeholder sweep. Only recreating the project cleared it.
|
||||
|
||||
**Fix.** `mergeEdgesBatch` stamps `r.ingestGen = $ingestGen` on every parser-emitted edge (a
|
||||
re-emitted edge is re-stamped through its MERGE key). A new language-agnostic step
|
||||
`reap-stale-parsed-edges` (`CypherQueries.DELETE_STALE_PARSED_EDGES`) runs after `merge-edges`
|
||||
and before `sweep-stale-file-nodes`, keyed on the item-160 `(sourceFile, ownerModule)` pairs of
|
||||
the re-parsed files, and deletes every edge from those nodes whose stamp is older than the run's.
|
||||
Deep only (`reconcile`), like the node sweep. Finalize-built edges carry no stamp unless a resolver
|
||||
copied it from a parser edge, and the deep finalize that follows rebuilds those. The three Natural
|
||||
reaps stay (they run *before* the merge and gate on statement kinds). Pre-existing edges without a
|
||||
stamp are never reaped: the first deep refresh after the upgrade stamps, the second reaps — no
|
||||
project recreation needed any more; the usage doc's "recreate after a parser change" note is retired.
|
||||
|
||||
**Test.** `StaleParsedEdgeReapIT`: a TypeScript component retargeted from `b` to `c` loses
|
||||
`app/src/b` from `callees` while an untouched file keeps it; a retarget onto a missing
|
||||
`./missing/d` mints a placeholder that disappears once the import is retargeted again; the same
|
||||
for a Java class switching its call target from `B` to `C`. Existing reap ITs
|
||||
(`StaleCallEdgeReapIT`, `StaleTableEdgeReapIT`, `RefreshReconciliationIT`) and the whole server IT
|
||||
suite stay green.
|
||||
|
||||
## DTO field bindings — item 195 (2026-09-22)
|
||||
|
||||
- [x] **195. DTO field binding: which component reads/writes which backend field**
|
||||
|
||||
No mappers exist: `*UseCase` DTOs sit verbatim in Redux as `SvcResult<T>`. Bindings are typed
|
||||
path expressions (`<SmartInput field={AgstammUseCaseField.broker.ebene}/>`, generated `Fields`
|
||||
classes) resolved by the sidecar to the dotted path `broker.ebene` and linked to the `FIELD` of the
|
||||
DTO interface; `pur-r-vbuch` binds lodash paths into the whole state. `SmartInput` → `WRITES`,
|
||||
`SmartOutput` and plain reads → `READS`, from the component `FUNCTION` to the `FIELD`. With 193's
|
||||
`COUNTERPART_OF` on the `DATA_STRUCTURE` this answers "which page edits `AgstammUseCase.broker.ebene`"
|
||||
across the frontend/backend boundary. `GET /data-structures/{name}/fields` gains `boundBy`
|
||||
counts; `ac data-structure-fields` follows.
|
||||
|
||||
**Implemented 2026-09-22.** As planned, with these decisions from VALIDATE: the target is the
|
||||
generated interface's `FIELD` (declaring DTO `Broker`, not the root), reached through a
|
||||
`binding=true` placeholder resolved exactly by module → structure → field (the generic resolver
|
||||
never resolves module-owned structures, item 74); every hop is typed by the checker
|
||||
(`XFields<TRoot, TSelf>`), list hops are the call's result type; a prop-rooted expression is
|
||||
`partial` and its carrier prop is not part of the path; `kind=prefix` records a handed-on
|
||||
sub-object as a read of the container field; WRITES for tags matching `Input$|Dropzone$|Editor$`.
|
||||
One endpoint `GET /bindings` (with the field's `COUNTERPART_OF` columns) instead of per-field
|
||||
endpoints; `data-structures/{dto}/fields` gained `boundReads`/`boundWrites` and — a bug found on the
|
||||
way — now returns TypeScript `FIELD`s at all (the query filtered them out; item 193's doc claim was
|
||||
wrong). A second 193 flaw surfaced on real data: interface `FIELD`s were named by the bare member,
|
||||
and the node identity is type + name + file, so `vid` was ONE node under six interfaces of the
|
||||
generated file (six `COUNTERPART_OF` twins, six-fold binding rows). Fields are now
|
||||
`<Interface>.<member>` with props `field`/`owner`; `data-structures/{dto}/fields` and `bindings`
|
||||
report the bare member, `counterparts` the qualified name. Sidecar contract version 3 (`bindings`). Sidecar dry run on
|
||||
`pur-ui`: 204 bindings (174
|
||||
field, 30 prefix; 50 partial) over 22 DTOs — 61 `SmartInput`, 64 `SmartOutput`, 23
|
||||
`fieldTermForRowData`. Verified in `BindingsIT` (frontend + backend, counterpart columns, counts,
|
||||
no placeholder left) and on `purfe` (server 322, recreate + deep refresh): 257 sites over 23 DTOs,
|
||||
all linked to `pur`; `counterparts?kind=field` 744 fields with exactly one twin each. A leaf
|
||||
inherited from a base interface (`datStart` on `AbstractHistorizedDO`) resolves to the declaring
|
||||
interface — the single-hop case was fixed after that run and is covered by the next deploy.
|
||||
|
||||
## The Redux store — item 194 (2026-09-22)
|
||||
|
||||
- [x] **194. Store: `STORE_SLICE` + `FIELD`, `READS`/`WRITES`/`CALLS` from reducers, selectors, dispatch**
|
||||
|
||||
Redux Toolkit: 16 `createSlice`/`createAppSlice` files, thunks via `createAppAsyncThunk` named
|
||||
`<slice>/<op>`, status via `isSlicePending/Fulfilled/Rejected` matchers, three-hop access
|
||||
slice → facade hook (`useAgstamm`, `useAgstammSelector`) → component; `pur-r-vbuch` selects by
|
||||
lodash path into the whole state. New `NodeType.STORE_SLICE` per slice with `FIELD` children named
|
||||
slice-qualified (`agstamm.agstammUseCaseSvcResult`) so `/variables/{name}/reads|writes` and
|
||||
`flow-forward` work unchanged. Reducer assignments → `WRITES`, selectors → `READS`,
|
||||
`dispatch(action)` → `CALLS` to the reducer/thunk `FUNCTION`. The store field is **not** flattened
|
||||
into the DTO: it holds `SvcResult<X>` and `USES_TYPE` the DTO `DATA_STRUCTURE`.
|
||||
`GET /projects/{p}/store`, `GET /projects/{p}/store/{slice}/fields/{field}/reads|writes`;
|
||||
`ac store`, `ac store-reads`, `ac store-writes`. `context` extended with store reads/writes.
|
||||
|
||||
**Implemented 2026-09-22.** Deviations from the plan above, decided while reading the real store:
|
||||
the slice is named by its **reducer key** (`gruppenprovision`), not the RTK name
|
||||
(`generalAgreement`) — the key is what every selector path starts with; the store mapping is
|
||||
traced by the sidecar through `xReducer = xSlice.reducer` / `export default`, across workspaces.
|
||||
Reducers are `FUNCTION`s named by the **action type** they handle (`schluesseltabelle/updateX`,
|
||||
`schluesseltabelle/suche/fulfilled`, `…/matcher:isSlicePending(sliceName)`), so a dispatched
|
||||
action's `calleeMethod` is the reducer. Reads come from three forms: selector arrows (incl.
|
||||
destructured results), **wrapper hooks** (`useSchluesseltabelleSelector`, resolved to their base
|
||||
path) and `getState()` chains (81 sites on `pur-ui`). Instead of one endpoint per field, one
|
||||
`GET /store` (slices with fields and counts) and one `GET /store/{slice}/accesses?field=&mode=`;
|
||||
CLI `ac store`, `ac store-accesses`. `USES_TYPE` field → DTO deferred to 195 (the field's
|
||||
`dataType` already says `SvcResult<X>`); `context` is not extended (the accesses endpoint and
|
||||
`variables/<slice>.<field>/reads|writes` answer). Sidecar facts contract bumped to **version 2**
|
||||
(`slices`, `store`, `stateAccesses`, `calls[].actionType`). Cross-file reads are placeholders
|
||||
`<key>.<field>` (`store=true`) resolved by a new cheap finalize step in every mode; the item-74
|
||||
stale-edge sweep covers store targets on a deep re-ingest. Verified on the fixtures (sidecar,
|
||||
parser, reader tests) and end-to-end in `StoreIT`; on `purfe` (server 318, recreate + deep refresh, 29 s): 9 slices,
|
||||
43 reducers, 216 reads / 82
|
||||
writes, no placeholder left; the reducer key ≠ slice name case (`gruppenprovision` /
|
||||
`generalAgreement`) resolves correctly. Not modelled: a slice created inside a factory function
|
||||
(`filetransferSlice`), which is also not mounted in the store.
|
||||
|
||||
## Web-service calls and the counterpart link — item 193 (2026-09-22)
|
||||
|
||||
- [x] **193. Webservice calls: outbound `rest-endpoints` on the frontend and `COUNTERPART_OF` to `pur`**
|
||||
|
||||
Two generated client generations exist: legacy `generated/*endpoints.ts` classes (one per backend
|
||||
controller, `baseUrl` + `get`/`post` members, `AgstammControllerEndpoint.saveBroker.post(...)`)
|
||||
and, in `pur-r-vbuch`, a hey-api `sdk.gen.ts` (URL literal per function, consumed via TanStack
|
||||
Query hooks and thunks). Both become `FUNCTION`s carrying `restPath` (composed from `baseUrl` +
|
||||
member path), `httpMethod`, `outbound=true`, `requestType`, `responseType` — the same properties
|
||||
the Java parser writes (item 130), so `GET /rest-endpoints` lists the frontend's outbound calls
|
||||
with no new endpoint. Thunks and components reach them through ordinary `CALLS`.
|
||||
|
||||
New `EdgeType.COUNTERPART_OF` and a counterpart enrichment step (Cypher in `CypherQueries`, driven
|
||||
like the other steps — there is no `GraphEnricher` SPI): outbound frontend `FUNCTION` → `pur`
|
||||
handler matched on `httpMethod` + `restPath`; generated `DATA_STRUCTURE` → Java class by simple
|
||||
name; `FIELD` → `FIELD` by name. Per-file reconcile `DETACH DELETE`s incoming edges, so the step
|
||||
re-runs at the end of every refresh of either project; a project setting `counterparts: [..]`
|
||||
names the partner. First cross-project MERGE in the codebase. `GET /projects/{p}/counterparts?
|
||||
module=&limit=&offset=` + `ac counterparts`. Same edge serves item 143.
|
||||
|
||||
**Implemented 2026-09-22.** Sidecar (`extract.mjs`, contract v1 + additive `endpoints` and
|
||||
`declarations[].members`): the legacy generator's Endpoint classes (`baseUrl` + `get`/`post`
|
||||
members, URL template reconstructed from `build<Backend>URL(`…`)`, `{param}` for substitutions,
|
||||
request/response/params types from the member's type arguments) and hey-api sdk functions
|
||||
(`url:` literal, verb from the client call). Parser: `TypeScriptRestPaths` splits an application
|
||||
base (`/<name>/v<n>`) off and strips the query string; endpoint `FUNCTION`s carry
|
||||
`restPath`/`httpMethod`/`outbound` exactly like Java handlers, so `rest-endpoints` lists them with
|
||||
no query change beyond `outbound = f.outbound = 'true'`; interface members become `FIELD`s; a
|
||||
member call on an Endpoint instance is retargeted to the endpoint function
|
||||
(`calleeMethod = AgstammControllerEndpoint.saveBroker` on the module-to-module edge; note that
|
||||
`functions/{fn}/callers` does not follow such edges for Java either — item 197). Store: `EdgeType.COUNTERPART_OF`,
|
||||
project property `counterparts` (create/update/get/list, REST `ProjectRequest.counterparts`, CLI
|
||||
`--counterpart`, self-reference → `400 COUNTERPART_SELF`), enrichment steps
|
||||
`delete-counterpart-edges` / `link-counterparts-rest` (path shape key: `{param}` → `{}`, class +
|
||||
method `@Path` composed as `REST_ENDPOINTS` does — keep in step) / `link-counterparts-dto` /
|
||||
`link-counterparts-field`, run after every finalize for the project **and for every project
|
||||
listing it** (`COUNTERPART_HOLDERS`), the first cross-project MERGE in the codebase.
|
||||
`GET /projects/{p}/counterparts` + `ac counterparts` (`--kind`, `--unmatched`, `--count-only`,
|
||||
paging). `CounterpartsIT` builds a Java backend and a TypeScript frontend as two projects and
|
||||
pins: outbound rows, call → handler incl. `{vermnr}` shape, `unmatched` = the one call nothing
|
||||
serves, DTO and field links, survival of a backend refresh, the setting and the two 400s.
|
||||
Scope note: the registered frontend is `pur-ui` + `pur-ui-common` (backend `pur`, base `''`), so
|
||||
the base-stripping matters only for the excluded vstamm/vbuch clients. **Verified on the deployed
|
||||
server (version 312) 2026-09-22:** `purfe` deep refresh 29 s (sidecar 6.1 s + 8.6 s for the two
|
||||
workspaces), 285 files, 0 failures; `rest-endpoints` 51 outbound rows, **51 of 51 linked** to `pur`
|
||||
handlers, 243 DTOs and 632 fields linked; the 52 unmatched DTOs are mirrors of JDK/framework types
|
||||
(`Class`, `Comparable`, `Annotation`, …) with no class in `pur`. Two defects found on real data and
|
||||
fixed the same day: imports of `pur-ui-common` resolved into its `dist` typings (now mapped to the
|
||||
`src` twin) and transitive packages (`immer`, `redux`) became placeholders (now external imports;
|
||||
Tier-1 reads `node_modules` directory names as externals). After the fix (server 314, project
|
||||
recreated because of item 198): 0 edges into `dist`, 7 placeholders left (from the Tier-1 pass
|
||||
before the node_modules rule), counterparts unchanged 51/51.
|
||||
|
||||
## TypeScript / React analysis — item 192 (2026-09-22)
|
||||
|
||||
- [x] **192. `ac-parser-typescript`: language wiring, coarse scan, sidecar, import/call graph, LoC**
|
||||
|
||||
New Maven module `ac-parser-typescript` implementing `LanguageParser`, `CoarseScanner` and
|
||||
`LineCounter` for language `typescript` (`.ts`/`.tsx`) plus `css` (`.css`). Frontend registered
|
||||
as project `purfe`. Delivers `MODULE` per file, `FUNCTION` per exported function / React component
|
||||
/ hook (property `kind`), `CALLS` and `REFERENCES` from imports and call sites, `DATA_STRUCTURE` +
|
||||
`FIELD` per exported interface/type (generated ones carry `generated=true` and
|
||||
`javaCounterpart=<simpleName>`), and LoC/SLoC so `GET /loc?language=typescript` works.
|
||||
|
||||
Validated design decisions (do not re-derive):
|
||||
- **Sidecar** = `ac-parser-typescript/sidecar/` (own `package.json`, pinned `typescript`, built in a
|
||||
`node:24-slim` image stage and copied into `Dockerfile.jvm` together with the `node` binary). The
|
||||
JVM starts it per workspace with `ProcessBuilder`, a timeout and `--max-old-space-size=1024`, reads
|
||||
a per-file JSON facts document from stdout, and the process ends. `noEmit`, no `incremental` (the
|
||||
project root is mounted `:ro`). `excludeDirs` (`node_modules`, `dist` by default) applies to the
|
||||
file walk only; the sidecar still reads the project's `node_modules` for library typings.
|
||||
- **Project context** like `CopycodeLibrary`: a `TypeScriptFacts` built once per ingest and passed
|
||||
into every per-file `parse()`; the Tier-1 coarse scanner is pure Java regex and needs no Node.
|
||||
- **`SourceFiles.Language` gains `TYPESCRIPT` and `CSS`**, and every `== JAVA` / "else Natural"
|
||||
branch (`AstIngestService.parse/coarseScan/count`, `ProjectIngestService` ~223 / ~1054, the seven
|
||||
`language: 'natural'|'java'` literals in `CypherQueries`) becomes an exhaustive switch.
|
||||
`ProjectResource.SUPPORTED_LANGUAGES` gains `typescript`.
|
||||
- **Anonymous nodes** (a `sx` block, a store write) use the `startLine` MERGE variant like
|
||||
`DB_ACCESS`, named `<owner>@<line>`.
|
||||
- `docker-compose.yml`: `mem_limit` 4g → 5g, frontend root mounted `:ro`.
|
||||
- Fixtures under `ac-parser-typescript/src/test/resources/fixtures/typescript/`; the JSON facts
|
||||
format is the tested contract between the two halves, so Java unit tests need no Node.
|
||||
- Accepted: a `changedOnly` refresh re-runs the sidecar over the whole workspace but re-persists
|
||||
only the changed files; unchanged files' facts may lag one refresh (same class as item 46a).
|
||||
|
||||
**Implemented 2026-09-22.** New module `ac-parser-typescript` (`TypeScriptCoarseScanner`,
|
||||
`TypeScriptParser`, `TypeScriptLineCounter`, `CssLineCounter`, `TypeScriptModuleNames`,
|
||||
`TypeScriptProject`, `TypeScriptFacts` + `TypeScriptFactsReader`, `TypeScriptSidecar`) and the
|
||||
sidecar `ac-parser-typescript/sidecar/extract.mjs` (pinned `typescript` 5.9.3, `npm ci`; the JSON
|
||||
facts contract v1 is documented at the top of the script and pinned by the checked-in
|
||||
`facts-pur-r-vstamm.json`, which `TypeScriptSidecarTest` regenerates live and compares). Server:
|
||||
`SourceFiles.Language` gained `TYPESCRIPT`/`CSS` with exhaustive switches in `AstIngestService`
|
||||
(`parse`/`coarseScan`/`count`); `SourceFiles.ingestedBy` gates TypeScript/CSS to `typescript`
|
||||
projects; `DEFAULT_EXCLUDE_DIRS` = `target, node_modules, dist`; `TypeScriptSidecarService` builds
|
||||
the per-ingest context (Tier-1: package.json only; deep: sidecar per workspace, failures reported
|
||||
as `sidecar:<workspace>` pseudo-paths); the copycode stand-down of item 129 is now Natural-only by
|
||||
name. `ProjectResource.SUPPORTED_LANGUAGES` and `ac project create --language` accept
|
||||
`typescript`. `Dockerfile.jvm` is a two-stage build (node + sidecar copied from `node:24-slim`),
|
||||
the compose build context moved to the repository root with a root `.dockerignore`,
|
||||
`mem_limit` 4g → 5g, the frontend root mounted `:ro`. Config `agenticcode.typescript.*` in
|
||||
`application.properties` (`%prod` points into the image). Measured on the real frontend: 413
|
||||
files, 15 s for all four workspaces, imports resolved 99.5 % (the rest: `index.css`, two deep
|
||||
`moment` locale paths). Decision taken while implementing: a CSS module keeps its `.css` in the
|
||||
identity, because `index.css` next to `index.ts` would otherwise collide on `…/src/index` and be
|
||||
skipped as a duplicate. 35 unit tests in the module, 232 across the build.
|
||||
|
||||
## Closing the performance campaign (items 173-175) — 2026-09-06
|
||||
|
||||
- [x] **173. Where the deep refresh stands after items 153-172, and what is left** (written 2026-09-06)
|
||||
|
||||
@@ -239,6 +239,25 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
|
||||
Neither the node cost nor the Natural side (does the same gap exist for long `MOVE`/`COMPRESS` text?)
|
||||
has been measured.
|
||||
|
||||
- [ ] **181. `callees` / `callers` truncate silently at 50 rows — no header, no body flag** (found
|
||||
2026-09-17, `PartnerCopy` Java↔Natural verification)
|
||||
|
||||
```
|
||||
GET /upms/modules/DPARTFN0/callees → 50 items (17 MODULE), no X-AC-Truncated header
|
||||
GET /upms/modules/DPARTFN0/callees?limit=1000 → 58 items (25 MODULE)
|
||||
GET /upms/modules/DPARTFN0/callees?scope=external → complete
|
||||
```
|
||||
The unscoped default drops `YPARTBN0`, `YPARTGNH`, `YPARTMN0`, `YPARTMNH`, `YPHONBNH`, `YPHONMNH`,
|
||||
`ZINCLGET`, `ZINERR01` — among them the module that actually writes the partner. `digest` lists them,
|
||||
and `callers` of `YPHONMNH` does include `DPARTFN0`, so the analysing agent reported it as an
|
||||
*inconsistency* between endpoints; it is the 50-row page, and nothing in the response says so.
|
||||
|
||||
Item 131 added `X-AC-Total-Count` / `X-AC-Truncated` to the search endpoints (item 135 to two more);
|
||||
`callees`/`callers` return an object with `sourceFiles`/`items` and carry neither the headers nor a
|
||||
`truncated` field. A wrong answer, not a missing one: the caller cannot tell a complete fan-out from a
|
||||
cut one. Either send the headers (and a `truncated` field, as `call-tree` does since item 67) or
|
||||
return all rows when `limit` is absent, as `db-accesses` does.
|
||||
|
||||
## Agent API gaps
|
||||
|
||||
- [ ] **108. `dispatch-table` only understands the `DECIDE` dispatcher, not the dispatch-*table* idiom —
|
||||
@@ -413,6 +432,181 @@ after probing and are recorded at the end, so nobody re-files them.
|
||||
MODULE_NOT_FOUND`), while `viaCopycode`/`includePath` on other responses hand back exactly that
|
||||
name. Accepting a copycode name would close the loop.
|
||||
|
||||
Normal priority. Reported 2026-09-17 after verifying `PartnerController.insertPartnerCopyHauptwohnsitz`
|
||||
(`pur`) against the Natural service it replaces, `WPARTX1S` → `WPARTD1S` → `DPARTFN0`/`DPARTEN0`/`YPARTMNH`
|
||||
plus the nested ADDR level `WADDRX0S` → `WADDRD1S` → `DADDRFN0`/`DADDREN0` (`upms`), with the old adapter
|
||||
`UpmsPvwPartnerHauptwohnsitzInsert` (`app`). Four agents compared mapping, validation, main address and
|
||||
sample-partner copy; every gap below forced a fall-back to reading source. Two further gaps from the same
|
||||
run are not re-filed: *"which Java method implements which Natural rule"* is item **143**, and *"is message
|
||||
4136 raised on the Java side at all"* is item **149** — both were needed in this run (two missing 4136
|
||||
rules were found only by reading `PartnerValidator`). The silent 50-row cut on `callees` is item **181**
|
||||
under Known bugs.
|
||||
|
||||
- [ ] **182. No call order and no guard conditions — "does X run before Y, and only on ADD?" cannot be
|
||||
asked**
|
||||
|
||||
The two most consequential findings of the run were ordering questions:
|
||||
* `DPARTFN0.DO-ACTION` → `PROCESS-OBJECT` → `BEFORE-ET` → `COPY-ADDR-COMM-BANK-DATA` runs **inside** the
|
||||
PART object call, i.e. before `WPARTX1S.CALL-NEXT-LEVEL-PUT` inserts the main address. The Java does it
|
||||
the other way round, so `DADDREN0.CHECK-DOUBLES` (8012) sees different data.
|
||||
* Are `ACCESS-MDCL`, `CHECK-RELEASE`, `VMDCLN01`, `YMDCLMN0` reached on `#ADD`? They are not —
|
||||
`DPARTFC0.cpy` sends `C-MOD-ADD` to `WHEN NONE → CALL-MAINTAIN` — but `digest`, `callees` and
|
||||
`call-tree` list them unconditionally.
|
||||
|
||||
`callees` returns a set with `lineNos`; `call-tree` a depth-annotated set. Neither says in which order a
|
||||
function performs its callees, nor under which `DECIDE`/`IF` branch. The `DECIDE` guard already exists on
|
||||
`dispatch-table` rows and `CONTROL_FLOW` nodes exist (P2-a), but no query joins them to a call edge.
|
||||
|
||||
**Proposed shape.** `GET /modules/{name}/functions/{fn}/sequence` → ordered
|
||||
`[{ lineNo, kind: PERFORM|CALLNAT|INCLUDE, target, guards: [{ construct, condition }] }]`, and an optional
|
||||
`?guard=CDAOBJ2.#FUNCTION=C-MOD-ADD` on `call-tree` that prunes branches whose literal guard contradicts
|
||||
the value. Item 111e (per-statement `CONTROL_FLOW`) and item 75 (`CONTAINS` traversal cost) are the
|
||||
constraints; item 142 (`live`) is the sibling question for commented-out `PERFORM`s. Report a guard that
|
||||
cannot be evaluated statically as `null`, never as satisfied.
|
||||
|
||||
- [ ] **183. Transaction boundaries are not graph data**
|
||||
|
||||
*"Is the partner rolled back when the address fails?"* needed four files: `WPARTX1S:230` forces
|
||||
`P-OPT-NO-ET := 'X'`, `WPARTD1S:356` passes it on, `USIX044C.cpy` suppresses `END TRANSACTION` in
|
||||
`DPARTFN0` (`#OMIT-ET`) and `YPARTMN0`, and `WPARTX1S:235-240` finally does one `END TRANSACTION` or
|
||||
`BACKOUT TRANSACTION` for the whole PUT. No endpoint returns ET/BT sites, let alone the flags that
|
||||
suppress them.
|
||||
|
||||
**Proposed shape.** `GET /modules/{name}/transactions?depth=N` →
|
||||
`[{ module, function, lineNo, kind: END|BACKOUT, guards, viaCopycode, via }]`, guards as in item 182.
|
||||
The Java counterpart (`@RunInTransaction`, `@Transactional`) is the annotation half of item 190.
|
||||
|
||||
- [ ] **184. Data flow between modules called one after another, and qualified PDA names, return an empty
|
||||
`200`**
|
||||
|
||||
* `W-WIF-A1.P-NIF-PERSONA` is written in `WPARTD1S:374` and read in `WADDRD1S:1056`. Both are called in
|
||||
sequence from `WPARTX1S` with the same PDA by reference; neither calls the other. `field-flow` finds no
|
||||
edge, because it pairs a producer with consumers reachable **via `CALLS` from the producer**.
|
||||
* `MSG-INFO.##MSG-NR` travels `ISINSOIN` (writes 4218) → `DPARTEN0` → `DPARTFN0` → `YPARTMNH`
|
||||
(`RESET MSG-INFO`). This chain is how an invalid social insurance number is silently accepted by
|
||||
Natural — the single most surprising finding of the run — and it was found by reading.
|
||||
|
||||
Probes (all `200 []`):
|
||||
```
|
||||
GET /upms/variables/NIF-PERSONA-SAMPLE/flow-backward?module=DPARTFN0
|
||||
GET /upms/variables/%23%23MSG-NR/flow-forward?module=ISINSOIN
|
||||
GET /upms/variables/MSG-INFO.%23%23ERROR-FIELD/reads?module=DPARTEN0 (unqualified ##ERROR-FIELD → 21 rows)
|
||||
```
|
||||
Item 146 covers the positional argument→parameter hop; this is the case beside it — **sibling calls
|
||||
sharing a by-reference PDA**, where the producer's caller is the consumer's caller. Two asks:
|
||||
(a) derive producer→consumer pairs across sibling `CALLNAT`s of one caller (order-imprecise is acceptable
|
||||
if flagged, as `field-flow` already is); (b) a qualified name `GROUP.FIELD` that resolves to nothing
|
||||
should answer `404` or a hint, not an empty `200` that reads as "nobody reads it".
|
||||
|
||||
- [ ] **185. Validation rules are not extractable — per action: condition, message, error field, return
|
||||
code**
|
||||
|
||||
Rebuilding `DPARTEN0`'s rule list for `#ADD` (≈ 40 rules) and `DADDREN0`'s (≈ 12) was the bulk of the
|
||||
run, entirely by reading. The rules live half in copycode — `ISI173C1` (4080/4216), `ISICMAND` (126),
|
||||
`USIX058C` (8011) — and `dispatch-table` shows only some of the expanded assignments. Item 46a already
|
||||
splices copycode at ingest with `&n&` substitution, so the graph has the material; there is no view on it.
|
||||
|
||||
What decides correctness is not the message number alone but **what the caller does with it**:
|
||||
`DPARTEN0:217-222` raises only `IF MSG-INFO.##ERROR-FIELD NE ' '`, so `ISINSOIN`, which sets `##MSG-NR`
|
||||
without an error field, never stops the insert. A rule list without the "error field set / return code
|
||||
set" columns would have missed it.
|
||||
|
||||
**Proposed shape.** `GET /modules/{name}/rules?action=ADD` →
|
||||
`[{ lineNo, viaCopycode, includedAt, condition, msgNr, errorField, returnCodeSet, escape }]`, plus
|
||||
`GET /modules/{name}/source?expandCopycode=true&includedAt=<line>` for the substituted text of one
|
||||
include. Pairs naturally with item 149 (the Java side of the same message number).
|
||||
|
||||
- [ ] **186. Assignments carry no formats — implicit conversions are invisible; `MOVE BY NAME` is not
|
||||
resolved**
|
||||
|
||||
* `DPARTFN0:480 YPARTMA1.NIF-PERSONA := VNUMEGAA.P-NUMBER` assigns `N12` to `A14`. Whether the new
|
||||
partner id is `000000012345` or `12345` decides whether the Java port (`String.valueOf(…)`) is correct —
|
||||
rated critical in the run, but left a hypothesis for lack of exactly this information.
|
||||
`variables/NIF-PERSONA/writes` returns
|
||||
`assignedValue: "VNUMEGAA.P-NUMBER"` and nothing about either format.
|
||||
* `DPARTFN0:1063/1114/1165/1216 MOVE BY NAME Y…ROW TO Y…MA1` copies every same-named field, including the
|
||||
sort keys `VAL-SK-*` and `LOG-*-END` that the Java recomputes or resets. Which fields match had to be
|
||||
worked out by diffing two `data-structures/…/fields` responses by hand.
|
||||
|
||||
**Proposed shape.** On `writes`: `sourceDataType`, `targetDataType` and `conversion`
|
||||
(`N→A zero-padded`, `A→N`, `truncating`, …) where both sides resolve. For `MOVE BY NAME`:
|
||||
`GET /variables/{struct}/move-by-name?module=&lineNo=` → matched field pairs, plus later writers of each
|
||||
target field in the same module.
|
||||
|
||||
- [ ] **187. XML web-service routing and the tag contract are not queryable**
|
||||
|
||||
P1-m resolves `W-MNT-N0 → KDWWIFN0 → Wxxxx*S` into edges, but the **routing key** is lost:
|
||||
`GET /upms/modules/WPARTX1S/callers` → `[W-LST-N0, W-MNT-N0]` (`CALLNAT_DYNAMIC`), with no trace that
|
||||
objects `PartnerCopy` **and** `PartnerAddress` both route here (`KDWWIFN0.nat:888-891`). The file
|
||||
`x-docs/kdwwifn0-prog-routing.csv` already holds this mapping outside the graph.
|
||||
|
||||
The second half crosses projects: the old adapter (`app`) emits tags such as `cod_salut_id`, `ind_copyable`
|
||||
(always `'0'`/`'1'` when a Vermittler exists — the root of a Java 8011 regression) and `cod_addrtype_id`
|
||||
= `"1"`; `WPARTD1S` consumes them in `DECIDE ON #W-TAG`. Which request field reaches which PDA field was
|
||||
reconstructed from both sources.
|
||||
|
||||
**Proposed shape.** (a) the routing key on the dynamic edge or `dispatch-table` rows
|
||||
(`{ objectName, adapter, target }`); (b) `GET /modules/{name}/xml-tags` → consumed tags with target field
|
||||
and line; the producing side in `app` rides on item 143's cross-project edge, not a traversal.
|
||||
|
||||
- [ ] **188. `sql-statements` cannot be filtered to one call site**
|
||||
|
||||
*"Which statement, and which `ORDER BY`, does the browse at `DPARTFN0:1048` run?"* (`YADDRBNH`, two
|
||||
leading key components). `sql-statements?depth=2` on `DPARTFN0` returns 555 statements. Suggest
|
||||
`?via=YADDRBNH` and `?callSite=DPARTFN0:1048` (or `?function=COPY-ADDR-COMM-BANK-DATA`), returning only
|
||||
the statements reachable from there. The ordering mattered: Natural browses by
|
||||
`NUM_ADDRESS, DAT_START, V_ISN`, the Java repositories by number only.
|
||||
|
||||
- [ ] **189. Java `call-tree` and `db-accesses` on `pur` are too noisy to use for "what does this logic
|
||||
reach"**
|
||||
|
||||
```
|
||||
GET /pur/modules/PartnerCopyLogic/call-tree?depth=3 → 178 items, all type MODULE, among them var,
|
||||
String, Math, Integer, LOGGER, java.util.List,
|
||||
constants (COUNTRY_AT, PARTTYPE_LEGAL, …)
|
||||
GET /pur/modules/AddressLogic/db-accesses?depth=3 → 203 rows over 157 tables: 184 DECLARES,
|
||||
14 READS, 5 WRITES
|
||||
```
|
||||
Both agents on the Java side gave up and read the classes. Suggest excluding JDK/`java.lang` types,
|
||||
local-variable type names and constant holders from `call-tree` by default (a `?includeTypeRefs=true`
|
||||
escape hatch), and restricting `DECLARES` at `depth>0` to entities actually used by a reached repository
|
||||
method.
|
||||
|
||||
- [ ] **190. Annotation semantics — which interceptor runs, and what it does — are not reachable**
|
||||
|
||||
`@RunInTransaction(type = READ_WRITE)` and `@RequiresRole(roles = {})` on the endpoint decided two
|
||||
findings: whether an `IllegalArgumentException` after the partner store rolls it back, and whether the
|
||||
open role list matches Natural's `ZINXSEC` (it does). `search/annotation` finds the annotation sites but
|
||||
not the `@InterceptorBinding` → interceptor class → `@AroundInvoke` method chain. Suggest
|
||||
`GET /pur/annotations/{name}/interceptors` → `[{ interceptor, aroundInvoke, sourceFile, lineNo }]`; the
|
||||
rollback rule itself stays a source read, but finding the class should not be.
|
||||
|
||||
- [ ] **191. Reference data behind the code is out of reach** (low priority — may be out of scope)
|
||||
|
||||
Four findings stay hypotheses because they depend on DB content, not code: key-table formats
|
||||
(`IN-FORM-CLAVE`, `IND-CLIENT` — does `CODES-TO-INT` zero-pad this code?), `CONSTDAT` values per client
|
||||
(`NATPERS`, `AUSTRIA`, `NONTRADE`, `UNKTAXOF`, `MAINADDR`, hard-coded in the Java), whether `'00000001'
|
||||
IS (N1)` holds, and the INSTLDA routine suffix for number range `PART-NR`. Not a graph problem; filed so
|
||||
the boundary is explicit. If a reference-data export exists, a read-only `GET /reference-data/{table}`
|
||||
would turn four hypotheses into lookups.
|
||||
|
||||
## Frontend — React/TypeScript analysis (2026-09-22)
|
||||
|
||||
The `pur` backend (Java, ingested) is driven by a React/TypeScript frontend at
|
||||
`/home/ingo/deve/uniqa/pur-sources/frontend` (npm workspaces `pur-ui`, `pur-ui-common`,
|
||||
`pur-r-vstamm`, `pur-r-vbuch`; 253 `.tsx` + 213 `.ts`, ~31k hand-written lines). **Scope decided
|
||||
2026-09-22: only `pur-ui` and `pur-ui-common` are registered (project `purfe`, `excludeDirs`
|
||||
`pur-r-vstamm`, `pur-r-vbuch`); both use the legacy generated client against backend `pur`.** Nothing of it is
|
||||
in the graph. The questions that cannot be asked today: *which component reads/writes which store
|
||||
field*, *which backend endpoint does this page call*, *which DTO field is bound where*, *which
|
||||
theme token is used by whom*. Items 192–196 add a `typescript` language with a Tier-1 regex coarse
|
||||
scanner in Java and a Tier-2 **Node sidecar** on the TypeScript compiler API (whole-program,
|
||||
type-aware; measured 3–5 s and ~0.5 GB heap per workspace). Decided 2026-09-22 after PROPOSAL +
|
||||
VALIDATE. **192 (parser module, sidecar, wiring, import/call graph, LoC), 193 (web-service calls
|
||||
in `rest-endpoints`, `COUNTERPART_OF` to `pur`), 194 (the Redux store), 195 (DTO field bindings)
|
||||
and 196 (styling) are implemented — 2026-09-22, see `features.md`**; the validated design notes
|
||||
live there.
|
||||
|
||||
## Ingest performance
|
||||
|
||||
- [ ] **180. The `DOCUMENTS` edge carries two strings and nothing else — 20.2 % of all edges**
|
||||
|
||||
@@ -162,6 +162,16 @@ paging_check "search/annotation" "search/annotation?name=ApplicationScoped"
|
||||
paging_check "search/value" "search/value?value=project"
|
||||
paging_check "search/references" "search/references?name=Logger"
|
||||
paging_check "rest-endpoints" "rest-endpoints?"
|
||||
paging_check "counterparts" "counterparts?"
|
||||
# Item 194: the store endpoints answer for every project (an empty list on a non-TypeScript one).
|
||||
get "$API/projects/$PROJECT/store"
|
||||
check "store: answers 200 with a list" "$([[ $STATUS == 200 && $(head -c1 "$BODY") == '[' ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
# Item 195: bindings is a paged list on every project (empty on a non-TypeScript one).
|
||||
paging_check "bindings" "bindings?"
|
||||
# Item 196: theme and styles answer on every project (empty on a non-TypeScript one).
|
||||
get "$API/projects/$PROJECT/theme"
|
||||
check "theme: answers 200 with a list" "$([[ $STATUS == 200 && $(head -c1 "$BODY") == '[' ]] && echo 0 || echo 1)" "status=$STATUS"
|
||||
paging_check "styles" "styles?"
|
||||
|
||||
# --- 4. data plausibility -----------------------------------------------------------------
|
||||
group "4. Data plausibility"
|
||||
|
||||
Reference in New Issue
Block a user